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
+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,
},
});