From c3e3d1627e8211930468ee97303b44ca10290814 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:54:57 +0000 Subject: [PATCH] feat(app): full management surface + email sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Manage now opens the entire admin control surface on the phone, mirroring the web dashboard page for page: - Models: full CRUD for model backends incl. write-only API keys (set/clear), harness selection (claude/pi), enable toggles. - Skills: create, toggle, delete, expand to read SKILL.md, and install-from-prompt driven through the command queue. - Connectors: stdio/http/sse MCP servers with args/env/header parsing. - Plugins: marketplace plugins pinned to their repo. - Permissions: default mode + allow/deny/ask rules over the read-only env baseline. - Repositories: register in git-server or manual mode (incl. the mise-init bootstrap), sync, delete. - Git servers: forge hosts with encrypted tokens and generated deploy keys (public half selectable for copying). - Approvals: record operator approve/reject verdicts per branch. - Shared context: browse and set the cross-agent key/value store. - Users: invite (with shareable invite links), promote/disable, mint reset links, delete — the user-accounts feature that just landed. - Claude login: drive the worker's claude /login flow from the phone. - Account (Settings): who you're signed in as, change password, sign out with server-side session revocation. The connect screen gains the matching gate: email sign-in via /auth/login (session token stored like the legacy env token), first-run setup when the server has zero accounts, forgot-password, and the API-token method as fallback (auto-selected for pre-accounts servers). Memory gains note authoring + deletion via a new reloadMemory hook. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- app/App.tsx | 30 +- app/src/screens/ConnectScreen.tsx | 313 ++++++++++++--- app/src/screens/MemoryScreen.tsx | 206 +++++++++- app/src/screens/manage/AccountScreen.tsx | 160 ++++++++ app/src/screens/manage/ApprovalsScreen.tsx | 237 +++++++++++ app/src/screens/manage/ClaudeLoginScreen.tsx | 197 +++++++++ app/src/screens/manage/ConnectorsScreen.tsx | 318 +++++++++++++++ app/src/screens/manage/GitServersScreen.tsx | 279 +++++++++++++ app/src/screens/manage/ModelsScreen.tsx | 378 ++++++++++++++++++ app/src/screens/manage/PermissionsScreen.tsx | 188 +++++++++ app/src/screens/manage/PluginsScreen.tsx | 223 +++++++++++ app/src/screens/manage/RepositoriesScreen.tsx | 358 +++++++++++++++++ .../screens/manage/SharedContextScreen.tsx | 163 ++++++++ app/src/screens/manage/SkillsScreen.tsx | 345 ++++++++++++++++ app/src/screens/manage/UsersScreen.tsx | 256 ++++++++++++ app/src/state/AppState.tsx | 7 +- 16 files changed, 3588 insertions(+), 70 deletions(-) create mode 100644 app/src/screens/manage/AccountScreen.tsx create mode 100644 app/src/screens/manage/ApprovalsScreen.tsx create mode 100644 app/src/screens/manage/ClaudeLoginScreen.tsx create mode 100644 app/src/screens/manage/ConnectorsScreen.tsx create mode 100644 app/src/screens/manage/GitServersScreen.tsx create mode 100644 app/src/screens/manage/ModelsScreen.tsx create mode 100644 app/src/screens/manage/PermissionsScreen.tsx create mode 100644 app/src/screens/manage/PluginsScreen.tsx create mode 100644 app/src/screens/manage/RepositoriesScreen.tsx create mode 100644 app/src/screens/manage/SharedContextScreen.tsx create mode 100644 app/src/screens/manage/SkillsScreen.tsx create mode 100644 app/src/screens/manage/UsersScreen.tsx diff --git a/app/App.tsx b/app/App.tsx index adf5404..d4e93f4 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -33,6 +33,18 @@ import { type Screen as ScreenName, } from "./src/state/AppState"; import { ManageScreen } from "./src/screens/manage/ManageScreen"; +import { ModelsScreen } from "./src/screens/manage/ModelsScreen"; +import { SkillsScreen } from "./src/screens/manage/SkillsScreen"; +import { ConnectorsScreen } from "./src/screens/manage/ConnectorsScreen"; +import { PluginsScreen } from "./src/screens/manage/PluginsScreen"; +import { PermissionsScreen } from "./src/screens/manage/PermissionsScreen"; +import { RepositoriesScreen } from "./src/screens/manage/RepositoriesScreen"; +import { GitServersScreen } from "./src/screens/manage/GitServersScreen"; +import { ApprovalsScreen } from "./src/screens/manage/ApprovalsScreen"; +import { SharedContextScreen } from "./src/screens/manage/SharedContextScreen"; +import { UsersScreen } from "./src/screens/manage/UsersScreen"; +import { AccountScreen } from "./src/screens/manage/AccountScreen"; +import { ClaudeLoginScreen } from "./src/screens/manage/ClaudeLoginScreen"; import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig"; import { ConnectScreen } from "./src/screens/ConnectScreen"; import { FleetScreen } from "./src/screens/FleetScreen"; @@ -47,9 +59,7 @@ import { SettingsScreen } from "./src/screens/SettingsScreen"; function Router() { const { screen } = useAppState(); - // 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>> = { + const screens: Record React.JSX.Element> = { connect: ConnectScreen, fleet: FleetScreen, detail: AgentDetailScreen, @@ -60,8 +70,20 @@ function Router() { log: LogScreen, settings: SettingsScreen, manage: ManageScreen, + models: ModelsScreen, + skills: SkillsScreen, + connectors: ConnectorsScreen, + plugins: PluginsScreen, + permissions: PermissionsScreen, + repositories: RepositoriesScreen, + gitServers: GitServersScreen, + approvals: ApprovalsScreen, + shared: SharedContextScreen, + users: UsersScreen, + account: AccountScreen, + claudeLogin: ClaudeLoginScreen, }; - const Screen = screens[screen] ?? FleetScreen; + const Screen = screens[screen]; return ; } diff --git a/app/src/screens/ConnectScreen.tsx b/app/src/screens/ConnectScreen.tsx index 5fb5a27..cbc41b6 100644 --- a/app/src/screens/ConnectScreen.tsx +++ b/app/src/screens/ConnectScreen.tsx @@ -2,6 +2,8 @@ import React, { useState } from "react"; import { KeyboardAvoidingView, Platform, + Pressable, + ScrollView, StyleSheet, Text, View, @@ -10,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { text } from "../theme/tokens"; import { useTheme } from "../theme/useTheme"; import { Button } from "../components/Button"; +import { SegmentedControl } from "../components/SegmentedControl"; import { TextField } from "../components/TextField"; import { SectionLabel } from "../components/primitives"; import { @@ -17,13 +20,27 @@ import { useServerConfig, type ServerConfig, } from "../state/ServerConfig"; -import { AuthError, createClient, type Project } from "../api/client"; +import { + authApi, + AuthError, + createClient, + type ApiError, + type AuthStatus, + type Project, + type SessionResponse, +} from "../api/client"; /** - * First-open configuration screen, shown whenever no server config is stored (and after a - * Sign out or a persistent 401). Verifies connectivity (GET /health) and the token - * (GET /projects) before persisting, distinguishing an unreachable endpoint from a bad - * token in the inline error. Matches the SpawnScreen layout. + * First-open configuration screen, shown whenever no server config is stored (and after + * a Sign out or a persistent 401). Two ways in, mirroring the web dashboard's gate: + * + * - **Email** (default): sign in against /auth/login; the returned session token is + * stored and used exactly like the legacy env token. A server with zero accounts + * (GET /auth/status → initialized: false) flips the form into first-run setup — the + * account created there becomes the admin. A server predating user accounts (404 on + * /auth/status) falls back to the token method with a note. + * - **API token**: the legacy env token, verified via /health + /projects before it is + * persisted (distinguishing an unreachable endpoint from a rejected token). */ export function ConnectScreen() { const { colors } = useTheme(); @@ -31,55 +48,161 @@ export function ConnectScreen() { const { lastEndpoint, save } = useServerConfig(); const [endpoint, setEndpoint] = useState(lastEndpoint || DEFAULT_ENDPOINT); + const [method, setMethod] = useState<"email" | "token">("email"); + const [setup, setSetup] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); const [token, setToken] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [note, setNote] = useState(null); - async function connect() { - const ep = endpoint.trim(); - const tok = token.trim(); - if (!ep) { - setError("Enter an endpoint."); + function isApiError(e: unknown): e is ApiError { + return e instanceof Error && typeof (e as ApiError).status === "number"; + } + + async function connectWithEmail(ep: string) { + if (!email.trim() || !password) { + setError("Enter your email and password."); return; } + + // Probe first: unreachable endpoint, pre-accounts server, and first-run all + // need different flows, and /auth/status is the unauthenticated discriminator. + let status: AuthStatus; + try { + status = await authApi(ep, "/auth/status"); + } catch (e) { + if (isApiError(e) && e.status === 404) { + setMethod("token"); + setNote( + "This server predates user accounts — connect with its API token instead.", + ); + return; + } + setError("Couldn't reach that endpoint. Check the URL and your connection."); + return; + } + + if (!status.initialized) { + if (!setup) { + // Reveal the confirm field and let the operator opt in explicitly — + // creating the admin account should never happen off a mistyped tap. + setSetup(true); + setNote( + "This server has no accounts yet. The account you create now becomes the admin.", + ); + return; + } + if (password !== confirm) { + setError("Passwords don't match."); + return; + } + const session = await authApi(ep, "/auth/setup", { + email: email.trim(), + password, + }); + await save({ endpoint: ep, token: session.token }); + return; + } + + const session = await authApi(ep, "/auth/login", { + email: email.trim(), + password, + }); + await save({ endpoint: ep, token: session.token }); + } + + async function connectWithToken(ep: string) { + const tok = token.trim(); if (!tok) { setError("Enter an API token."); return; } + const client = createClient(ep, tok, () => {}); + // 1. Connectivity — /health needs no auth, so a failure here is the endpoint. + try { + await client.api<{ status: string }>("/health"); + } catch { + setError("Couldn't reach that endpoint. Check the URL and your connection."); + return; + } + + // 2. Auth — a 401 on /projects is the token, not the endpoint. + try { + await client.api("/projects"); + } catch (e) { + if (e instanceof AuthError) { + setError("Token rejected. Check your API token."); + } else { + setError(e instanceof Error ? e.message : "Couldn't load projects."); + } + return; + } + + const cfg: ServerConfig = { endpoint: ep, token: tok }; + await save(cfg); + // The gate in App.tsx swaps this screen for the fleet once config is set. + } + + async function connect() { + const ep = endpoint.trim(); + if (!ep) { + setError("Enter an endpoint."); + return; + } setError(null); + setNote(null); setBusy(true); try { - const client = createClient(ep, tok, () => {}); - - // 1. Connectivity — /health needs no auth, so a failure here is the endpoint. - try { - await client.api<{ status: string }>("/health"); - } catch { - setError("Couldn't reach that endpoint. Check the URL and your connection."); - return; + if (method === "email") { + await connectWithEmail(ep); + } else { + await connectWithToken(ep); } - - // 2. Auth — a 401 on /projects is the token, not the endpoint. - try { - await client.api("/projects"); - } catch (e) { - if (e instanceof AuthError) { - setError("Token rejected. Check your API token."); - } else { - setError(e instanceof Error ? e.message : "Couldn't load projects."); - } - return; - } - - const cfg: ServerConfig = { endpoint: ep, token: tok }; - await save(cfg); - // The gate in App.tsx swaps this screen for the fleet once config is set. + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't connect."); } finally { setBusy(false); } } + async function forgot() { + const ep = endpoint.trim(); + if (!ep || !email.trim()) { + setError("Enter the endpoint and your email first."); + return; + } + setError(null); + setBusy(true); + try { + const res = await authApi<{ ok: boolean; emailed: boolean }>( + ep, + "/auth/forgot", + { email: email.trim() }, + ); + setNote( + res.emailed + ? "If that address has an account, a reset link is on its way." + : "This server has no email configured — ask an admin for a reset link.", + ); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't request a reset."); + } finally { + setBusy(false); + } + } + + const buttonLabel = busy + ? "Connecting…" + : setup + ? "Create admin account" + : method === "email" + ? "Sign in" + : "Connect"; + return ( @@ -87,7 +210,12 @@ export function ConnectScreen() { style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined} > - + Connect @@ -110,19 +238,97 @@ export function ConnectScreen() { /> - - - API token - - { + setMethod(v as "email" | "token"); + setError(null); + setNote(null); + }} /> - + ) : null} + + {method === "email" ? ( + <> + + + Email + + + + + + Password + + + + {setup ? ( + + + Confirm password + + + + ) : ( + + + Forgot password? + + + )} + + ) : ( + + + API token + + + + )} + + {note ? ( + + {note} + + ) : null} {error ? ( - + - + ); @@ -157,11 +363,16 @@ export function ConnectScreen() { const styles = StyleSheet.create({ page: { flex: 1 }, flex: { flex: 1 }, - content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 }, + content: { flexGrow: 1, paddingTop: 8, paddingHorizontal: 20 }, heading: { marginTop: 12, marginBottom: 24 }, errorBox: { borderWidth: 1, borderRadius: 10, padding: 12, }, + noteBox: { + borderWidth: 1, + borderRadius: 10, + padding: 12, + }, }); diff --git a/app/src/screens/MemoryScreen.tsx b/app/src/screens/MemoryScreen.tsx index 01ff747..1e76590 100644 --- a/app/src/screens/MemoryScreen.tsx +++ b/app/src/screens/MemoryScreen.tsx @@ -1,22 +1,32 @@ import React, { useMemo, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { text } from "../theme/tokens"; +import { radius, text } from "../theme/tokens"; import { useTheme } from "../theme/useTheme"; import { Badge } from "../components/Badge"; +import { Button } from "../components/Button"; import { Chip } from "../components/Chip"; +import { Select } from "../components/Select"; import { TabBar } from "../components/TabBar"; +import { TextField } from "../components/TextField"; import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; import { useAppState, type BadgeTone } from "../state/AppState"; import { timeAgo } from "../api/format"; import type { MemoryNote } from "../api/client"; /** - * Memory — a read view over the agent-memory note graph (the web dashboard's - * Memory page). Notes are the distilled facts/decisions/gotchas/runbooks agents - * leave for each other; tapping a note expands its body, tags, and links. - * Authoring stays on the web dashboard (admin token) and in the agents' own - * MCP server — the phone is for looking things up. + * Memory — the agent-memory note graph (the web dashboard's Memory page). + * Notes are the distilled facts/decisions/gotchas/runbooks agents leave for + * each other; tapping a note expands its body, tags, and links. Operators can + * author and delete notes here too (admin-gated server-side — notes feed every + * future agent's context); link editing stays on the web dashboard's graph UI. */ const KIND_FILTERS = ["all", "fact", "decision", "gotcha", "runbook"]; @@ -31,11 +41,81 @@ const KIND_TONES: Record = { export function MemoryScreen() { const { colors } = useTheme(); const insets = useSafeAreaInsets(); - const { memory, memoryError } = useAppState(); + const { client, memory, memoryError, reloadMemory, projects } = useAppState(); const [kind, setKind] = useState("all"); const [openId, setOpenId] = useState(null); + // New-note form (POST /memory/notes; admin-gated server-side). + const [showForm, setShowForm] = useState(false); + const [title, setTitle] = useState(""); + const [noteKind, setNoteKind] = useState("fact"); + const [project, setProject] = useState(""); + const [tags, setTags] = useState(""); + const [body, setBody] = useState(""); + const [busy, setBusy] = useState(false); + const [formError, setFormError] = useState(null); + + async function createNote() { + if (!client) return; + if (!title.trim() || !body.trim()) { + setFormError("A title and a body are both required."); + return; + } + setFormError(null); + setBusy(true); + try { + const tagList = tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + await client.api("/memory/notes", { + body: { + title: title.trim(), + body: body.trim(), + kind: noteKind, + project_id: project || null, + ...(tagList.length > 0 ? { tags: tagList } : {}), + }, + }); + setTitle(""); + setTags(""); + setBody(""); + setShowForm(false); + reloadMemory(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Couldn't save the note."); + } finally { + setBusy(false); + } + } + + function confirmDeleteNote(n: MemoryNote) { + Alert.alert( + "Delete note?", + `Remove "${n.title}" and its links. Future agents stop seeing it.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + if (!client) return; + client + .api(`/memory/notes/${n.id}`, { method: "DELETE" }) + .then(() => { + setOpenId(null); + reloadMemory(); + }) + .catch((e) => + setFormError(e instanceof Error ? e.message : "Delete failed."), + ); + }, + }, + ], + ); + } + const notes = useMemo(() => { const all = memory?.notes ?? []; const inKind = kind === "all" ? all : all.filter((n) => n.kind === kind); @@ -56,9 +136,9 @@ export function MemoryScreen() { const out: { key: string; label: string }[] = []; for (const l of links) { if (l.src_note_id === note.id) { - out.push({ key: `o${l.id}`, label: `${l.relation} → ${title(byId, l.dst_note_id)}` }); + out.push({ key: `o${l.id}`, label: `${l.relation} → ${noteTitle(byId, l.dst_note_id)}` }); } else if (l.dst_note_id === note.id) { - out.push({ key: `i${l.id}`, label: `${title(byId, l.src_note_id)} → ${l.relation}` }); + out.push({ key: `i${l.id}`, label: `${noteTitle(byId, l.src_note_id)} → ${l.relation}` }); } } return out; @@ -69,10 +149,18 @@ export function MemoryScreen() { - Memory + + Memory + + - Notes agents distill for every future run. Edit them from the web - dashboard. + Notes agents distill for every future run. + {showForm ? ( + + + + Title + + + + ({ value: p.id, label: p.id })), + ]} + value={project} + onChange={setProject} + /> + + + Tags + + + + + + Body + + + + + + ) : null} + + {formError ? ( + + {formError} + + ) : null} + {memoryError ? ( {memoryError} ) : memory === null ? ( @@ -155,6 +314,14 @@ export function MemoryScreen() { ))} ) : null} + ) : null} @@ -171,15 +338,26 @@ export function MemoryScreen() { ); } -function title(byId: Map, id: number): string { +function noteTitle(byId: Map, id: number): string { return byId.get(id)?.title ?? `note #${id}`; } const styles = StyleSheet.create({ page: { flex: 1 }, header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 14 }, + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 }, body: { paddingHorizontal: 20, paddingBottom: 24 }, + notice: { + borderWidth: 1, + borderRadius: radius.md, + padding: 12, + marginBottom: 16, + }, row: { paddingVertical: 12, paddingHorizontal: 16 }, titleRow: { flexDirection: "row", alignItems: "center", gap: 8 }, }); diff --git a/app/src/screens/manage/AccountScreen.tsx b/app/src/screens/manage/AccountScreen.tsx new file mode 100644 index 0000000..0d6e29d --- /dev/null +++ b/app/src/screens/manage/AccountScreen.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { radius, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { TextField } from "../../components/TextField"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Card, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useServerConfig } from "../../state/ServerConfig"; +import { useResource } from "../../state/useResource"; +import { AuthError, type Me } from "../../api/client"; + +/** + * Account (Settings → Account): who this session is, change password, sign out. + * Sessions come in two kinds — a user account (email + password, revocable + * sessions) or the legacy env API token (configuration, not a session): tokens + * have no password to change and nothing server-side to revoke on sign-out. + */ + +export function AccountScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + const { clear } = useServerConfig(); + const { data: me, error: meError, loading } = useResource("/auth/me"); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + async function changePassword() { + if (!client) return; + if (!currentPassword || !newPassword) { + setError("Both the current and the new password are required."); + return; + } + setError(null); + setNotice(null); + setBusy(true); + try { + await client.api("/auth/change-password", { + body: { current_password: currentPassword, new_password: newPassword }, + }); + setCurrentPassword(""); + setNewPassword(""); + setNotice("Password changed — other sessions were signed out."); + } catch (e) { + if (e instanceof AuthError) return; // handled by onUnauthorized + setError(e instanceof Error ? e.message : "Password change failed."); + } finally { + setBusy(false); + } + } + + async function signOut() { + // Revoke the session server-side when possible; a dead server or an env + // token must never block the local sign-out. + if (client) { + try { + await client.api("/auth/logout", { method: "POST" }); + } catch { + /* ignore — clearing the stored config is the part that matters */ + } + } + await clear(); + } + + return ( + + {meError ? : null} + + Signed in as + + {loading && !me ? ( + Loading… + ) : ( + <> + + {me?.kind === "token" ? "legacy API token" : me?.email ?? "—"} + + + + {me?.is_admin ? "admin" : "member"} + + + + )} + + + Change password + {me?.kind === "token" ? ( + + Env tokens have no password — this session authenticates with the + server's configured API token. + + ) : ( + + + + + + + + + {notice ? ( + + {notice} + + ) : null} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + badgeRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + notice: { + borderWidth: 1, + borderRadius: radius.md, + padding: 12, + }, +}); diff --git a/app/src/screens/manage/ApprovalsScreen.tsx b/app/src/screens/manage/ApprovalsScreen.tsx new file mode 100644 index 0000000..c1c7212 --- /dev/null +++ b/app/src/screens/manage/ApprovalsScreen.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Select } from "../../components/Select"; +import { TextField } from "../../components/TextField"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import { statusLabel, statusTone, timeAgo } from "../../api/format"; +import type { Approval, Command } from "../../api/client"; + +/** + * Approvals — record a per-branch verdict (the review gate), the mobile counterpart + * of the web dashboard's Approvals section. A verdict is enqueued as a control + * command so the worker can resolve the reviewed HEAD and pin the approval. + */ + +/* "agent #N" for agent-made verdicts, else the recorded actor (operator:web etc). */ +function verdictBy(ap: Approval): string | null { + if (ap.approved_by_agent_id != null) return `agent #${ap.approved_by_agent_id}`; + return ap.actor || null; +} + +export function ApprovalsScreen() { + const { colors } = useTheme(); + const { client, projects } = useAppState(); + + const projectIds = projects.map((p) => p.id); + const [project, setProject] = useState(""); + const [branch, setBranch] = useState(""); + const [sha, setSha] = useState(""); + const [pr, setPr] = useState(""); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (projectIds.length > 0 && !projectIds.includes(project)) { + setProject(projectIds[0]); + } + }, [projectIds, project]); + + const { + data: approvals, + error: loadError, + loading, + reload, + } = useResource( + project ? `/projects/${encodeURIComponent(project)}/approvals` : null, + ); + + async function submit(status: "approved" | "rejected") { + if (!client || !project) return; + if (!branch.trim()) { + setError("Branch is required."); + return; + } + setError(null); + setBusy(true); + try { + const cmd = await client.api( + `/projects/${encodeURIComponent(project)}/approvals`, + { + body: { + branch: branch.trim(), + status, + ...(sha.trim() ? { sha: sha.trim() } : {}), + ...(pr.trim() ? { pr: pr.trim() } : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }, + }, + ); + const tracked = await client.trackCommand(cmd.id); + if (tracked?.status === "failed") { + setError(tracked.error || "The verdict command failed."); + } else { + setBranch(""); + setSha(""); + setPr(""); + setNote(""); + } + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t record the verdict."); + } finally { + setBusy(false); + } + } + + const rows = approvals ?? []; + + return ( + + {projectIds.length === 0 ? ( + + Register a repository first. + + ) : ( + <> + + setTransport(v as McpTransport)} + /> + {stdio ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + )} + + + ) : null} + + + + {connectors.length === 0 && !loading ? ( + + No connectors yet. + + ) : ( + + {connectors.map((c, i) => ( + + {i > 0 && } + + + + + {c.name} + + {c.transport} + {c.owner_user_id != null ? ( + private + ) : null} + + + {c.transport === "stdio" + ? [c.command ?? "", ...(c.args ?? [])].join(" ").trim() + : c.url ?? ""} + + + + toggle(c, v)} /> + + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "space-between", + gap: 10, + }, +}); diff --git a/app/src/screens/manage/GitServersScreen.tsx b/app/src/screens/manage/GitServersScreen.tsx new file mode 100644 index 0000000..6c9c2cc --- /dev/null +++ b/app/src/screens/manage/GitServersScreen.tsx @@ -0,0 +1,279 @@ +import React, { useState } from "react"; +import { Alert, StyleSheet, Text, View } from "react-native"; +import { fonts, radius, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Select } from "../../components/Select"; +import { Switch } from "../../components/Switch"; +import { TextField } from "../../components/TextField"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import type { Host } from "../../api/client"; + +/** + * Git servers — one entry per forge host, carrying the server's own + * credentials: a forge token (encrypted at rest, write-only) and an ed25519 + * deploy keypair whose public half is shown here for the operator to paste + * into the forge. Projects on a configured server need no per-repo + * credentials. Writes are admin-only; 403s surface inline. + */ + +const FORGE_OPTIONS = ["github", "gitlab", "gitea", "forgejo", "bitbucket"]; + +export function GitServersScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + const { data, error: loadError, loading, reload } = useResource("/hosts"); + const hosts = data ?? []; + + const [showForm, setShowForm] = useState(false); + const [hostname, setHostname] = useState(""); + const [forgeType, setForgeType] = useState("github"); + const [baseUrl, setBaseUrl] = useState(""); + const [token, setToken] = useState(""); + const [generateKey, setGenerateKey] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function add() { + if (!client) return; + if (!hostname.trim()) { + setError("Hostname is required."); + return; + } + setError(null); + setBusy(true); + try { + await client.api("/hosts", { + body: { + hostname: hostname.trim(), + forge_type: forgeType, + base_url: baseUrl.trim() || undefined, + token: token || undefined, + generate_ssh_key: generateKey, + }, + }); + setHostname(""); + setBaseUrl(""); + setToken(""); + setGenerateKey(true); + setShowForm(false); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t add the git server."); + } finally { + setBusy(false); + } + } + + function confirmDelete(h: Host) { + Alert.alert( + "Delete git server?", + `Remove ${h.hostname} and its stored credentials. Projects on it lose their token + deploy key.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + client + ?.api(`/hosts/${encodeURIComponent(h.hostname)}`, { method: "DELETE" }) + .then(() => reload()) + .catch((e) => + setError(e instanceof Error ? e.message : "Delete failed."), + ); + }, + }, + ], + ); + } + + return ( + + + + {`${hosts.length} server${hosts.length === 1 ? "" : "s"}`} + + + + + {showForm ? ( + + + + + set({ harness: v as "claude" | "pi" })} + /> + + set({ apiKey: v })} + placeholder="sk-…" + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + /> + + {editing?.has_api_key ? ( + + + + Clear stored key + + + Drop the stored key on save (ignored if a new key is set). + + + set({ clearKey: v })} + /> + + ) : null} + + + ) : null} + + + + {!loading && list.length === 0 ? ( + + No model backends yet — agents run on the Claude subscription. + + ) : null} + + {list.length > 0 ? ( + + {list.map((m, i) => ( + + {i > 0 && } + + + + + {m.name} + + {m.harness === "pi" ? ( + pi harness + ) : ( + claude + )} + {m.owner_user_id != null ? ( + private + ) : null} + + + {m.model} + {m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} + + + {m.base_url} + + {m.has_api_key ? ( + + key stored + + ) : null} + + + toggleEnabled(m, v)} + /> + + + + + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + switchRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "space-between", + gap: 10, + }, +}); diff --git a/app/src/screens/manage/PermissionsScreen.tsx b/app/src/screens/manage/PermissionsScreen.tsx new file mode 100644 index 0000000..24035dd --- /dev/null +++ b/app/src/screens/manage/PermissionsScreen.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { radius, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Button } from "../../components/Button"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Select } from "../../components/Select"; +import { TextField } from "../../components/TextField"; +import { Card, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import type { ClaudePermissions } from "../../api/client"; + +/** + * Permissions — the stored overrides merged over the server's env baseline into + * every generated settings.json, the mobile counterpart of the web dashboard's + * PermissionsPanel. Headless runs auto-deny anything that would prompt, so + * allow rules are what let work proceed. + */ + +const MODE_OPTIONS = [ + { value: "", label: "(keep server baseline)" }, + { value: "default", label: "default" }, + { value: "acceptEdits", label: "acceptEdits" }, + { value: "plan", label: "plan" }, + { value: "bypassPermissions", label: "bypassPermissions" }, +]; + +interface Draft { + mode: string; + allow: string; + deny: string; + ask: string; +} + +/* One rule per line → trimmed, blank-free list. */ +function parseLines(s: string): string[] { + return s + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +} + +export function PermissionsScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + + const { data, error: loadError, loading, reload } = + useResource("/claude/permissions"); + + const [form, setForm] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Seed the form from the loaded permissions once; afterwards the operator's draft wins. + useEffect(() => { + if (form === null && data !== null) { + setForm({ + mode: data.default_mode ?? "", + allow: data.allow.join("\n"), + deny: data.deny.join("\n"), + ask: data.ask.join("\n"), + }); + } + }, [data, form]); + + async function save() { + if (!client || !form) return; + setError(null); + setBusy(true); + try { + await client.api("/claude/permissions", { + method: "PUT", + body: { + default_mode: form.mode || null, + allow: parseLines(form.allow), + deny: parseLines(form.deny), + ask: parseLines(form.ask), + }, + }); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t save permissions."); + } finally { + setBusy(false); + } + } + + return ( + + + + {data === null || form === null ? ( + + {loading ? "Loading permissions…" : "Permissions unavailable."} + + ) : ( + <> + + + Server baseline (env) + + + {`mode: ${data.base_mode}`} + + {data.base_allow.length > 0 ? ( + data.base_allow.map((rule) => ( + + {rule} + + )) + ) : ( + + (no baseline allow rules) + + )} + + + + ({ + value: h.hostname, + label: `${h.hostname} (${h.forge_type})`, + }))} + value={gitServer || hostnames[0]} + onChange={setGitServer} + /> + ) : ( + + + None registered yet. + + + )} + + + + + + + + + + Initialize mise + + + Queues a bootstrap agent that authors .mise.toml with a test + task and pushes it. + + + + + + ) : ( + <> + + + + + + + + + + + )} + + + + ) : null} + + + + {projectsRes.loading && projects.length === 0 ? ( + Loading… + ) : projects.length === 0 ? ( + + No repositories registered. + + ) : ( + + {projects.map((p, i) => ( + + {i > 0 && } + + + + + {p.id} + + {p.owner_user_id != null ? ( + private + ) : null} + + + {p.git_remote || p.root_dir} + + + added {timeAgo(p.created_at)} ago + + {syncedId === p.id ? ( + + sync queued + + ) : null} + + + {p.git_remote ? ( + + ) : null} + + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + switchRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "flex-start", + gap: 10, + }, +}); diff --git a/app/src/screens/manage/SharedContextScreen.tsx b/app/src/screens/manage/SharedContextScreen.tsx new file mode 100644 index 0000000..89f0bb5 --- /dev/null +++ b/app/src/screens/manage/SharedContextScreen.tsx @@ -0,0 +1,163 @@ +import React, { useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { radius, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Button } from "../../components/Button"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { TextField } from "../../components/TextField"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import { timeAgo } from "../../api/format"; +import type { SharedContext } from "../../api/client"; + +/** + * Shared context — the cross-project key/value store every agent can read + * (README 3.4). Reads use the normal token; writing a key needs the + * higher-trust shared-context write token, so a normal or even admin token + * may 403 — the server's message is surfaced verbatim. + */ + +export function SharedContextScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + + const { data, error: loadError, loading, reload } = + useResource("/shared/context"); + + const [key, setKey] = useState(""); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + + async function set() { + if (!client) return; + if (!key.trim() || !value.trim()) { + setError("Key and value are both required."); + return; + } + setError(null); + setBusy(true); + try { + await client.api( + `/shared/context/${encodeURIComponent(key.trim())}`, + { method: "PUT", body: { value: value.trim() } }, + ); + setKey(""); + setValue(""); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t set the key."); + } finally { + setBusy(false); + } + } + + const rows = data ?? []; + + return ( + + + Set a key + + + + + + + + + + + + + {`${rows.length} key${rows.length === 1 ? "" : "s"}`} + + + {rows.length === 0 ? ( + + {loading ? "Loading…" : "No shared context set."} + + ) : ( + + {rows.map((c, i) => ( + + {i > 0 && } + setExpanded((k) => (k === c.key ? null : c.key))} + > + + {c.key} + + {expanded === c.key ? ( + + + {c.value} + + + ) : ( + + {c.value} + + )} + + {c.set_by_agent_id != null + ? `agent #${c.set_by_agent_id}` + : "operator"} + {` · updated ${timeAgo(c.updated_at)} ago`} + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + row: { + gap: 4, + paddingVertical: 12, + paddingHorizontal: 16, + }, + valueBlock: { + borderWidth: 1, + borderRadius: radius.md, + padding: 10, + marginTop: 2, + }, +}); diff --git a/app/src/screens/manage/SkillsScreen.tsx b/app/src/screens/manage/SkillsScreen.tsx new file mode 100644 index 0000000..30637a6 --- /dev/null +++ b/app/src/screens/manage/SkillsScreen.tsx @@ -0,0 +1,345 @@ +import React, { useState } from "react"; +import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; +import { radius, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { Switch } from "../../components/Switch"; +import { TextField } from "../../components/TextField"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import type { ClaudeSkill, Command } from "../../api/client"; + +/** + * Skills — managed Claude Code skills, the mobile counterpart of the web + * dashboard's Claude → Skills panel. Rows are plain DB writes the control + * container syncs to every worker's ~/.claude/skills at the next agent launch. + * Everyone sees the shared rows (owner NULL) plus their own; mutating a row the + * session doesn't own 403s server-side and surfaces inline, never fatally. + */ + +export function SkillsScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + const { + data, + error: loadError, + loading, + reload, + } = useResource("/claude/skills"); + const skills = data ?? []; + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [content, setContent] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + const [installPrompt, setInstallPrompt] = useState(""); + const [installing, setInstalling] = useState(false); + const [installError, setInstallError] = useState(null); + const [installSummary, setInstallSummary] = useState(null); + + async function create() { + if (!client) return; + if (!name.trim() || !content.trim()) { + setError("Name and content are both required."); + return; + } + setError(null); + setBusy(true); + try { + await client.api("/claude/skills", { + body: { + name: name.trim(), + description: description.trim() || null, + content, + enabled: true, + }, + }); + setName(""); + setDescription(""); + setContent(""); + setShowForm(false); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t create the skill."); + } finally { + setBusy(false); + } + } + + function toggle(sk: ClaudeSkill, enabled: boolean) { + if (!client) return; + client + .api(`/claude/skills/${sk.id}`, { method: "PATCH", body: { enabled } }) + .then(reload) + .catch((e) => setError(e instanceof Error ? e.message : "Update failed.")); + } + + function confirmDelete(sk: ClaudeSkill) { + Alert.alert( + "Delete skill?", + `Remove ${sk.name} from every worker at its next launch. This can’t be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + client + ?.api(`/claude/skills/${sk.id}`, { method: "DELETE" }) + .then(reload) + .catch((e) => + setError(e instanceof Error ? e.message : "Delete failed."), + ); + }, + }, + ], + ); + } + + /* Install-from-prompt runs headlessly on the worker — the 202 hands back a + * command we poll to completion (installs can be slow: network + a full + * claude run, hence the 4-minute budget). */ + async function install() { + if (!client || !installPrompt.trim()) return; + setInstallError(null); + setInstallSummary(null); + setInstalling(true); + try { + const cmd = await client.api("/claude/skills/install", { + body: { prompt: installPrompt.trim() }, + }); + const done = await client.trackCommand(cmd.id, { + attempts: 120, + intervalMs: 2000, + }); + if (done === null) { + setInstallError( + "Still installing after 4 minutes — pull to reload later to see what landed.", + ); + } else if (done.status === "failed") { + setInstallError(done.error ?? "Install failed."); + } else { + setInstallSummary( + done.result ? JSON.stringify(done.result).trim() : "Installed.", + ); + setInstallPrompt(""); + reload(); + } + } catch (e) { + setInstallError(e instanceof Error ? e.message : "Install failed."); + } finally { + setInstalling(false); + } + } + + return ( + + + + {loading + ? "Loading…" + : `${skills.length} skill${skills.length === 1 ? "" : "s"}`} + + + + + {showForm ? ( + + + + + + + + + + + + + ) : null} + + Install from a marketplace prompt + + + + + + {installSummary ? ( + + {installSummary} + + ) : null} + + + + + + {skills.length === 0 && !loading ? ( + + No custom skills yet. + + ) : ( + + {skills.map((sk, i) => { + const expanded = expandedId === sk.id; + return ( + + {i > 0 && } + setExpandedId(expanded ? null : sk.id)} + > + + + + {sk.name} + + {sk.owner_user_id != null ? ( + private + ) : null} + + {sk.description ? ( + + {sk.description} + + ) : null} + {sk.files.length > 0 ? ( + + ships with {sk.files.length} file + {sk.files.length === 1 ? "" : "s"} + + ) : null} + + + toggle(sk, v)} /> + + + + {expanded ? ( + + + + {sk.content} + + + {sk.files.map((f) => ( + + {f} + + ))} + + ) : null} + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "space-between", + gap: 10, + }, + detail: { + paddingHorizontal: 16, + paddingBottom: 12, + gap: 6, + }, + monoBlock: { + borderWidth: 1, + borderRadius: radius.md, + padding: 12, + }, +}); diff --git a/app/src/screens/manage/UsersScreen.tsx b/app/src/screens/manage/UsersScreen.tsx new file mode 100644 index 0000000..35410cd --- /dev/null +++ b/app/src/screens/manage/UsersScreen.tsx @@ -0,0 +1,256 @@ +import React, { useState } from "react"; +import { Alert, StyleSheet, Text, View } from "react-native"; +import { fonts, text } from "../../theme/tokens"; +import { useTheme } from "../../theme/useTheme"; +import { Badge } from "../../components/Badge"; +import { Button } from "../../components/Button"; +import { Switch } from "../../components/Switch"; +import { TextField } from "../../components/TextField"; +import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell"; +import { Card, Divider, Mono, SectionLabel } from "../../components/primitives"; +import { useAppState } from "../../state/AppState"; +import { useResource } from "../../state/useResource"; +import { timeAgo } from "../../api/format"; +import { AuthError, type ResetLink, type User, type UserCreated } from "../../api/client"; + +/** + * Users — admin-only account management (Settings → Manage → Users), the mobile + * counterpart of the web dashboard's Users section. Invite by email (the invitee + * sets their own password through a one-shot link, emailed when SMTP is configured + * and always shown here), toggle admin/disabled, mint reset links, delete. The + * server guards the last active admin and self-deletes; those refusals surface + * inline. A non-admin session gets a 403 on the list itself. + */ + +/* The invite/reset link the last mutation minted, kept visible until the next one. */ +interface LastLink { + email: string; + url: string; + emailed: boolean; +} + +export function UsersScreen() { + const { colors } = useTheme(); + const { client } = useAppState(); + const { data: users, error: listError, loading, reload } = useResource( + "/auth/users", + ); + + const [email, setEmail] = useState(""); + const [inviteAdmin, setInviteAdmin] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [lastLink, setLastLink] = useState(null); + + async function invite() { + if (!client) return; + if (!email.trim()) { + setError("An email address is required."); + return; + } + setError(null); + setBusy(true); + try { + const created = await client.api("/auth/users", { + body: { email: email.trim(), is_admin: inviteAdmin }, + }); + setLastLink({ + email: created.user.email, + url: created.invite_url, + emailed: created.emailed, + }); + setEmail(""); + setInviteAdmin(false); + reload(); + } catch (e) { + if (e instanceof AuthError) return; // handled by onUnauthorized + setError(e instanceof Error ? e.message : "Invite failed."); + } finally { + setBusy(false); + } + } + + async function patchUser(id: number, fields: Partial>) { + if (!client) return; + setError(null); + try { + await client.api(`/auth/users/${id}`, { method: "PATCH", body: fields }); + reload(); + } catch (e) { + if (e instanceof AuthError) return; + setError(e instanceof Error ? e.message : "Update failed."); + } + } + + async function mintResetLink(u: User) { + if (!client) return; + setError(null); + try { + const link = await client.api(`/auth/users/${u.id}/reset-link`, { + method: "POST", + }); + setLastLink({ email: u.email, url: link.reset_url, emailed: link.emailed }); + } catch (e) { + if (e instanceof AuthError) return; + setError(e instanceof Error ? e.message : "Couldn’t mint a reset link."); + } + } + + function confirmDelete(u: User) { + Alert.alert( + "Remove user?", + `Remove ${u.email}. Their projects, skills, and tools become shared.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + if (!client) return; + client + .api(`/auth/users/${u.id}`, { method: "DELETE" }) + .then(() => reload()) + .catch((e) => { + if (e instanceof AuthError) return; + // Server refuses last-admin and self deletes; surface its reason. + setError(e instanceof Error ? e.message : "Delete failed."); + }); + }, + }, + ], + ); + } + + const count = users?.length ?? 0; + + return ( + + + + + + + Admin + + + + + + + + {lastLink ? ( + + + One-shot set-password link for {lastLink.email} + + + {lastLink.url} + + + {lastLink.emailed + ? "Also emailed to them. It expires; share over a channel you trust." + : "SMTP is off — hand this link over yourself. It expires."} + + + ) : null} + + + {`${count} user${count === 1 ? "" : "s"}`} + + + {listError ? ( + + ) : loading && !users ? ( + Loading… + ) : count === 0 ? ( + No users yet. + ) : ( + + {(users ?? []).map((u, i) => ( + + {i > 0 && } + + + + + {u.email} + + {u.is_admin ? admin : null} + {u.disabled ? disabled : null} + {!u.has_password ? invite pending : null} + + + created {timeAgo(u.created_at)} ago + + + admin + void patchUser(u.id, { is_admin: v })} + /> + + + disabled + void patchUser(u.id, { disabled: v })} + /> + + + + + + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "flex-start", + gap: 10, + }, + switchRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, +}); diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index c4d9dab..3153b5e 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -127,6 +127,7 @@ interface AppStateValue { /* The agent-memory note graph; null until the memory screen first loads it. */ memory: MemoryGraph | null; memoryError: string | null; + reloadMemory: () => void; waiting: WaitingItem[]; recent: RecentItem[]; counts: { running: number; waiting: number; done: number }; @@ -434,6 +435,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { // open) rather than on the fleet poll — it changes slowly and can be large. const [memory, setMemory] = useState(null); const [memoryError, setMemoryError] = useState(null); + const [memoryNonce, setMemoryNonce] = useState(0); + const reloadMemory = useCallback(() => setMemoryNonce((n) => n + 1), []); useEffect(() => { if (!client || screen !== "memory") return; let stale = false; @@ -450,7 +453,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { return () => { stale = true; }; - }, [client, screen]); + }, [client, screen, memoryNonce]); // ---- Selected agent's run events ---------------------------------------- // Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only @@ -645,6 +648,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { schedules, memory, memoryError, + reloadMemory, waiting, recent, counts, @@ -677,6 +681,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { schedules, memory, memoryError, + reloadMemory, waiting, recent, counts,