Files
handler/app/src/screens/manage/PermissionsScreen.tsx
T
Claude c3e3d1627e 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
2026-08-13 13:55:18 +00:00

189 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
});