mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-05 02:46:25 +00:00
c3e3d1627e
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
189 lines
6.0 KiB
TypeScript
189 lines
6.0 KiB
TypeScript
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 : "Couldn’t 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,
|
||
},
|
||
});
|