diff --git a/app/App.tsx b/app/App.tsx index 93d836b..adf5404 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -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 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 ; } diff --git a/app/src/api/client.ts b/app/src/api/client.ts index cbd2475..ed9f69d 100644 --- a/app/src/api/client.ts +++ b/app/src/api/client.ts @@ -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 | null; + url?: string | null; + headers?: Record | 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 | 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( + baseUrl: string, + path: string, + body?: unknown, +): Promise { + 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, diff --git a/app/src/components/ManageShell.tsx b/app/src/components/ManageShell.tsx new file mode 100644 index 0000000..271c8ce --- /dev/null +++ b/app/src/components/ManageShell.tsx @@ -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 ( + + + + + go(backTo)} title={title} /> + {subtitle ? ( + + {subtitle} + + ) : null} + {children} + + + + ); +} + +/** 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 ( + + {message} + + ); +} + +/** 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 ( + + + {label} + + {children} + {hint ? ( + + {hint} + + ) : null} + + ); +} + +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, + }, +}); diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx index 1e31a91..922fbce 100644 --- a/app/src/screens/SettingsScreen.tsx +++ b/app/src/screens/SettingsScreen.tsx @@ -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() { + Control + + go("manage")} + /> + + go("account")} + /> + + Notifications void; +}) { + const { colors } = useTheme(); + return ( + + + {title} + {subtitle} + + + + ); +} + function InfoRow({ label, value }: { label: string; value: string }) { const { colors } = useTheme(); return ( diff --git a/app/src/screens/manage/ManageScreen.tsx b/app/src/screens/manage/ManageScreen.tsx new file mode 100644 index 0000000..a43c12b --- /dev/null +++ b/app/src/screens/manage/ManageScreen.tsx @@ -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 ( + + Claude + + Server + + + ); +} + +function RowsCard({ rows }: { rows: Row[] }) { + const { colors } = useTheme(); + const { go } = useAppState(); + return ( + + {rows.map((r, i) => ( + + {i > 0 && } + go(r.screen)}> + + {r.title} + + {r.subtitle} + + + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + minHeight: 44, + }, +}); diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index c1a1c0f..c4d9dab 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -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( () => ({ + client, screen, detailTab, logFilter, @@ -644,6 +664,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { deleteSchedule, }), [ + client, screen, detailTab, logFilter, diff --git a/app/src/state/useResource.ts b/app/src/state/useResource.ts new file mode 100644 index 0000000..d207a88 --- /dev/null +++ b/app/src/state/useResource.ts @@ -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(path: string | null): { + data: T | null; + error: string | null; + loading: boolean; + reload: () => void; +} { + const { client } = useAppState(); + const [data, setData] = useState(null); + const [error, setError] = useState(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(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 }; +}