feat(app): full management surface + email sign-in

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
Claude
2026-08-13 13:54:57 +00:00
parent 804baadddd
commit c3e3d1627e
16 changed files with 3588 additions and 70 deletions
+26 -4
View File
@@ -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<Record<ScreenName, () => React.JSX.Element>> = {
const screens: Record<ScreenName, () => 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 <Screen />;
}
+262 -51
View File
@@ -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<string | null>(null);
const [note, setNote] = useState<string | null>(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<AuthStatus>(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<SessionResponse>(ep, "/auth/setup", {
email: email.trim(),
password,
});
await save({ endpoint: ep, token: session.token });
return;
}
const session = await authApi<SessionResponse>(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<Project[]>("/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<Project[]>("/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 (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
@@ -87,7 +210,12 @@ export function ConnectScreen() {
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
<ScrollView
style={styles.flex}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.heading}>
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
@@ -110,19 +238,97 @@ export function ConnectScreen() {
/>
</View>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
API token
</Text>
<TextField
value={token}
onChangeText={setToken}
placeholder="Bearer token"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
{!setup ? (
<SegmentedControl
segments={[
{ value: "email", label: "Email" },
{ value: "token", label: "API token" },
]}
value={method}
onChange={(v) => {
setMethod(v as "email" | "token");
setError(null);
setNote(null);
}}
/>
</View>
) : null}
{method === "email" ? (
<>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Email
</Text>
<TextField
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
</View>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Password
</Text>
<TextField
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{setup ? (
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Confirm password
</Text>
<TextField
value={confirm}
onChangeText={setConfirm}
placeholder="Same password again"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : (
<Pressable onPress={busy ? undefined : forgot} hitSlop={8}>
<Text style={[text.bodySm, { color: colors.textMuted }]}>
Forgot password?
</Text>
</Pressable>
)}
</>
) : (
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
API token
</Text>
<TextField
value={token}
onChangeText={setToken}
placeholder="Bearer token"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</View>
)}
{note ? (
<View
style={[
styles.noteBox,
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
<Text style={[text.bodySm, { color: colors.textBody }]}>{note}</Text>
</View>
) : null}
{error ? (
<View
@@ -139,16 +345,16 @@ export function ConnectScreen() {
) : null}
</View>
<View style={{ marginTop: "auto" }}>
<View style={{ marginTop: 28 }}>
<Button
size="lg"
style={{ width: "100%" }}
onPress={busy ? undefined : connect}
>
{busy ? "Connecting…" : "Connect"}
{buttonLabel}
</Button>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
);
@@ -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,
},
});
+192 -14
View File
@@ -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<string, BadgeTone> = {
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<number | null>(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<string | null>(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() {
<View style={{ height: insets.top }} />
<View style={styles.header}>
<Text style={[text.h3, { color: colors.textHeading }]}>Memory</Text>
<View style={styles.headRow}>
<Text style={[text.h3, { color: colors.textHeading }]}>Memory</Text>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4 }]}>
Notes agents distill for every future run. Edit them from the web
dashboard.
Notes agents distill for every future run.
</Text>
<ScrollView
horizontal
@@ -95,6 +183,77 @@ export function MemoryScreen() {
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Title
</Text>
<TextField
value={title}
onChangeText={setTitle}
placeholder="One-line takeaway"
/>
</View>
<Select
label="Kind"
options={["fact", "decision", "gotcha", "runbook"]}
value={noteKind}
onChange={setNoteKind}
/>
<Select
label="Scope"
options={[
{ value: "", label: "Global (all projects)" },
...projects.map((p) => ({ value: p.id, label: p.id })),
]}
value={project}
onChange={setProject}
/>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Tags
</Text>
<TextField
value={tags}
onChangeText={setTags}
placeholder="comma, separated (optional)"
autoCapitalize="none"
/>
</View>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Body
</Text>
<TextField
value={body}
onChangeText={setBody}
placeholder="What should future agents know?"
multiline
height={100}
/>
</View>
<Button
size="lg"
style={{ width: "100%" }}
onPress={busy ? undefined : createNote}
>
{busy ? "Saving…" : "Save note"}
</Button>
</Card>
) : null}
{formError ? (
<View
style={[
styles.notice,
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
]}
>
<Text style={[text.bodySm, { color: colors.danger }]}>{formError}</Text>
</View>
) : null}
{memoryError ? (
<Text style={[text.bodySm, { color: colors.danger }]}>{memoryError}</Text>
) : memory === null ? (
@@ -155,6 +314,14 @@ export function MemoryScreen() {
))}
</View>
) : null}
<Button
size="sm"
variant="danger"
style={{ alignSelf: "flex-start" }}
onPress={() => confirmDeleteNote(n)}
>
Delete
</Button>
</View>
) : null}
</Pressable>
@@ -171,15 +338,26 @@ export function MemoryScreen() {
);
}
function title(byId: Map<number, MemoryNote>, id: number): string {
function noteTitle(byId: Map<number, MemoryNote>, 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 },
});
+160
View File
@@ -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<Me>("/auth/me");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<ManageShell title="Account" backTo="settings">
{meError ? <ErrorNotice message={meError} /> : null}
<SectionLabel style={{ marginBottom: 8 }}>Signed in as</SectionLabel>
<Card style={{ padding: 16, marginBottom: 20, gap: 8 }}>
{loading && !me ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading</Text>
) : (
<>
<Text style={[text.body, { color: colors.textHeading }]}>
{me?.kind === "token" ? "legacy API token" : me?.email ?? "—"}
</Text>
<View style={styles.badgeRow}>
<Badge tone={me?.is_admin ? "positive" : "neutral"}>
{me?.is_admin ? "admin" : "member"}
</Badge>
</View>
</>
)}
</Card>
<SectionLabel style={{ marginBottom: 8 }}>Change password</SectionLabel>
{me?.kind === "token" ? (
<Text style={[text.bodySm, { color: colors.textMuted, marginBottom: 20 }]}>
Env tokens have no password this session authenticates with the
server's configured API token.
</Text>
) : (
<Card style={{ padding: 16, marginBottom: 20, gap: 14 }}>
<Field label="Current password">
<TextField
value={currentPassword}
onChangeText={setCurrentPassword}
placeholder="••••••••"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="New password">
<TextField
value={newPassword}
onChangeText={setNewPassword}
placeholder="••••••••"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<ErrorNotice message={error} />
{notice ? (
<View
style={[
styles.notice,
{ backgroundColor: colors.positiveTint, borderColor: colors.positive },
]}
>
<Text style={[text.bodySm, { color: colors.positive }]}>{notice}</Text>
</View>
) : null}
<Button
size="lg"
style={{ width: "100%" }}
onPress={busy ? undefined : changePassword}
>
{busy ? "Changing…" : "Change password"}
</Button>
</Card>
)}
<Button size="lg" variant="danger" style={{ width: "100%" }} onPress={() => void signOut()}>
Sign out
</Button>
</ManageShell>
);
}
const styles = StyleSheet.create({
badgeRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
notice: {
borderWidth: 1,
borderRadius: radius.md,
padding: 12,
},
});
+237
View File
@@ -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<string | null>(null);
useEffect(() => {
if (projectIds.length > 0 && !projectIds.includes(project)) {
setProject(projectIds[0]);
}
}, [projectIds, project]);
const {
data: approvals,
error: loadError,
loading,
reload,
} = useResource<Approval[]>(
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<Command>(
`/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 : "Couldnt record the verdict.");
} finally {
setBusy(false);
}
}
const rows = approvals ?? [];
return (
<ManageShell
title="Approvals"
subtitle="A merge is denied unless a standing approval exists — made by a second party, pinned to the reviewed commit."
>
{projectIds.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
Register a repository first.
</Text>
) : (
<>
<View style={{ marginBottom: 16 }}>
<Select
label="Repository"
options={projectIds}
value={project || projectIds[0]}
onChange={setProject}
/>
</View>
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<SectionLabel>Record a verdict</SectionLabel>
<Field label="Branch">
<TextField
value={branch}
onChangeText={setBranch}
placeholder="feat/auth"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field
label="Sha"
hint="Optional — defaults to the branch head resolved by the worker."
>
<TextField
value={sha}
onChangeText={setSha}
placeholder="pins the approval"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="PR ref">
<TextField
value={pr}
onChangeText={setPr}
placeholder="optional"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Note">
<TextField value={note} onChangeText={setNote} placeholder="optional" />
</Field>
<View style={styles.verdictRow}>
<Button
style={{ flex: 1 }}
onPress={busy ? undefined : () => submit("approved")}
>
{busy ? "Enqueuing…" : "Approve"}
</Button>
<Button
variant="danger"
style={{ flex: 1 }}
onPress={busy ? undefined : () => submit("rejected")}
>
{busy ? "Enqueuing…" : "Reject"}
</Button>
</View>
</Card>
<ErrorNotice message={error ?? loadError} />
<SectionLabel style={{ marginBottom: 8 }}>
{`${rows.length} verdict${rows.length === 1 ? "" : "s"}`}
</SectionLabel>
{rows.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{loading ? "Loading…" : "No approvals recorded."}
</Text>
) : (
<Card>
{rows.map((ap, i) => (
<View key={ap.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{ap.branch}
</Mono>
<Badge tone={statusTone(ap.status)}>
{statusLabel(ap.status)}
</Badge>
</View>
<Text style={[text.caption, { color: colors.textMuted }]}>
{[
ap.approved_sha ? ap.approved_sha.slice(0, 8) : null,
ap.pr_ref,
verdictBy(ap),
`${timeAgo(ap.created_at)} ago`,
]
.filter(Boolean)
.join(" · ")}
</Text>
{ap.note ? (
<Text
numberOfLines={2}
style={[text.caption, { color: colors.textMuted }]}
>
{ap.note}
</Text>
) : null}
</View>
</View>
))}
</Card>
)}
</>
)}
</ManageShell>
);
}
const styles = StyleSheet.create({
verdictRow: {
flexDirection: "row",
gap: 10,
},
row: {
gap: 4,
paddingVertical: 12,
paddingHorizontal: 16,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
},
});
@@ -0,0 +1,197 @@
import React, { useState } from "react";
import { Linking, 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 { Mono } from "../../components/primitives";
import { TextField } from "../../components/TextField";
import { useAppState } from "../../state/AppState";
import type { Command } from "../../api/client";
/**
* Claude login — sign the control container's `claude` binary into a Claude
* subscription, phone edition of the web dashboard's login flow. Both steps are
* control commands the worker executes (the API container has no claude binary):
* login_start scrapes the authorization URL out of `claude /login`, the operator
* authorizes in the browser and pastes the code back, login_submit feeds it to the
* waiting tmux session. Admin-gated server-side.
*/
type Phase =
| { step: "idle" }
| { step: "starting" }
| { step: "awaiting"; url: string }
| { step: "submitting"; url: string }
| { step: "done" };
export function ClaudeLoginScreen() {
const { colors } = useTheme();
const { client } = useAppState();
const [phase, setPhase] = useState<Phase>({ step: "idle" });
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
async function start() {
if (!client) return;
setError(null);
setPhase({ step: "starting" });
try {
const cmd = await client.api<Command>("/login/start", { method: "POST" });
// login_start boots claude, drives the menu, and scrapes the URL — allow ~90s
// (worker claim latency + boot waits + URL timeout).
const final = await client.trackCommand(cmd.id, { attempts: 180 });
if (!final) {
setError("Still starting — is the control worker running?");
setPhase({ step: "idle" });
return;
}
if (final.status !== "done") {
setError(final.error || "Failed to start the login.");
setPhase({ step: "idle" });
return;
}
const url =
final.result && typeof final.result.url === "string" ? final.result.url : "";
if (!url) {
setError("No login URL was returned by claude.");
setPhase({ step: "idle" });
return;
}
setPhase({ step: "awaiting", url });
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't start the login.");
setPhase({ step: "idle" });
}
}
async function submit() {
if (!client || phase.step !== "awaiting") return;
const trimmed = code.trim();
if (!trimmed) {
setError("Paste the authorization code first.");
return;
}
setError(null);
setPhase({ step: "submitting", url: phase.url });
try {
const cmd = await client.api<Command>("/login/submit", {
method: "POST",
body: { code: trimmed },
});
const final = await client.trackCommand(cmd.id, { attempts: 60 });
if (!final) {
setError("Submit still running — is the control worker running?");
setPhase({ step: "awaiting", url: phase.url });
return;
}
if (final.status === "done") {
setCode("");
setPhase({ step: "done" });
return;
}
setError(final.error || "Login was not confirmed. Re-check the code or restart.");
setPhase({ step: "awaiting", url: phase.url });
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't submit the code.");
setPhase({ step: "awaiting", url: phase.url });
}
}
return (
<ManageShell
title="Claude login"
subtitle="Sign the control container's claude binary into a Claude subscription. New agents launch on whichever account is logged in."
>
<ErrorNotice message={error} />
{phase.step === "idle" || phase.step === "starting" ? (
<Button
size="lg"
style={{ width: "100%" }}
onPress={phase.step === "starting" ? undefined : start}
>
{phase.step === "starting" ? "Starting claude /login…" : "Start login"}
</Button>
) : null}
{phase.step === "awaiting" || phase.step === "submitting" ? (
<View style={{ gap: 16 }}>
<View
style={[
styles.urlBox,
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
<Text style={[text.bodySm, { color: colors.textBody, marginBottom: 8 }]}>
Authorize at this URL, then paste the code claude gives you:
</Text>
<Text selectable style={[styles.urlText, { color: colors.textMuted }]}>
{phase.url}
</Text>
</View>
<Button
variant="secondary"
style={{ width: "100%" }}
onPress={() => void Linking.openURL(phase.url)}
>
Open in browser
</Button>
<Field label="Authorization code">
<TextField
value={code}
onChangeText={setCode}
placeholder="Paste the code from claude.com"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button
size="lg"
style={{ width: "100%" }}
onPress={phase.step === "submitting" ? undefined : submit}
>
{phase.step === "submitting" ? "Submitting…" : "Submit code"}
</Button>
</View>
) : null}
{phase.step === "done" ? (
<View style={{ gap: 16 }}>
<View
style={[
styles.urlBox,
{ backgroundColor: colors.positiveTint, borderColor: colors.positive },
]}
>
<Text style={[text.bodySm, { color: colors.positive }]}>
Claude Code is now logged in on the host new agents will use this
account.
</Text>
</View>
<Button variant="secondary" style={{ width: "100%" }} onPress={start}>
Log in again
</Button>
</View>
) : null}
<Mono style={{ fontSize: 12, color: colors.textMuted, marginTop: 20 }}>
The URL opens claude.com; authorize with the subscription account you want
agents to run on.
</Mono>
</ManageShell>
);
}
const styles = StyleSheet.create({
urlBox: {
borderWidth: 1,
borderRadius: radius.md,
padding: 12,
},
urlText: {
fontSize: 12,
lineHeight: 17,
},
});
+318
View File
@@ -0,0 +1,318 @@
import React, { useState } from "react";
import { Alert, 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 { Select } from "../../components/Select";
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 { ClaudeConnector, McpTransport } from "../../api/client";
/**
* Connectors — MCP servers agents may reach, the mobile counterpart of the web
* dashboard's Claude → Connectors panel. Rows become each run's --mcp-config
* file at the next launch (stdio commands run inside the control container).
* 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.
*/
const TRANSPORT_OPTIONS = [
{ value: "stdio", label: "stdio (run a command)" },
{ value: "http", label: "http (remote server)" },
{ value: "sse", label: "sse (remote server, legacy)" },
];
/* "KEY=value per line" → map; blank and =-less lines are dropped, not errors. */
function parseEnvLines(input: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of input.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
/* "Name: value per line" → map; same lenient skipping as env parsing. */
function parseHeaderLines(input: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of input.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon <= 0) continue;
out[trimmed.slice(0, colon).trim()] = trimmed.slice(colon + 1).trim();
}
return out;
}
export function ConnectorsScreen() {
const { colors } = useTheme();
const { client } = useAppState();
const {
data,
error: loadError,
loading,
reload,
} = useResource<ClaudeConnector[]>("/claude/connectors");
const connectors = data ?? [];
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [transport, setTransport] = useState<McpTransport>("stdio");
const [command, setCommand] = useState("");
const [args, setArgs] = useState("");
const [env, setEnv] = useState("");
const [url, setUrl] = useState("");
const [headers, setHeaders] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const stdio = transport === "stdio";
async function create() {
if (!client) return;
if (!name.trim()) {
setError("A connector name is required.");
return;
}
setError(null);
setBusy(true);
try {
// The server 422s a stdio connector without a command / an http(s)-less
// remote url, so mismatched fields surface inline instead of client-side.
await client.api<ClaudeConnector>("/claude/connectors", {
body: {
name: name.trim(),
transport,
command: stdio ? command.trim() || null : null,
args: stdio ? args.trim().split(/\s+/).filter(Boolean) : [],
env: stdio ? parseEnvLines(env) : {},
url: stdio ? null : url.trim() || null,
headers: stdio ? {} : parseHeaderLines(headers),
enabled: true,
},
});
setName("");
setCommand("");
setArgs("");
setEnv("");
setUrl("");
setHeaders("");
setShowForm(false);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt create the connector.");
} finally {
setBusy(false);
}
}
function toggle(c: ClaudeConnector, enabled: boolean) {
if (!client) return;
client
.api(`/claude/connectors/${c.id}`, { method: "PATCH", body: { enabled } })
.then(reload)
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
}
function confirmDelete(c: ClaudeConnector) {
Alert.alert(
"Delete connector?",
`Agents lose ${c.name} at their next launch. This cant be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
client
?.api(`/claude/connectors/${c.id}`, { method: "DELETE" })
.then(reload)
.catch((e) =>
setError(e instanceof Error ? e.message : "Delete failed."),
);
},
},
],
);
}
return (
<ManageShell
title="Connectors"
subtitle="MCP servers agents can reach — passed to each run as its --mcp-config file, so nothing lands in the repository tree."
>
<View style={styles.headRow}>
<SectionLabel>
{loading
? "Loading…"
: `${connectors.length} connector${connectors.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Name">
<TextField
value={name}
onChangeText={setName}
placeholder="github"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Select
label="Transport"
options={TRANSPORT_OPTIONS}
value={transport}
onChange={(v) => setTransport(v as McpTransport)}
/>
{stdio ? (
<>
<Field label="Command" hint="Runs inside the control container.">
<TextField
value={command}
onChangeText={setCommand}
placeholder="npx"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Arguments" hint="Space-separated.">
<TextField
value={args}
onChangeText={setArgs}
placeholder="-y @modelcontextprotocol/server-github"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Environment" hint="KEY=value per line.">
<TextField
value={env}
onChangeText={setEnv}
placeholder={"GITHUB_TOKEN=ghp_..."}
multiline
height={80}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
</>
) : (
<>
<Field label="URL">
<TextField
value={url}
onChangeText={setUrl}
placeholder="https://mcp.example.com/mcp"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Field>
<Field label="Headers" hint="Name: value per line.">
<TextField
value={headers}
onChangeText={setHeaders}
placeholder={"Authorization: Bearer ..."}
multiline
height={80}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
</>
)}
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
{busy ? "Creating…" : "Add connector"}
</Button>
</Card>
) : null}
<ErrorNotice message={error ?? loadError} />
{connectors.length === 0 && !loading ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No connectors yet.
</Text>
) : (
<Card>
{connectors.map((c, i) => (
<View key={c.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{c.name}
</Mono>
<Badge tone="neutral">{c.transport}</Badge>
{c.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
<Mono
numberOfLines={2}
style={{ fontSize: 12, color: colors.textMuted }}
>
{c.transport === "stdio"
? [c.command ?? "", ...(c.args ?? [])].join(" ").trim()
: c.url ?? ""}
</Mono>
</View>
<View style={styles.rowActions}>
<Switch value={c.enabled} onValueChange={(v) => toggle(c, v)} />
<Button size="sm" variant="danger" onPress={() => confirmDelete(c)}>
Delete
</Button>
</View>
</View>
</View>
))}
</Card>
)}
</ManageShell>
);
}
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,
},
});
+279
View File
@@ -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<Host[]>("/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<string | null>(null);
async function add() {
if (!client) return;
if (!hostname.trim()) {
setError("Hostname is required.");
return;
}
setError(null);
setBusy(true);
try {
await client.api<Host>("/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 : "Couldnt 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 (
<ManageShell
title="Git servers"
subtitle="Each server carries its own credentials: a forge token used by agents, and an SSH deploy key — paste the public half into the forge."
>
<View style={styles.headRow}>
<SectionLabel>
{`${hosts.length} server${hosts.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Hostname">
<TextField
value={hostname}
onChangeText={setHostname}
placeholder="github.com"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Select
label="Forge type"
options={FORGE_OPTIONS}
value={forgeType}
onChange={setForgeType}
/>
<Field label="Base URL" hint="Optional — for self-hosted forges.">
<TextField
value={baseUrl}
onChangeText={setBaseUrl}
placeholder="https://git.corp.internal:8443"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Field>
<Field label="Forge token" hint="Optional — encrypted at rest, never returned.">
<TextField
value={token}
onChangeText={setToken}
placeholder="used by agents forge + git"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<View style={styles.switchRow}>
<View style={{ flex: 1 }}>
<Text style={[text.label, { color: colors.textHeading }]}>
Generate deploy key
</Text>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
The server mints an ed25519 keypair; the public half appears in
the list below.
</Text>
</View>
<Switch value={generateKey} onValueChange={setGenerateKey} />
</View>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : add}>
{busy ? "Adding…" : "Add server"}
</Button>
</Card>
) : null}
<ErrorNotice message={error ?? loadError} />
{loading && hosts.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading</Text>
) : hosts.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No git servers registered (the built-in host map still applies).
</Text>
) : (
<Card>
{hosts.map((h, i) => (
<View key={h.hostname}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={styles.head}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{h.hostname}
</Mono>
<Badge tone="neutral">{h.forge_type}</Badge>
</View>
{h.base_url ? (
<Mono
numberOfLines={1}
style={{ fontSize: 12, color: colors.textMuted }}
>
{h.base_url}
</Mono>
) : null}
{h.has_token ? (
<Text style={[text.caption, { color: colors.textMuted }]}>
token stored
</Text>
) : null}
</View>
<Button size="sm" variant="danger" onPress={() => confirmDelete(h)}>
Delete
</Button>
</View>
{h.ssh_public_key ? (
<View style={{ gap: 6 }}>
<Text style={[text.caption, { color: colors.textMuted }]}>
SSH public key add it to the forge as a deploy key
</Text>
{/* Mono doesn't pass `selectable` through, so a plain Text here
lets the operator long-press to copy the key. */}
<View
style={[
styles.keyBox,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderSubtle,
},
]}
>
<Text
selectable
style={[styles.keyText, { color: colors.textBody }]}
>
{h.ssh_public_key}
</Text>
</View>
</View>
) : null}
</View>
</View>
))}
</Card>
)}
</ManageShell>
);
}
const styles = StyleSheet.create({
headRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
row: {
gap: 10,
paddingVertical: 12,
paddingHorizontal: 16,
},
head: {
flexDirection: "row",
gap: 12,
alignItems: "flex-start",
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
},
keyBox: {
borderWidth: 1,
borderRadius: radius.sm,
padding: 8,
},
keyText: {
fontFamily: fonts.monoRegular,
fontSize: 11,
lineHeight: 15,
},
});
+378
View File
@@ -0,0 +1,378 @@
import React, { useState } from "react";
import { Alert, 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 { Select } from "../../components/Select";
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 { ClaudeModel } from "../../api/client";
/**
* Models — registered model backends, the mobile counterpart of the web
* dashboard's Claude → Models tab. API keys are write-only server-side:
* rows only carry has_api_key, so the edit form never prefills the key and
* offers "new key" / "clear key" verbs instead. Mutations need admin — a
* non-admin token gets an inline 403, never a sign-out.
*/
const HARNESS_OPTIONS = [
{ value: "claude", label: "claude (Anthropic-compatible endpoint)" },
{ value: "pi", label: "pi (bare OpenAI-compatible endpoint)" },
];
interface FormState {
name: string;
baseUrl: string;
model: string;
smallFastModel: string;
harness: "claude" | "pi";
apiKey: string;
clearKey: boolean;
}
const EMPTY_FORM: FormState = {
name: "",
baseUrl: "",
model: "",
smallFastModel: "",
harness: "claude",
apiKey: "",
clearKey: false,
};
export function ModelsScreen() {
const { colors } = useTheme();
const { client } = useAppState();
const {
data: models,
error: loadError,
loading,
reload,
} = useResource<ClaudeModel[]>("/claude/models");
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<ClaudeModel | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = (patch: Partial<FormState>) => setForm((f) => ({ ...f, ...patch }));
function closeForm() {
setShowForm(false);
setEditing(null);
setForm(EMPTY_FORM);
}
function startEdit(m: ClaudeModel) {
setEditing(m);
setShowForm(true);
setError(null);
setForm({
name: m.name,
baseUrl: m.base_url,
model: m.model,
smallFastModel: m.small_fast_model ?? "",
harness: m.harness ?? "claude",
apiKey: "", // write-only; blank = keep the stored key
clearKey: false,
});
}
async function save() {
if (!client) return;
if (!form.name.trim() || !form.baseUrl.trim() || !form.model.trim()) {
setError("Name, base URL, and model id are all required.");
return;
}
setError(null);
setBusy(true);
try {
const fields = {
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
small_fast_model: form.smallFastModel.trim() || null,
harness: form.harness,
};
if (editing) {
await client.api(`/claude/models/${editing.id}`, {
method: "PATCH",
body: {
...fields,
...(form.apiKey.trim()
? { api_key: form.apiKey.trim() }
: form.clearKey
? { clear_api_key: true }
: {}),
},
});
} else {
await client.api("/claude/models", {
body: {
...fields,
...(form.apiKey.trim() ? { api_key: form.apiKey.trim() } : {}),
enabled: true,
},
});
}
closeForm();
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt save the model.");
} finally {
setBusy(false);
}
}
function toggleEnabled(m: ClaudeModel, enabled: boolean) {
if (!client) return;
client
.api(`/claude/models/${m.id}`, { method: "PATCH", body: { enabled } })
.then(reload)
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
}
function confirmDelete(m: ClaudeModel) {
Alert.alert(
"Delete model?",
`Remove ${m.name} from the spawn and schedule forms. This cant be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
client
?.api(`/claude/models/${m.id}`, { method: "DELETE" })
.then(reload)
.catch((e) =>
setError(e instanceof Error ? e.message : "Delete failed."),
);
},
},
],
);
}
const list = models ?? [];
return (
<ManageShell
title="Models"
subtitle="Anthropic-API-compatible model backends the spawn and schedule forms offer next to the Claude subscription."
>
<View style={styles.headRow}>
<SectionLabel>
{loading
? "Loading…"
: `${list.length} model${list.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => {
if (showForm) closeForm();
else {
setError(null);
setShowForm(true);
}
}}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
{editing ? (
<Text style={[text.label, { color: colors.textHeading }]}>
Edit model · {editing.name}
</Text>
) : null}
<Field label="Name" hint="What the spawn dropdown shows.">
<TextField
value={form.name}
onChangeText={(v) => set({ name: v })}
placeholder="qwen3-coder"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Base URL">
<TextField
value={form.baseUrl}
onChangeText={(v) => set({ baseUrl: v })}
placeholder="http://llm.lan:4000"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Field>
<Field label="Model" hint="Model id as the endpoint serves it.">
<TextField
value={form.model}
onChangeText={(v) => set({ model: v })}
placeholder="qwen3-coder-30b"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field
label="Small/fast model"
hint="Optional — defaults to the main model."
>
<TextField
value={form.smallFastModel}
onChangeText={(v) => set({ smallFastModel: v })}
placeholder="qwen3-1.7b"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Select
label="Harness"
options={HARNESS_OPTIONS}
value={form.harness}
onChange={(v) => set({ harness: v as "claude" | "pi" })}
/>
<Field
label={editing ? "New API key" : "API key"}
hint={
editing
? "Optional — blank keeps the stored key. Stored encrypted, never shown again."
: "Optional. Stored encrypted, never shown again."
}
>
<TextField
value={form.apiKey}
onChangeText={(v) => set({ apiKey: v })}
placeholder="sk-…"
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
{editing?.has_api_key ? (
<View style={styles.switchRow}>
<View style={{ flex: 1 }}>
<Text style={[text.label, { color: colors.textHeading }]}>
Clear stored key
</Text>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
Drop the stored key on save (ignored if a new key is set).
</Text>
</View>
<Switch
value={form.clearKey}
onValueChange={(v) => set({ clearKey: v })}
/>
</View>
) : null}
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : save}>
{busy ? "Saving…" : editing ? "Save changes" : "Create model"}
</Button>
</Card>
) : null}
<ErrorNotice message={error ?? loadError} />
{!loading && list.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No model backends yet agents run on the Claude subscription.
</Text>
) : null}
{list.length > 0 ? (
<Card>
{list.map((m, i) => (
<View key={m.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{m.name}
</Mono>
{m.harness === "pi" ? (
<Badge tone="warning">pi harness</Badge>
) : (
<Badge tone="neutral">claude</Badge>
)}
{m.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
<Mono style={{ fontSize: 12, color: colors.textHeading }}>
{m.model}
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""}
</Mono>
<Mono
numberOfLines={1}
style={{ fontSize: 12, color: colors.textMuted }}
>
{m.base_url}
</Mono>
{m.has_api_key ? (
<Text style={[text.caption, { color: colors.textMuted }]}>
key stored
</Text>
) : null}
</View>
<View style={styles.rowActions}>
<Switch
value={m.enabled}
onValueChange={(v) => toggleEnabled(m, v)}
/>
<Button size="sm" variant="secondary" onPress={() => startEdit(m)}>
Edit
</Button>
<Button size="sm" variant="danger" onPress={() => confirmDelete(m)}>
Delete
</Button>
</View>
</View>
</View>
))}
</Card>
) : null}
</ManageShell>
);
}
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,
},
});
@@ -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<ClaudePermissions>("/claude/permissions");
const [form, setForm] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<ClaudePermissions>("/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 : "Couldnt save permissions.");
} finally {
setBusy(false);
}
}
return (
<ManageShell
title="Permissions"
subtitle="Overrides merged over the server baseline into every generated settings.json."
>
<ErrorNotice message={error ?? loadError} />
{data === null || form === null ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{loading ? "Loading permissions…" : "Permissions unavailable."}
</Text>
) : (
<>
<View
style={[
styles.baseline,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderSubtle,
},
]}
>
<SectionLabel style={{ marginBottom: 8 }}>
Server baseline (env)
</SectionLabel>
<Mono style={{ fontSize: 12, color: colors.textMuted }}>
{`mode: ${data.base_mode}`}
</Mono>
{data.base_allow.length > 0 ? (
data.base_allow.map((rule) => (
<Mono key={rule} style={{ fontSize: 12, color: colors.textMuted }}>
{rule}
</Mono>
))
) : (
<Mono style={{ fontSize: 12, color: colors.textMuted }}>
(no baseline allow rules)
</Mono>
)}
</View>
<Card style={{ padding: 16, gap: 14 }}>
<Select
label="Default mode"
options={MODE_OPTIONS}
value={form.mode}
onChange={(v) => setForm({ ...form, mode: v })}
/>
<Field label="Allow" hint="One rule per line, e.g. Bash(npm run *)">
<TextField
value={form.allow}
onChangeText={(v) => setForm({ ...form, allow: v })}
placeholder={"Bash(npm *)\nWebFetch(domain:docs.example.com)"}
multiline
height={90}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Deny" hint="One rule per line, e.g. Read(./secrets/**)">
<TextField
value={form.deny}
onChangeText={(v) => setForm({ ...form, deny: v })}
placeholder={"Bash(rm -rf *)\nRead(./secrets/**)"}
multiline
height={90}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Ask" hint="One rule per line — headless runs deny these.">
<TextField
value={form.ask}
onChangeText={(v) => setForm({ ...form, ask: v })}
placeholder="Bash(git push *)"
multiline
height={90}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : save}>
{busy ? "Saving…" : "Save permissions"}
</Button>
</Card>
</>
)}
</ManageShell>
);
}
const styles = StyleSheet.create({
baseline: {
borderWidth: 1,
borderRadius: radius.md,
padding: 12,
marginBottom: 16,
gap: 2,
},
});
+223
View File
@@ -0,0 +1,223 @@
import React, { useState } from "react";
import { Alert, 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 { 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 { ClaudePlugin } from "../../api/client";
/**
* Plugins — Claude Code marketplace plugins, the mobile counterpart of the web
* dashboard's Claude → Plugins panel. Each row pins a plugin to the marketplace
* serving it; generated settings declare the marketplace and enable the plugin,
* so headless runs install both on boot. 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 PluginsScreen() {
const { colors } = useTheme();
const { client } = useAppState();
const {
data,
error: loadError,
loading,
reload,
} = useResource<ClaudePlugin[]>("/claude/plugins");
const plugins = data ?? [];
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [marketplace, setMarketplace] = useState("");
const [marketplaceRepo, setMarketplaceRepo] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function create() {
if (!client) return;
if (!name.trim() || !marketplace.trim() || !marketplaceRepo.trim()) {
setError("Name, marketplace, and marketplace repo are all required.");
return;
}
setError(null);
setBusy(true);
try {
await client.api<ClaudePlugin>("/claude/plugins", {
body: {
name: name.trim(),
marketplace: marketplace.trim(),
marketplace_repo: marketplaceRepo.trim(),
enabled: true,
},
});
setName("");
setMarketplace("");
setMarketplaceRepo("");
setShowForm(false);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt create the plugin.");
} finally {
setBusy(false);
}
}
function toggle(p: ClaudePlugin, enabled: boolean) {
if (!client) return;
client
.api(`/claude/plugins/${p.id}`, { method: "PATCH", body: { enabled } })
.then(reload)
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
}
function confirmDelete(p: ClaudePlugin) {
Alert.alert(
"Delete plugin?",
`Runs stop installing ${p.name}@${p.marketplace} at their next launch. This cant be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
client
?.api(`/claude/plugins/${p.id}`, { method: "DELETE" })
.then(reload)
.catch((e) =>
setError(e instanceof Error ? e.message : "Delete failed."),
);
},
},
],
);
}
return (
<ManageShell
title="Plugins"
subtitle="Claude Code plugins, pinned to the marketplace serving them. Generated settings declare the marketplace and enable the plugin, so headless runs install both on boot."
>
<View style={styles.headRow}>
<SectionLabel>
{loading
? "Loading…"
: `${plugins.length} plugin${plugins.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Plugin name">
<TextField
value={name}
onChangeText={setName}
placeholder="code-reviewer"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Marketplace key">
<TextField
value={marketplace}
onChangeText={setMarketplace}
placeholder="acme-tools"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Marketplace repo" hint="owner/repo or a git URL.">
<TextField
value={marketplaceRepo}
onChangeText={setMarketplaceRepo}
placeholder="acme/claude-marketplace"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
{busy ? "Creating…" : "Add plugin"}
</Button>
</Card>
) : null}
<ErrorNotice message={error ?? loadError} />
{plugins.length === 0 && !loading ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No plugins yet.
</Text>
) : (
<Card>
{plugins.map((p, i) => (
<View key={p.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{`${p.name}@${p.marketplace}`}
</Mono>
{p.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
<Mono
numberOfLines={1}
style={{ fontSize: 12, color: colors.textMuted }}
>
{p.marketplace_repo}
</Mono>
</View>
<View style={styles.rowActions}>
<Switch value={p.enabled} onValueChange={(v) => toggle(p, v)} />
<Button size="sm" variant="danger" onPress={() => confirmDelete(p)}>
Delete
</Button>
</View>
</View>
</View>
))}
</Card>
)}
</ManageShell>
);
}
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,
},
});
@@ -0,0 +1,358 @@
import React, { useEffect, useState } from "react";
import { Alert, 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 { SegmentedControl } from "../../components/SegmentedControl";
import { Select } from "../../components/Select";
import { Switch } from "../../components/Switch";
import { TextField } from "../../components/TextField";
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
import { timeAgo } from "../../api/format";
import { useAppState } from "../../state/AppState";
import { useResource } from "../../state/useResource";
import type { Command, Host, Project } from "../../api/client";
/**
* Repositories — register + sync the repos Handler manages, the mobile
* counterpart of the web dashboard's Repositories section. The preferred path
* is git-server mode: pick a configured forge host and type owner/name; the
* server derives the remote, clones under PROJECTS_ROOT, and keeps it fresh.
* Manual mode registers an existing checkout by absolute path.
*/
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
type Mode = "server" | "manual";
const enc = encodeURIComponent;
export function RepositoriesScreen() {
const { colors } = useTheme();
const { client, refresh } = useAppState();
const projectsRes = useResource<Project[]>("/projects");
const hostsRes = useResource<Host[]>("/hosts");
const projects = projectsRes.data ?? [];
const hosts = hostsRes.data ?? [];
const hostnames = hosts.map((h) => h.hostname);
const [showForm, setShowForm] = useState(false);
const [mode, setMode] = useState<Mode>("server");
const [gitServer, setGitServer] = useState("");
const [repo, setRepo] = useState("");
const [projectId, setProjectId] = useState("");
const [initMise, setInitMise] = useState(false);
const [rootDir, setRootDir] = useState("");
const [gitRemote, setGitRemote] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Project id of the last enqueued sync — shows a transient "sync queued" note.
const [syncedId, setSyncedId] = useState<string | null>(null);
// Default to the first git server once hosts load (or if the pick vanished).
useEffect(() => {
if (hostnames.length > 0 && !hostnames.includes(gitServer)) {
setGitServer(hostnames[0]);
}
}, [hostnames, gitServer]);
function resetForm() {
setRepo("");
setProjectId("");
setInitMise(false);
setRootDir("");
setGitRemote("");
setShowForm(false);
}
async function register() {
if (!client) return;
if (mode === "server") {
if (!gitServer) {
setError("Pick a git server first (add one under Git servers).");
return;
}
if (!REPO_RE.test(repo.trim())) {
setError("Repository must be owner/name.");
return;
}
} else if (!projectId.trim() || !rootDir.trim()) {
setError("Project id and root dir are both required.");
return;
}
setError(null);
setBusy(true);
try {
const body =
mode === "server"
? {
git_server: gitServer,
repo: repo.trim(),
id: projectId.trim() || undefined,
init_mise: initMise,
}
: {
id: projectId.trim(),
root_dir: rootDir.trim(),
git_remote: gitRemote.trim() || undefined,
};
await client.api<Project>("/projects", { body });
resetForm();
await refresh();
projectsRes.reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt register the repository.");
} finally {
setBusy(false);
}
}
async function sync(p: Project) {
if (!client) return;
setError(null);
try {
await client.api<Command>(`/projects/${enc(p.id)}/sync`, { method: "POST" });
setSyncedId(p.id);
setTimeout(() => setSyncedId((cur) => (cur === p.id ? null : cur)), 4000);
} catch (e) {
setError(e instanceof Error ? e.message : "Sync failed.");
}
}
function confirmDelete(p: Project) {
Alert.alert(
"Delete repository?",
`Unregister ${p.id} from Handler. The checkout on disk is not touched.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
client
?.api(`/projects/${enc(p.id)}`, { method: "DELETE" })
.then(() => {
projectsRes.reload();
return refresh();
})
.catch((e) =>
setError(e instanceof Error ? e.message : "Delete failed."),
);
},
},
],
);
}
return (
<ManageShell
title="Repositories"
subtitle="Repos Handler manages. Each carries its own agents, history, and credentials."
>
<View style={styles.headRow}>
<SectionLabel>
{`${projects.length} repositor${projects.length === 1 ? "y" : "ies"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<SegmentedControl<Mode>
segments={[
{ value: "server", label: "Git server" },
{ value: "manual", label: "Manual" },
]}
value={mode}
onChange={setMode}
/>
{mode === "server" ? (
<>
{hostnames.length > 0 ? (
<Select
label="Git server"
options={hosts.map((h) => ({
value: h.hostname,
label: `${h.hostname} (${h.forge_type})`,
}))}
value={gitServer || hostnames[0]}
onChange={setGitServer}
/>
) : (
<Field
label="Git server"
hint="No git servers configured — add one under Git servers first."
>
<Text style={[text.bodySm, { color: colors.textMuted }]}>
None registered yet.
</Text>
</Field>
)}
<Field
label="Repository"
hint="The server derives the remote, clones it, and keeps it fresh before every run."
>
<TextField
value={repo}
onChangeText={setRepo}
placeholder="owner/name"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Project id" hint="Optional — defaults to a slug of the repo name.">
<TextField
value={projectId}
onChangeText={setProjectId}
placeholder="coolproj"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<View style={styles.switchRow}>
<View style={{ flex: 1 }}>
<Text style={[text.label, { color: colors.textHeading }]}>
Initialize mise
</Text>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
Queues a bootstrap agent that authors .mise.toml with a test
task and pushes it.
</Text>
</View>
<Switch value={initMise} onValueChange={setInitMise} />
</View>
</>
) : (
<>
<Field label="Project id">
<TextField
value={projectId}
onChangeText={setProjectId}
placeholder="leeworks-api"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Root dir" hint="Absolute path on the control container.">
<TextField
value={rootDir}
onChangeText={setRootDir}
placeholder="/var/lib/handler/projects/leeworks"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Git remote" hint="Optional — needed for Sync and pushes.">
<TextField
value={gitRemote}
onChangeText={setGitRemote}
placeholder="git@github.com:user/repo.git"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
</>
)}
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : register}>
{busy ? "Registering…" : mode === "server" ? "Add & pull" : "Register"}
</Button>
</Card>
) : null}
<ErrorNotice message={error ?? projectsRes.error} />
{projectsRes.loading && projects.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading</Text>
) : projects.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No repositories registered.
</Text>
) : (
<Card>
{projects.map((p, i) => (
<View key={p.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{p.id}
</Mono>
{p.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
<Mono
numberOfLines={1}
style={{ fontSize: 12, color: colors.textMuted }}
>
{p.git_remote || p.root_dir}
</Mono>
<Text style={[text.caption, { color: colors.textMuted }]}>
added {timeAgo(p.created_at)} ago
</Text>
{syncedId === p.id ? (
<Text style={[text.caption, { color: colors.positive }]}>
sync queued
</Text>
) : null}
</View>
<View style={styles.rowActions}>
{p.git_remote ? (
<Button size="sm" variant="secondary" onPress={() => sync(p)}>
Sync
</Button>
) : null}
<Button size="sm" variant="danger" onPress={() => confirmDelete(p)}>
Delete
</Button>
</View>
</View>
</View>
))}
</Card>
)}
</ManageShell>
);
}
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,
},
});
@@ -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<SharedContext[]>("/shared/context");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(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<SharedContext>(
`/shared/context/${encodeURIComponent(key.trim())}`,
{ method: "PUT", body: { value: value.trim() } },
);
setKey("");
setValue("");
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Couldnt set the key.");
} finally {
setBusy(false);
}
}
const rows = data ?? [];
return (
<ManageShell
title="Shared context"
subtitle="Key/value facts every agent across every project can read."
>
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<SectionLabel>Set a key</SectionLabel>
<Field label="Key">
<TextField
value={key}
onChangeText={setKey}
placeholder="staging_url"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field
label="Value"
hint="Requires the shared-context write token (or admin/global if unset)."
>
<TextField
value={value}
onChangeText={setValue}
placeholder="value"
multiline
height={80}
/>
</Field>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : set}>
{busy ? "Setting…" : "Set"}
</Button>
</Card>
<ErrorNotice message={error ?? loadError} />
<SectionLabel style={{ marginBottom: 8 }}>
{`${rows.length} key${rows.length === 1 ? "" : "s"}`}
</SectionLabel>
{rows.length === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{loading ? "Loading…" : "No shared context set."}
</Text>
) : (
<Card>
{rows.map((c, i) => (
<View key={c.key}>
{i > 0 && <Divider />}
<Pressable
style={styles.row}
onPress={() => setExpanded((k) => (k === c.key ? null : c.key))}
>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{c.key}
</Mono>
{expanded === c.key ? (
<View
style={[
styles.valueBlock,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderSubtle,
},
]}
>
<Mono style={{ fontSize: 12, color: colors.textBody }}>
{c.value}
</Mono>
</View>
) : (
<Text
numberOfLines={2}
style={[text.bodySm, { color: colors.textMuted }]}
>
{c.value}
</Text>
)}
<Text style={[text.caption, { color: colors.textMuted }]}>
{c.set_by_agent_id != null
? `agent #${c.set_by_agent_id}`
: "operator"}
{` · updated ${timeAgo(c.updated_at)} ago`}
</Text>
</Pressable>
</View>
))}
</Card>
)}
</ManageShell>
);
}
const styles = StyleSheet.create({
row: {
gap: 4,
paddingVertical: 12,
paddingHorizontal: 16,
},
valueBlock: {
borderWidth: 1,
borderRadius: radius.md,
padding: 10,
marginTop: 2,
},
});
+345
View File
@@ -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<ClaudeSkill[]>("/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<string | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [installPrompt, setInstallPrompt] = useState("");
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [installSummary, setInstallSummary] = useState<string | null>(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<ClaudeSkill>("/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 : "Couldnt 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 cant 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<Command>("/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 (
<ManageShell
title="Skills"
subtitle="Managed skills, synced to every workers ~/.claude/skills at each agent launch. The description is what makes Claude pick a skill up."
>
<View style={styles.headRow}>
<SectionLabel>
{loading
? "Loading…"
: `${skills.length} skill${skills.length === 1 ? "" : "s"}`}
</SectionLabel>
<Button
size="sm"
variant={showForm ? "secondary" : "primary"}
onPress={() => setShowForm((v) => !v)}
>
{showForm ? "Cancel" : "New"}
</Button>
</View>
{showForm ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Name" hint="A slug — becomes the skill directory.">
<TextField
value={name}
onChangeText={setName}
placeholder="deploy-checklist"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Field label="Description" hint="When should Claude use it? (optional)">
<TextField
value={description}
onChangeText={setDescription}
placeholder="Use when preparing or reviewing a deploy."
/>
</Field>
<Field label="SKILL.md body">
<TextField
value={content}
onChangeText={setContent}
placeholder={"# Deploy checklist\n\n1. ..."}
multiline
height={160}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
{busy ? "Creating…" : "Add skill"}
</Button>
</Card>
) : null}
<SectionLabel style={{ marginBottom: 8 }}>Install from a marketplace prompt</SectionLabel>
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field
label="Install prompt"
hint="Runs headlessly on the worker — when the instructions offer choices, Claude picks the defaults (always user scope). Review the imported skill below afterwards."
>
<TextField
value={installPrompt}
onChangeText={setInstallPrompt}
placeholder="Paste the whole prompt the marketplace page tells you to give Claude."
multiline
height={100}
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<Button
size="lg"
style={{ width: "100%" }}
onPress={installing || !installPrompt.trim() ? undefined : install}
>
{installing ? "Installing…" : "Run install"}
</Button>
{installSummary ? (
<Mono style={{ fontSize: 12, color: colors.positive }} numberOfLines={4}>
{installSummary}
</Mono>
) : null}
<ErrorNotice message={installError} />
</Card>
<ErrorNotice message={error ?? loadError} />
{skills.length === 0 && !loading ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>
No custom skills yet.
</Text>
) : (
<Card>
{skills.map((sk, i) => {
const expanded = expandedId === sk.id;
return (
<View key={sk.id}>
{i > 0 && <Divider />}
<Pressable
style={styles.row}
onPress={() => setExpandedId(expanded ? null : sk.id)}
>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{sk.name}
</Mono>
{sk.owner_user_id != null ? (
<Badge tone="neutral">private</Badge>
) : null}
</View>
{sk.description ? (
<Text
numberOfLines={expanded ? undefined : 2}
style={[text.caption, { color: colors.textMuted }]}
>
{sk.description}
</Text>
) : null}
{sk.files.length > 0 ? (
<Text style={[text.caption, { color: colors.textMuted }]}>
ships with {sk.files.length} file
{sk.files.length === 1 ? "" : "s"}
</Text>
) : null}
</View>
<View style={styles.rowActions}>
<Switch value={sk.enabled} onValueChange={(v) => toggle(sk, v)} />
<Button size="sm" variant="danger" onPress={() => confirmDelete(sk)}>
Delete
</Button>
</View>
</Pressable>
{expanded ? (
<View style={styles.detail}>
<View
style={[
styles.monoBlock,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderSubtle,
},
]}
>
<Mono style={{ fontSize: 12, color: colors.textBody }}>
{sk.content}
</Mono>
</View>
{sk.files.map((f) => (
<Mono
key={f}
style={{ fontSize: 12, color: colors.textMuted }}
numberOfLines={1}
>
{f}
</Mono>
))}
</View>
) : null}
</View>
);
})}
</Card>
)}
</ManageShell>
);
}
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,
},
});
+256
View File
@@ -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<User[]>(
"/auth/users",
);
const [email, setEmail] = useState("");
const [inviteAdmin, setInviteAdmin] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastLink, setLastLink] = useState<LastLink | null>(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<UserCreated>("/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<Pick<User, "is_admin" | "disabled">>) {
if (!client) return;
setError(null);
try {
await client.api<User>(`/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<ResetLink>(`/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 : "Couldnt 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 (
<ManageShell
title="Users"
subtitle="Accounts for this Handler. Each user's projects, skills, and tools are theirs alone; shared resources are admin-managed."
>
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
<Field label="Email" hint="The invitee sets their own password through a one-shot link.">
<TextField
value={email}
onChangeText={setEmail}
placeholder="new-user@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
</Field>
<View style={styles.switchRow}>
<Text style={[text.label, { color: colors.textHeading }]}>Admin</Text>
<Switch value={inviteAdmin} onValueChange={setInviteAdmin} />
</View>
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : invite}>
{busy ? "Inviting…" : "Invite user"}
</Button>
</Card>
<ErrorNotice message={error} />
{lastLink ? (
<Card style={{ padding: 16, marginBottom: 16, gap: 8 }}>
<Text style={[text.label, { color: colors.textHeading }]}>
One-shot set-password link for {lastLink.email}
</Text>
<Text
selectable
style={{ fontFamily: fonts.monoRegular, fontSize: 12, color: colors.textBody }}
>
{lastLink.url}
</Text>
<Text style={[text.caption, { color: colors.textMuted }]}>
{lastLink.emailed
? "Also emailed to them. It expires; share over a channel you trust."
: "SMTP is off — hand this link over yourself. It expires."}
</Text>
</Card>
) : null}
<SectionLabel style={{ marginBottom: 8 }}>
{`${count} user${count === 1 ? "" : "s"}`}
</SectionLabel>
{listError ? (
<ErrorNotice message={listError} />
) : loading && !users ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading</Text>
) : count === 0 ? (
<Text style={[text.bodySm, { color: colors.textMuted }]}>No users yet.</Text>
) : (
<Card>
{(users ?? []).map((u, i) => (
<View key={u.id}>
{i > 0 && <Divider />}
<View style={styles.row}>
<View style={{ flex: 1, gap: 4 }}>
<View style={styles.titleRow}>
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
{u.email}
</Mono>
{u.is_admin ? <Badge tone="positive">admin</Badge> : null}
{u.disabled ? <Badge tone="danger">disabled</Badge> : null}
{!u.has_password ? <Badge tone="warning">invite pending</Badge> : null}
</View>
<Text style={[text.caption, { color: colors.textMuted }]}>
created {timeAgo(u.created_at)} ago
</Text>
<View style={styles.switchRow}>
<Text style={[text.caption, { color: colors.textMuted }]}>admin</Text>
<Switch
value={u.is_admin}
onValueChange={(v) => void patchUser(u.id, { is_admin: v })}
/>
</View>
<View style={styles.switchRow}>
<Text style={[text.caption, { color: colors.textMuted }]}>disabled</Text>
<Switch
value={u.disabled}
onValueChange={(v) => void patchUser(u.id, { disabled: v })}
/>
</View>
</View>
<View style={styles.rowActions}>
<Button size="sm" variant="secondary" onPress={() => void mintResetLink(u)}>
Reset link
</Button>
<Button size="sm" variant="danger" onPress={() => confirmDelete(u)}>
Delete
</Button>
</View>
</View>
</View>
))}
</Card>
)}
</ManageShell>
);
}
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,
},
});
+6 -1
View File
@@ -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<MemoryGraph | null>(null);
const [memoryError, setMemoryError] = useState<string | null>(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,