mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
feat(app): management foundation - client exposure, manage hub, shared shell
Groundwork for the full admin surface on mobile: the API client gains the user-account and Claude-management types (skills, connectors, plugins, permissions, users/auth, owner fields) plus an unauthenticated authApi helper; AppState exposes the client and the new management screen names; ManageShell/Field/ErrorNotice and a useResource hook give the subscreens one shared page/fetch pattern; Settings gains Manage and Account rows leading to the new hub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
+12
-3
@@ -27,7 +27,12 @@ import {
|
||||
} from "@expo-google-fonts/spline-sans-mono";
|
||||
|
||||
import { useTheme } from "./src/theme/useTheme";
|
||||
import { AppStateProvider, useAppState } from "./src/state/AppState";
|
||||
import {
|
||||
AppStateProvider,
|
||||
useAppState,
|
||||
type Screen as ScreenName,
|
||||
} from "./src/state/AppState";
|
||||
import { ManageScreen } from "./src/screens/manage/ManageScreen";
|
||||
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
||||
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
||||
import { FleetScreen } from "./src/screens/FleetScreen";
|
||||
@@ -42,7 +47,9 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
|
||||
function Router() {
|
||||
const { screen } = useAppState();
|
||||
|
||||
const Screen = {
|
||||
// Partial while the management subscreens land one by one; anything unmapped
|
||||
// falls back to the fleet so a half-wired build never renders undefined.
|
||||
const screens: Partial<Record<ScreenName, () => React.JSX.Element>> = {
|
||||
connect: ConnectScreen,
|
||||
fleet: FleetScreen,
|
||||
detail: AgentDetailScreen,
|
||||
@@ -52,7 +59,9 @@ function Router() {
|
||||
memory: MemoryScreen,
|
||||
log: LogScreen,
|
||||
settings: SettingsScreen,
|
||||
}[screen];
|
||||
manage: ManageScreen,
|
||||
};
|
||||
const Screen = screens[screen] ?? FleetScreen;
|
||||
|
||||
return <Screen />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Project {
|
||||
root_dir: string;
|
||||
git_remote?: string | null;
|
||||
credential_ref?: string | null;
|
||||
/* Owning user account; null = shared/legacy (visible to everyone, admin-managed). */
|
||||
owner_user_id?: number | null;
|
||||
created_at: string;
|
||||
/* Present on the registration response in git-server mode: the enqueued clone. */
|
||||
sync_command_id?: number | null;
|
||||
@@ -142,6 +144,48 @@ export interface Schedule {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* ---- Claude management (the web dashboard's Claude page) ---- */
|
||||
|
||||
export interface ClaudeSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
/* Relative paths of auxiliary files captured by an install-from-prompt import
|
||||
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
|
||||
files: string[];
|
||||
owner_user_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
|
||||
export interface ClaudeConnector {
|
||||
id: number;
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
env?: Record<string, string> | null;
|
||||
url?: string | null;
|
||||
headers?: Record<string, string> | null;
|
||||
enabled: boolean;
|
||||
owner_user_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ClaudePlugin {
|
||||
id: number;
|
||||
name: string;
|
||||
marketplace: string;
|
||||
marketplace_repo: string;
|
||||
enabled: boolean;
|
||||
owner_user_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* A registered model backend: an Anthropic-API-compatible endpoint (a local model
|
||||
* behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next
|
||||
* to the Claude subscription. The API key is write-only server-side (has_api_key only). */
|
||||
@@ -157,9 +201,60 @@ export interface ClaudeModel {
|
||||
env?: Record<string, string> | null;
|
||||
enabled: boolean;
|
||||
has_api_key: boolean;
|
||||
owner_user_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
|
||||
export interface ClaudePermissions {
|
||||
default_mode?: string | null;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
ask: string[];
|
||||
base_mode: string;
|
||||
base_allow: string[];
|
||||
}
|
||||
|
||||
/* ---- user accounts (/auth) ---- */
|
||||
|
||||
export interface AuthStatus {
|
||||
initialized: boolean; // any account exists; false => show the first-run setup form
|
||||
smtp_configured: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
disabled: boolean;
|
||||
/* False until an invited user sets their password through their invite link. */
|
||||
has_password: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
kind: "user" | "token";
|
||||
user_id?: number | null;
|
||||
email?: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface UserCreated {
|
||||
user: User;
|
||||
invite_url: string;
|
||||
emailed: boolean;
|
||||
}
|
||||
|
||||
export interface ResetLink {
|
||||
reset_url: string;
|
||||
emailed: boolean;
|
||||
}
|
||||
|
||||
/* ---- agent memory (the note graph agents distill their learnings into) ---- */
|
||||
|
||||
export interface MemoryNote {
|
||||
@@ -237,6 +332,36 @@ function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/* Unauthenticated auth calls (status/login/setup/forgot/reset) — used by the connect
|
||||
* flow before any token exists, so they sit outside createClient. Unlike the web
|
||||
* client the endpoint is a parameter (the phone talks to a user-entered URL). */
|
||||
export async function authApi<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(normalizeBaseUrl(baseUrl) + path, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail: string = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && typeof j.detail !== "undefined") {
|
||||
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body; keep statusText */
|
||||
}
|
||||
const err = new Error(detail) as ApiError;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { radius, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { PageHeader } from "./PageHeader";
|
||||
import { useAppState, type Screen } from "../state/AppState";
|
||||
|
||||
/**
|
||||
* Page chrome shared by every management subscreen (Settings → Manage → …):
|
||||
* safe-area top, back header, title/subtitle, keyboard-aware scroll body.
|
||||
*/
|
||||
export function ManageShell({
|
||||
title,
|
||||
subtitle,
|
||||
backTo = "manage",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backTo?: Screen;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go } = useAppState();
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<PageHeader leading="back" onLeadingPress={() => go(backTo)} title={title} />
|
||||
{subtitle ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginBottom: 16 }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{children}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline danger notice used by the manage screens for mutation errors. */
|
||||
export function ErrorNotice({ message }: { message: string | null }) {
|
||||
const { colors } = useTheme();
|
||||
if (!message) return null;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labelled block wrapping a TextField (the manage forms' field-with-label pattern). */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
{hint ? (
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 6 }]}>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { paddingTop: 8, paddingHorizontal: 20 },
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
SectionLabel,
|
||||
StatusDot,
|
||||
} from "../components/primitives";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import { useServerConfig } from "../state/ServerConfig";
|
||||
import { createClient } from "../api/client";
|
||||
import { Pressable } from "react-native";
|
||||
|
||||
type Ping =
|
||||
| { state: "checking" }
|
||||
@@ -25,6 +28,7 @@ export function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { config, clear } = useServerConfig();
|
||||
const { go } = useAppState();
|
||||
|
||||
// Notification toggles stay local (no server-side counterpart yet).
|
||||
const [pushWait, setPushWait] = useState(true);
|
||||
@@ -94,6 +98,21 @@ export function SettingsScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Control</SectionLabel>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<NavRow
|
||||
title="Manage"
|
||||
subtitle="Models, skills, repos, users — the full admin surface"
|
||||
onPress={() => go("manage")}
|
||||
/>
|
||||
<Divider />
|
||||
<NavRow
|
||||
title="Account"
|
||||
subtitle="Who you're signed in as, change password"
|
||||
onPress={() => go("account")}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Notifications</SectionLabel>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<ToggleRow
|
||||
@@ -125,6 +144,27 @@ export function SettingsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavRow({
|
||||
title,
|
||||
subtitle,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable style={[styles.infoRow, { paddingVertical: 12 }]} onPress={onPress}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{title}</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>{subtitle}</Text>
|
||||
</View>
|
||||
<Icon name="chevronRight" size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { text } from "../../theme/tokens";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
import { Icon } from "../../components/Icon";
|
||||
import { ManageShell } from "../../components/ManageShell";
|
||||
import { Card, Divider, SectionLabel } from "../../components/primitives";
|
||||
import { useAppState, type Screen } from "../../state/AppState";
|
||||
|
||||
/**
|
||||
* The management hub (Settings → Manage): the mobile counterpart of the web
|
||||
* dashboard's admin surface. Most actions here need the admin token (or an
|
||||
* admin account session); non-admin sessions get inline 403s on writes.
|
||||
*/
|
||||
|
||||
interface Row {
|
||||
screen: Screen;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const CLAUDE_ROWS: Row[] = [
|
||||
{ screen: "models", title: "Models", subtitle: "Model backends for spawns + schedules" },
|
||||
{ screen: "skills", title: "Skills", subtitle: "Managed skills synced to every worker" },
|
||||
{ screen: "connectors", title: "Connectors", subtitle: "MCP servers agents may reach" },
|
||||
{ screen: "plugins", title: "Plugins", subtitle: "Marketplace plugins installed on boot" },
|
||||
{ screen: "permissions", title: "Permissions", subtitle: "Default mode + allow/deny/ask rules" },
|
||||
{ screen: "claudeLogin", title: "Claude login", subtitle: "Sign the worker into Claude Code" },
|
||||
];
|
||||
|
||||
const SERVER_ROWS: Row[] = [
|
||||
{ screen: "repositories", title: "Repositories", subtitle: "Register + sync project repos" },
|
||||
{ screen: "gitServers", title: "Git servers", subtitle: "Forge hosts, tokens, deploy keys" },
|
||||
{ screen: "approvals", title: "Approvals", subtitle: "Approve or reject protected branches" },
|
||||
{ screen: "shared", title: "Shared context", subtitle: "Key/value context all agents see" },
|
||||
{ screen: "users", title: "Users", subtitle: "Invite, promote, disable, reset" },
|
||||
];
|
||||
|
||||
export function ManageScreen() {
|
||||
return (
|
||||
<ManageShell
|
||||
title="Manage"
|
||||
subtitle="The full control surface — everything the web dashboard can do."
|
||||
backTo="settings"
|
||||
>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Claude</SectionLabel>
|
||||
<RowsCard rows={CLAUDE_ROWS} />
|
||||
<SectionLabel style={{ marginTop: 20, marginBottom: 8 }}>Server</SectionLabel>
|
||||
<RowsCard rows={SERVER_ROWS} />
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RowsCard({ rows }: { rows: Row[] }) {
|
||||
const { colors } = useTheme();
|
||||
const { go } = useAppState();
|
||||
return (
|
||||
<Card>
|
||||
{rows.map((r, i) => (
|
||||
<View key={r.screen}>
|
||||
{i > 0 && <Divider />}
|
||||
<Pressable style={styles.row} onPress={() => go(r.screen)}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{r.title}</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
{r.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Icon name="chevronRight" size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
@@ -44,7 +44,21 @@ export type Screen =
|
||||
| "schedules"
|
||||
| "memory"
|
||||
| "log"
|
||||
| "settings";
|
||||
| "settings"
|
||||
/* Management subscreens, reached from Settings → Manage. */
|
||||
| "manage"
|
||||
| "models"
|
||||
| "skills"
|
||||
| "connectors"
|
||||
| "plugins"
|
||||
| "permissions"
|
||||
| "repositories"
|
||||
| "gitServers"
|
||||
| "approvals"
|
||||
| "shared"
|
||||
| "users"
|
||||
| "account"
|
||||
| "claudeLogin";
|
||||
|
||||
export type DetailTab = "state" | "events" | "log";
|
||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||
@@ -87,6 +101,11 @@ interface Selected {
|
||||
}
|
||||
|
||||
interface AppStateValue {
|
||||
/* The API client for the configured endpoint/token. Management screens fetch and
|
||||
* mutate through it directly (with their own local state) rather than growing this
|
||||
* store; it is null only in the moment before ServerConfig loads. */
|
||||
client: ApiClient | null;
|
||||
|
||||
// Navigation.
|
||||
screen: Screen;
|
||||
detailTab: DetailTab;
|
||||
@@ -609,6 +628,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const value = useMemo<AppStateValue>(
|
||||
() => ({
|
||||
client,
|
||||
screen,
|
||||
detailTab,
|
||||
logFilter,
|
||||
@@ -644,6 +664,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
deleteSchedule,
|
||||
}),
|
||||
[
|
||||
client,
|
||||
screen,
|
||||
detailTab,
|
||||
logFilter,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AuthError } from "../api/client";
|
||||
import { useAppState } from "./AppState";
|
||||
|
||||
/**
|
||||
* Fetch-on-mount helper for the management screens: GETs `path` through the app's
|
||||
* client, tracks loading/error, and exposes `reload` for after a mutation.
|
||||
* Pass null to fetch nothing (e.g. while a prerequisite is missing).
|
||||
*/
|
||||
export function useResource<T>(path: string | null): {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
reload: () => void;
|
||||
} {
|
||||
const { client } = useAppState();
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(path !== null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || path === null) return;
|
||||
let stale = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
client
|
||||
.api<T>(path)
|
||||
.then((d) => {
|
||||
if (!stale) setData(d);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e instanceof AuthError) return; // handled by onUnauthorized
|
||||
if (!stale) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [client, path, nonce]);
|
||||
|
||||
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
||||
return { data, error, loading, reload };
|
||||
}
|
||||
Reference in New Issue
Block a user