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