mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-13 23:46:25 +00:00
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:
@@ -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 : "Couldn’t 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user