mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 07:06: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:
+26
-4
@@ -33,6 +33,18 @@ import {
|
||||
type Screen as ScreenName,
|
||||
} from "./src/state/AppState";
|
||||
import { ManageScreen } from "./src/screens/manage/ManageScreen";
|
||||
import { ModelsScreen } from "./src/screens/manage/ModelsScreen";
|
||||
import { SkillsScreen } from "./src/screens/manage/SkillsScreen";
|
||||
import { ConnectorsScreen } from "./src/screens/manage/ConnectorsScreen";
|
||||
import { PluginsScreen } from "./src/screens/manage/PluginsScreen";
|
||||
import { PermissionsScreen } from "./src/screens/manage/PermissionsScreen";
|
||||
import { RepositoriesScreen } from "./src/screens/manage/RepositoriesScreen";
|
||||
import { GitServersScreen } from "./src/screens/manage/GitServersScreen";
|
||||
import { ApprovalsScreen } from "./src/screens/manage/ApprovalsScreen";
|
||||
import { SharedContextScreen } from "./src/screens/manage/SharedContextScreen";
|
||||
import { UsersScreen } from "./src/screens/manage/UsersScreen";
|
||||
import { AccountScreen } from "./src/screens/manage/AccountScreen";
|
||||
import { ClaudeLoginScreen } from "./src/screens/manage/ClaudeLoginScreen";
|
||||
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
||||
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
||||
import { FleetScreen } from "./src/screens/FleetScreen";
|
||||
@@ -47,9 +59,7 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
|
||||
function Router() {
|
||||
const { screen } = useAppState();
|
||||
|
||||
// Partial while the management subscreens land one by one; anything unmapped
|
||||
// falls back to the fleet so a half-wired build never renders undefined.
|
||||
const screens: Partial<Record<ScreenName, () => React.JSX.Element>> = {
|
||||
const screens: Record<ScreenName, () => React.JSX.Element> = {
|
||||
connect: ConnectScreen,
|
||||
fleet: FleetScreen,
|
||||
detail: AgentDetailScreen,
|
||||
@@ -60,8 +70,20 @@ function Router() {
|
||||
log: LogScreen,
|
||||
settings: SettingsScreen,
|
||||
manage: ManageScreen,
|
||||
models: ModelsScreen,
|
||||
skills: SkillsScreen,
|
||||
connectors: ConnectorsScreen,
|
||||
plugins: PluginsScreen,
|
||||
permissions: PermissionsScreen,
|
||||
repositories: RepositoriesScreen,
|
||||
gitServers: GitServersScreen,
|
||||
approvals: ApprovalsScreen,
|
||||
shared: SharedContextScreen,
|
||||
users: UsersScreen,
|
||||
account: AccountScreen,
|
||||
claudeLogin: ClaudeLoginScreen,
|
||||
};
|
||||
const Screen = screens[screen] ?? FleetScreen;
|
||||
const Screen = screens[screen];
|
||||
|
||||
return <Screen />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
@@ -10,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { SegmentedControl } from "../components/SegmentedControl";
|
||||
import { TextField } from "../components/TextField";
|
||||
import { SectionLabel } from "../components/primitives";
|
||||
import {
|
||||
@@ -17,13 +20,27 @@ import {
|
||||
useServerConfig,
|
||||
type ServerConfig,
|
||||
} from "../state/ServerConfig";
|
||||
import { AuthError, createClient, type Project } from "../api/client";
|
||||
import {
|
||||
authApi,
|
||||
AuthError,
|
||||
createClient,
|
||||
type ApiError,
|
||||
type AuthStatus,
|
||||
type Project,
|
||||
type SessionResponse,
|
||||
} from "../api/client";
|
||||
|
||||
/**
|
||||
* First-open configuration screen, shown whenever no server config is stored (and after a
|
||||
* Sign out or a persistent 401). Verifies connectivity (GET /health) and the token
|
||||
* (GET /projects) before persisting, distinguishing an unreachable endpoint from a bad
|
||||
* token in the inline error. Matches the SpawnScreen layout.
|
||||
* First-open configuration screen, shown whenever no server config is stored (and after
|
||||
* a Sign out or a persistent 401). Two ways in, mirroring the web dashboard's gate:
|
||||
*
|
||||
* - **Email** (default): sign in against /auth/login; the returned session token is
|
||||
* stored and used exactly like the legacy env token. A server with zero accounts
|
||||
* (GET /auth/status → initialized: false) flips the form into first-run setup — the
|
||||
* account created there becomes the admin. A server predating user accounts (404 on
|
||||
* /auth/status) falls back to the token method with a note.
|
||||
* - **API token**: the legacy env token, verified via /health + /projects before it is
|
||||
* persisted (distinguishing an unreachable endpoint from a rejected token).
|
||||
*/
|
||||
export function ConnectScreen() {
|
||||
const { colors } = useTheme();
|
||||
@@ -31,55 +48,161 @@ export function ConnectScreen() {
|
||||
const { lastEndpoint, save } = useServerConfig();
|
||||
|
||||
const [endpoint, setEndpoint] = useState(lastEndpoint || DEFAULT_ENDPOINT);
|
||||
const [method, setMethod] = useState<"email" | "token">("email");
|
||||
const [setup, setSetup] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
async function connect() {
|
||||
const ep = endpoint.trim();
|
||||
const tok = token.trim();
|
||||
if (!ep) {
|
||||
setError("Enter an endpoint.");
|
||||
function isApiError(e: unknown): e is ApiError {
|
||||
return e instanceof Error && typeof (e as ApiError).status === "number";
|
||||
}
|
||||
|
||||
async function connectWithEmail(ep: string) {
|
||||
if (!email.trim() || !password) {
|
||||
setError("Enter your email and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe first: unreachable endpoint, pre-accounts server, and first-run all
|
||||
// need different flows, and /auth/status is the unauthenticated discriminator.
|
||||
let status: AuthStatus;
|
||||
try {
|
||||
status = await authApi<AuthStatus>(ep, "/auth/status");
|
||||
} catch (e) {
|
||||
if (isApiError(e) && e.status === 404) {
|
||||
setMethod("token");
|
||||
setNote(
|
||||
"This server predates user accounts — connect with its API token instead.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.initialized) {
|
||||
if (!setup) {
|
||||
// Reveal the confirm field and let the operator opt in explicitly —
|
||||
// creating the admin account should never happen off a mistyped tap.
|
||||
setSetup(true);
|
||||
setNote(
|
||||
"This server has no accounts yet. The account you create now becomes the admin.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords don't match.");
|
||||
return;
|
||||
}
|
||||
const session = await authApi<SessionResponse>(ep, "/auth/setup", {
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
await save({ endpoint: ep, token: session.token });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authApi<SessionResponse>(ep, "/auth/login", {
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
await save({ endpoint: ep, token: session.token });
|
||||
}
|
||||
|
||||
async function connectWithToken(ep: string) {
|
||||
const tok = token.trim();
|
||||
if (!tok) {
|
||||
setError("Enter an API token.");
|
||||
return;
|
||||
}
|
||||
const client = createClient(ep, tok, () => {});
|
||||
|
||||
// 1. Connectivity — /health needs no auth, so a failure here is the endpoint.
|
||||
try {
|
||||
await client.api<{ status: string }>("/health");
|
||||
} catch {
|
||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Auth — a 401 on /projects is the token, not the endpoint.
|
||||
try {
|
||||
await client.api<Project[]>("/projects");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
setError("Token rejected. Check your API token.");
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load projects.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: ServerConfig = { endpoint: ep, token: tok };
|
||||
await save(cfg);
|
||||
// The gate in App.tsx swaps this screen for the fleet once config is set.
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const ep = endpoint.trim();
|
||||
if (!ep) {
|
||||
setError("Enter an endpoint.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNote(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const client = createClient(ep, tok, () => {});
|
||||
|
||||
// 1. Connectivity — /health needs no auth, so a failure here is the endpoint.
|
||||
try {
|
||||
await client.api<{ status: string }>("/health");
|
||||
} catch {
|
||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
||||
return;
|
||||
if (method === "email") {
|
||||
await connectWithEmail(ep);
|
||||
} else {
|
||||
await connectWithToken(ep);
|
||||
}
|
||||
|
||||
// 2. Auth — a 401 on /projects is the token, not the endpoint.
|
||||
try {
|
||||
await client.api<Project[]>("/projects");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
setError("Token rejected. Check your API token.");
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load projects.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: ServerConfig = { endpoint: ep, token: tok };
|
||||
await save(cfg);
|
||||
// The gate in App.tsx swaps this screen for the fleet once config is set.
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't connect.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgot() {
|
||||
const ep = endpoint.trim();
|
||||
if (!ep || !email.trim()) {
|
||||
setError("Enter the endpoint and your email first.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await authApi<{ ok: boolean; emailed: boolean }>(
|
||||
ep,
|
||||
"/auth/forgot",
|
||||
{ email: email.trim() },
|
||||
);
|
||||
setNote(
|
||||
res.emailed
|
||||
? "If that address has an account, a reset link is on its way."
|
||||
: "This server has no email configured — ask an admin for a reset link.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't request a reset.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonLabel = busy
|
||||
? "Connecting…"
|
||||
: setup
|
||||
? "Create admin account"
|
||||
: method === "email"
|
||||
? "Sign in"
|
||||
: "Connect";
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
@@ -87,7 +210,12 @@ export function ConnectScreen() {
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.heading}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
@@ -110,19 +238,97 @@ export function ConnectScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
API token
|
||||
</Text>
|
||||
<TextField
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="Bearer token"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
{!setup ? (
|
||||
<SegmentedControl
|
||||
segments={[
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "token", label: "API token" },
|
||||
]}
|
||||
value={method}
|
||||
onChange={(v) => {
|
||||
setMethod(v as "email" | "token");
|
||||
setError(null);
|
||||
setNote(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{method === "email" ? (
|
||||
<>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Email
|
||||
</Text>
|
||||
<TextField
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="you@example.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Password
|
||||
</Text>
|
||||
<TextField
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{setup ? (
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Confirm password
|
||||
</Text>
|
||||
<TextField
|
||||
value={confirm}
|
||||
onChangeText={setConfirm}
|
||||
placeholder="Same password again"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={busy ? undefined : forgot} hitSlop={8}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
Forgot password?
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
API token
|
||||
</Text>
|
||||
<TextField
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="Bearer token"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{note ? (
|
||||
<View
|
||||
style={[
|
||||
styles.noteBox,
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.textBody }]}>{note}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
@@ -139,16 +345,16 @@ export function ConnectScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<View style={{ marginTop: 28 }}>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : connect}
|
||||
>
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
@@ -157,11 +363,16 @@ export function ConnectScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
content: { flexGrow: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
heading: { marginTop: 12, marginBottom: 24 },
|
||||
errorBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
},
|
||||
noteBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { radius, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Chip } from "../components/Chip";
|
||||
import { Select } from "../components/Select";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { TextField } from "../components/TextField";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||
import { useAppState, type BadgeTone } from "../state/AppState";
|
||||
import { timeAgo } from "../api/format";
|
||||
import type { MemoryNote } from "../api/client";
|
||||
|
||||
/**
|
||||
* Memory — a read view over the agent-memory note graph (the web dashboard's
|
||||
* Memory page). Notes are the distilled facts/decisions/gotchas/runbooks agents
|
||||
* leave for each other; tapping a note expands its body, tags, and links.
|
||||
* Authoring stays on the web dashboard (admin token) and in the agents' own
|
||||
* MCP server — the phone is for looking things up.
|
||||
* Memory — the agent-memory note graph (the web dashboard's Memory page).
|
||||
* Notes are the distilled facts/decisions/gotchas/runbooks agents leave for
|
||||
* each other; tapping a note expands its body, tags, and links. Operators can
|
||||
* author and delete notes here too (admin-gated server-side — notes feed every
|
||||
* future agent's context); link editing stays on the web dashboard's graph UI.
|
||||
*/
|
||||
|
||||
const KIND_FILTERS = ["all", "fact", "decision", "gotcha", "runbook"];
|
||||
@@ -31,11 +41,81 @@ const KIND_TONES: Record<string, BadgeTone> = {
|
||||
export function MemoryScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { memory, memoryError } = useAppState();
|
||||
const { client, memory, memoryError, reloadMemory, projects } = useAppState();
|
||||
|
||||
const [kind, setKind] = useState("all");
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
|
||||
// New-note form (POST /memory/notes; admin-gated server-side).
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [noteKind, setNoteKind] = useState("fact");
|
||||
const [project, setProject] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
async function createNote() {
|
||||
if (!client) return;
|
||||
if (!title.trim() || !body.trim()) {
|
||||
setFormError("A title and a body are both required.");
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await client.api("/memory/notes", {
|
||||
body: {
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
kind: noteKind,
|
||||
project_id: project || null,
|
||||
...(tagList.length > 0 ? { tags: tagList } : {}),
|
||||
},
|
||||
});
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setBody("");
|
||||
setShowForm(false);
|
||||
reloadMemory();
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : "Couldn't save the note.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteNote(n: MemoryNote) {
|
||||
Alert.alert(
|
||||
"Delete note?",
|
||||
`Remove "${n.title}" and its links. Future agents stop seeing it.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (!client) return;
|
||||
client
|
||||
.api(`/memory/notes/${n.id}`, { method: "DELETE" })
|
||||
.then(() => {
|
||||
setOpenId(null);
|
||||
reloadMemory();
|
||||
})
|
||||
.catch((e) =>
|
||||
setFormError(e instanceof Error ? e.message : "Delete failed."),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const notes = useMemo(() => {
|
||||
const all = memory?.notes ?? [];
|
||||
const inKind = kind === "all" ? all : all.filter((n) => n.kind === kind);
|
||||
@@ -56,9 +136,9 @@ export function MemoryScreen() {
|
||||
const out: { key: string; label: string }[] = [];
|
||||
for (const l of links) {
|
||||
if (l.src_note_id === note.id) {
|
||||
out.push({ key: `o${l.id}`, label: `${l.relation} → ${title(byId, l.dst_note_id)}` });
|
||||
out.push({ key: `o${l.id}`, label: `${l.relation} → ${noteTitle(byId, l.dst_note_id)}` });
|
||||
} else if (l.dst_note_id === note.id) {
|
||||
out.push({ key: `i${l.id}`, label: `${title(byId, l.src_note_id)} → ${l.relation}` });
|
||||
out.push({ key: `i${l.id}`, label: `${noteTitle(byId, l.src_note_id)} → ${l.relation}` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -69,10 +149,18 @@ export function MemoryScreen() {
|
||||
<View style={{ height: insets.top }} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Memory</Text>
|
||||
<View style={styles.headRow}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Memory</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showForm ? "secondary" : "primary"}
|
||||
onPress={() => setShowForm((v) => !v)}
|
||||
>
|
||||
{showForm ? "Cancel" : "New"}
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4 }]}>
|
||||
Notes agents distill for every future run. Edit them from the web
|
||||
dashboard.
|
||||
Notes agents distill for every future run.
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -95,6 +183,77 @@ export function MemoryScreen() {
|
||||
contentContainerStyle={styles.body}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{showForm ? (
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Title
|
||||
</Text>
|
||||
<TextField
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="One-line takeaway"
|
||||
/>
|
||||
</View>
|
||||
<Select
|
||||
label="Kind"
|
||||
options={["fact", "decision", "gotcha", "runbook"]}
|
||||
value={noteKind}
|
||||
onChange={setNoteKind}
|
||||
/>
|
||||
<Select
|
||||
label="Scope"
|
||||
options={[
|
||||
{ value: "", label: "Global (all projects)" },
|
||||
...projects.map((p) => ({ value: p.id, label: p.id })),
|
||||
]}
|
||||
value={project}
|
||||
onChange={setProject}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Tags
|
||||
</Text>
|
||||
<TextField
|
||||
value={tags}
|
||||
onChangeText={setTags}
|
||||
placeholder="comma, separated (optional)"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Body
|
||||
</Text>
|
||||
<TextField
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
placeholder="What should future agents know?"
|
||||
multiline
|
||||
height={100}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : createNote}
|
||||
>
|
||||
{busy ? "Saving…" : "Save note"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{formError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{memoryError ? (
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{memoryError}</Text>
|
||||
) : memory === null ? (
|
||||
@@ -155,6 +314,14 @@ export function MemoryScreen() {
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onPress={() => confirmDeleteNote(n)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -171,15 +338,26 @@ export function MemoryScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function title(byId: Map<number, MemoryNote>, id: number): string {
|
||||
function noteTitle(byId: Map<number, MemoryNote>, id: number): string {
|
||||
return byId.get(id)?.title ?? `note #${id}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 14 },
|
||||
headRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 },
|
||||
body: { paddingHorizontal: 20, paddingBottom: 24 },
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
row: { paddingVertical: 12, paddingHorizontal: 16 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import { 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 { TextField } from "../../components/TextField";
|
||||
import { ErrorNotice, Field, ManageShell } from "../../components/ManageShell";
|
||||
import { Card, SectionLabel } from "../../components/primitives";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import { useServerConfig } from "../../state/ServerConfig";
|
||||
import { useResource } from "../../state/useResource";
|
||||
import { AuthError, type Me } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Account (Settings → Account): who this session is, change password, sign out.
|
||||
* Sessions come in two kinds — a user account (email + password, revocable
|
||||
* sessions) or the legacy env API token (configuration, not a session): tokens
|
||||
* have no password to change and nothing server-side to revoke on sign-out.
|
||||
*/
|
||||
|
||||
export function AccountScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const { clear } = useServerConfig();
|
||||
const { data: me, error: meError, loading } = useResource<Me>("/auth/me");
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
async function changePassword() {
|
||||
if (!client) return;
|
||||
if (!currentPassword || !newPassword) {
|
||||
setError("Both the current and the new password are required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.api("/auth/change-password", {
|
||||
body: { current_password: currentPassword, new_password: newPassword },
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setNotice("Password changed — other sessions were signed out.");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return; // handled by onUnauthorized
|
||||
setError(e instanceof Error ? e.message : "Password change failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
// Revoke the session server-side when possible; a dead server or an env
|
||||
// token must never block the local sign-out.
|
||||
if (client) {
|
||||
try {
|
||||
await client.api("/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* ignore — clearing the stored config is the part that matters */
|
||||
}
|
||||
}
|
||||
await clear();
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell title="Account" backTo="settings">
|
||||
{meError ? <ErrorNotice message={meError} /> : null}
|
||||
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Signed in as</SectionLabel>
|
||||
<Card style={{ padding: 16, marginBottom: 20, gap: 8 }}>
|
||||
{loading && !me ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[text.body, { color: colors.textHeading }]}>
|
||||
{me?.kind === "token" ? "legacy API token" : me?.email ?? "—"}
|
||||
</Text>
|
||||
<View style={styles.badgeRow}>
|
||||
<Badge tone={me?.is_admin ? "positive" : "neutral"}>
|
||||
{me?.is_admin ? "admin" : "member"}
|
||||
</Badge>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Change password</SectionLabel>
|
||||
{me?.kind === "token" ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginBottom: 20 }]}>
|
||||
Env tokens have no password — this session authenticates with the
|
||||
server's configured API token.
|
||||
</Text>
|
||||
) : (
|
||||
<Card style={{ padding: 16, marginBottom: 20, gap: 14 }}>
|
||||
<Field label="Current password">
|
||||
<TextField
|
||||
value={currentPassword}
|
||||
onChangeText={setCurrentPassword}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="New password">
|
||||
<TextField
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<ErrorNotice message={error} />
|
||||
{notice ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.positiveTint, borderColor: colors.positive },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.positive }]}>{notice}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : changePassword}
|
||||
>
|
||||
{busy ? "Changing…" : "Change password"}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button size="lg" variant="danger" style={{ width: "100%" }} onPress={() => void signOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badgeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { 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 { TextField } from "../../components/TextField";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import { useResource } from "../../state/useResource";
|
||||
import { statusLabel, statusTone, timeAgo } from "../../api/format";
|
||||
import type { Approval, Command } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Approvals — record a per-branch verdict (the review gate), the mobile counterpart
|
||||
* of the web dashboard's Approvals section. A verdict is enqueued as a control
|
||||
* command so the worker can resolve the reviewed HEAD and pin the approval.
|
||||
*/
|
||||
|
||||
/* "agent #N" for agent-made verdicts, else the recorded actor (operator:web etc). */
|
||||
function verdictBy(ap: Approval): string | null {
|
||||
if (ap.approved_by_agent_id != null) return `agent #${ap.approved_by_agent_id}`;
|
||||
return ap.actor || null;
|
||||
}
|
||||
|
||||
export function ApprovalsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client, projects } = useAppState();
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const [project, setProject] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [sha, setSha] = useState("");
|
||||
const [pr, setPr] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectIds.length > 0 && !projectIds.includes(project)) {
|
||||
setProject(projectIds[0]);
|
||||
}
|
||||
}, [projectIds, project]);
|
||||
|
||||
const {
|
||||
data: approvals,
|
||||
error: loadError,
|
||||
loading,
|
||||
reload,
|
||||
} = useResource<Approval[]>(
|
||||
project ? `/projects/${encodeURIComponent(project)}/approvals` : null,
|
||||
);
|
||||
|
||||
async function submit(status: "approved" | "rejected") {
|
||||
if (!client || !project) return;
|
||||
if (!branch.trim()) {
|
||||
setError("Branch is required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const cmd = await client.api<Command>(
|
||||
`/projects/${encodeURIComponent(project)}/approvals`,
|
||||
{
|
||||
body: {
|
||||
branch: branch.trim(),
|
||||
status,
|
||||
...(sha.trim() ? { sha: sha.trim() } : {}),
|
||||
...(pr.trim() ? { pr: pr.trim() } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const tracked = await client.trackCommand(cmd.id);
|
||||
if (tracked?.status === "failed") {
|
||||
setError(tracked.error || "The verdict command failed.");
|
||||
} else {
|
||||
setBranch("");
|
||||
setSha("");
|
||||
setPr("");
|
||||
setNote("");
|
||||
}
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t record the verdict.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = approvals ?? [];
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Approvals"
|
||||
subtitle="A merge is denied unless a standing approval exists — made by a second party, pinned to the reviewed commit."
|
||||
>
|
||||
{projectIds.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
Register a repository first.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
label="Repository"
|
||||
options={projectIds}
|
||||
value={project || projectIds[0]}
|
||||
onChange={setProject}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||
<SectionLabel>Record a verdict</SectionLabel>
|
||||
<Field label="Branch">
|
||||
<TextField
|
||||
value={branch}
|
||||
onChangeText={setBranch}
|
||||
placeholder="feat/auth"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Sha"
|
||||
hint="Optional — defaults to the branch head resolved by the worker."
|
||||
>
|
||||
<TextField
|
||||
value={sha}
|
||||
onChangeText={setSha}
|
||||
placeholder="pins the approval"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="PR ref">
|
||||
<TextField
|
||||
value={pr}
|
||||
onChangeText={setPr}
|
||||
placeholder="optional"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Note">
|
||||
<TextField value={note} onChangeText={setNote} placeholder="optional" />
|
||||
</Field>
|
||||
<View style={styles.verdictRow}>
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
onPress={busy ? undefined : () => submit("approved")}
|
||||
>
|
||||
{busy ? "Enqueuing…" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
style={{ flex: 1 }}
|
||||
onPress={busy ? undefined : () => submit("rejected")}
|
||||
>
|
||||
{busy ? "Enqueuing…" : "Reject"}
|
||||
</Button>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<ErrorNotice message={error ?? loadError} />
|
||||
|
||||
<SectionLabel style={{ marginBottom: 8 }}>
|
||||
{`${rows.length} verdict${rows.length === 1 ? "" : "s"}`}
|
||||
</SectionLabel>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
{loading ? "Loading…" : "No approvals recorded."}
|
||||
</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{rows.map((ap, i) => (
|
||||
<View key={ap.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{ap.branch}
|
||||
</Mono>
|
||||
<Badge tone={statusTone(ap.status)}>
|
||||
{statusLabel(ap.status)}
|
||||
</Badge>
|
||||
</View>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
{[
|
||||
ap.approved_sha ? ap.approved_sha.slice(0, 8) : null,
|
||||
ap.pr_ref,
|
||||
verdictBy(ap),
|
||||
`${timeAgo(ap.created_at)} ago`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
{ap.note ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[text.caption, { color: colors.textMuted }]}
|
||||
>
|
||||
{ap.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
verdictRow: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
row: {
|
||||
gap: 4,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from "react";
|
||||
import { Linking, 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 { Mono } from "../../components/primitives";
|
||||
import { TextField } from "../../components/TextField";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import type { Command } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Claude login — sign the control container's `claude` binary into a Claude
|
||||
* subscription, phone edition of the web dashboard's login flow. Both steps are
|
||||
* control commands the worker executes (the API container has no claude binary):
|
||||
* login_start scrapes the authorization URL out of `claude /login`, the operator
|
||||
* authorizes in the browser and pastes the code back, login_submit feeds it to the
|
||||
* waiting tmux session. Admin-gated server-side.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
| { step: "idle" }
|
||||
| { step: "starting" }
|
||||
| { step: "awaiting"; url: string }
|
||||
| { step: "submitting"; url: string }
|
||||
| { step: "done" };
|
||||
|
||||
export function ClaudeLoginScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>({ step: "idle" });
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
if (!client) return;
|
||||
setError(null);
|
||||
setPhase({ step: "starting" });
|
||||
try {
|
||||
const cmd = await client.api<Command>("/login/start", { method: "POST" });
|
||||
// login_start boots claude, drives the menu, and scrapes the URL — allow ~90s
|
||||
// (worker claim latency + boot waits + URL timeout).
|
||||
const final = await client.trackCommand(cmd.id, { attempts: 180 });
|
||||
if (!final) {
|
||||
setError("Still starting — is the control worker running?");
|
||||
setPhase({ step: "idle" });
|
||||
return;
|
||||
}
|
||||
if (final.status !== "done") {
|
||||
setError(final.error || "Failed to start the login.");
|
||||
setPhase({ step: "idle" });
|
||||
return;
|
||||
}
|
||||
const url =
|
||||
final.result && typeof final.result.url === "string" ? final.result.url : "";
|
||||
if (!url) {
|
||||
setError("No login URL was returned by claude.");
|
||||
setPhase({ step: "idle" });
|
||||
return;
|
||||
}
|
||||
setPhase({ step: "awaiting", url });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't start the login.");
|
||||
setPhase({ step: "idle" });
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!client || phase.step !== "awaiting") return;
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
setError("Paste the authorization code first.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setPhase({ step: "submitting", url: phase.url });
|
||||
try {
|
||||
const cmd = await client.api<Command>("/login/submit", {
|
||||
method: "POST",
|
||||
body: { code: trimmed },
|
||||
});
|
||||
const final = await client.trackCommand(cmd.id, { attempts: 60 });
|
||||
if (!final) {
|
||||
setError("Submit still running — is the control worker running?");
|
||||
setPhase({ step: "awaiting", url: phase.url });
|
||||
return;
|
||||
}
|
||||
if (final.status === "done") {
|
||||
setCode("");
|
||||
setPhase({ step: "done" });
|
||||
return;
|
||||
}
|
||||
setError(final.error || "Login was not confirmed. Re-check the code or restart.");
|
||||
setPhase({ step: "awaiting", url: phase.url });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't submit the code.");
|
||||
setPhase({ step: "awaiting", url: phase.url });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Claude login"
|
||||
subtitle="Sign the control container's claude binary into a Claude subscription. New agents launch on whichever account is logged in."
|
||||
>
|
||||
<ErrorNotice message={error} />
|
||||
|
||||
{phase.step === "idle" || phase.step === "starting" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={phase.step === "starting" ? undefined : start}
|
||||
>
|
||||
{phase.step === "starting" ? "Starting claude /login…" : "Start login"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{phase.step === "awaiting" || phase.step === "submitting" ? (
|
||||
<View style={{ gap: 16 }}>
|
||||
<View
|
||||
style={[
|
||||
styles.urlBox,
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.textBody, marginBottom: 8 }]}>
|
||||
Authorize at this URL, then paste the code claude gives you:
|
||||
</Text>
|
||||
<Text selectable style={[styles.urlText, { color: colors.textMuted }]}>
|
||||
{phase.url}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{ width: "100%" }}
|
||||
onPress={() => void Linking.openURL(phase.url)}
|
||||
>
|
||||
Open in browser
|
||||
</Button>
|
||||
<Field label="Authorization code">
|
||||
<TextField
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
placeholder="Paste the code from claude.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={phase.step === "submitting" ? undefined : submit}
|
||||
>
|
||||
{phase.step === "submitting" ? "Submitting…" : "Submit code"}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{phase.step === "done" ? (
|
||||
<View style={{ gap: 16 }}>
|
||||
<View
|
||||
style={[
|
||||
styles.urlBox,
|
||||
{ backgroundColor: colors.positiveTint, borderColor: colors.positive },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.positive }]}>
|
||||
Claude Code is now logged in on the host — new agents will use this
|
||||
account.
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="secondary" style={{ width: "100%" }} onPress={start}>
|
||||
Log in again
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Mono style={{ fontSize: 12, color: colors.textMuted, marginTop: 20 }}>
|
||||
The URL opens claude.com; authorize with the subscription account you want
|
||||
agents to run on.
|
||||
</Mono>
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
urlBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
},
|
||||
urlText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { text } from "../../theme/tokens";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Select } from "../../components/Select";
|
||||
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 { ClaudeConnector, McpTransport } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Connectors — MCP servers agents may reach, the mobile counterpart of the web
|
||||
* dashboard's Claude → Connectors panel. Rows become each run's --mcp-config
|
||||
* file at the next launch (stdio commands run inside the control container).
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const TRANSPORT_OPTIONS = [
|
||||
{ value: "stdio", label: "stdio (run a command)" },
|
||||
{ value: "http", label: "http (remote server)" },
|
||||
{ value: "sse", label: "sse (remote server, legacy)" },
|
||||
];
|
||||
|
||||
/* "KEY=value per line" → map; blank and =-less lines are dropped, not errors. */
|
||||
function parseEnvLines(input: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of input.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* "Name: value per line" → map; same lenient skipping as env parsing. */
|
||||
function parseHeaderLines(input: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of input.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon <= 0) continue;
|
||||
out[trimmed.slice(0, colon).trim()] = trimmed.slice(colon + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ConnectorsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const {
|
||||
data,
|
||||
error: loadError,
|
||||
loading,
|
||||
reload,
|
||||
} = useResource<ClaudeConnector[]>("/claude/connectors");
|
||||
const connectors = data ?? [];
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<McpTransport>("stdio");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headers, setHeaders] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stdio = transport === "stdio";
|
||||
|
||||
async function create() {
|
||||
if (!client) return;
|
||||
if (!name.trim()) {
|
||||
setError("A connector name is required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// The server 422s a stdio connector without a command / an http(s)-less
|
||||
// remote url, so mismatched fields surface inline instead of client-side.
|
||||
await client.api<ClaudeConnector>("/claude/connectors", {
|
||||
body: {
|
||||
name: name.trim(),
|
||||
transport,
|
||||
command: stdio ? command.trim() || null : null,
|
||||
args: stdio ? args.trim().split(/\s+/).filter(Boolean) : [],
|
||||
env: stdio ? parseEnvLines(env) : {},
|
||||
url: stdio ? null : url.trim() || null,
|
||||
headers: stdio ? {} : parseHeaderLines(headers),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
setName("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setEnv("");
|
||||
setUrl("");
|
||||
setHeaders("");
|
||||
setShowForm(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t create the connector.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(c: ClaudeConnector, enabled: boolean) {
|
||||
if (!client) return;
|
||||
client
|
||||
.api(`/claude/connectors/${c.id}`, { method: "PATCH", body: { enabled } })
|
||||
.then(reload)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
|
||||
}
|
||||
|
||||
function confirmDelete(c: ClaudeConnector) {
|
||||
Alert.alert(
|
||||
"Delete connector?",
|
||||
`Agents lose ${c.name} at their next launch. This can’t be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
client
|
||||
?.api(`/claude/connectors/${c.id}`, { method: "DELETE" })
|
||||
.then(reload)
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Delete failed."),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Connectors"
|
||||
subtitle="MCP servers agents can reach — passed to each run as its --mcp-config file, so nothing lands in the repository tree."
|
||||
>
|
||||
<View style={styles.headRow}>
|
||||
<SectionLabel>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: `${connectors.length} connector${connectors.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">
|
||||
<TextField
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="github"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Select
|
||||
label="Transport"
|
||||
options={TRANSPORT_OPTIONS}
|
||||
value={transport}
|
||||
onChange={(v) => setTransport(v as McpTransport)}
|
||||
/>
|
||||
{stdio ? (
|
||||
<>
|
||||
<Field label="Command" hint="Runs inside the control container.">
|
||||
<TextField
|
||||
value={command}
|
||||
onChangeText={setCommand}
|
||||
placeholder="npx"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Arguments" hint="Space-separated.">
|
||||
<TextField
|
||||
value={args}
|
||||
onChangeText={setArgs}
|
||||
placeholder="-y @modelcontextprotocol/server-github"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Environment" hint="KEY=value per line.">
|
||||
<TextField
|
||||
value={env}
|
||||
onChangeText={setEnv}
|
||||
placeholder={"GITHUB_TOKEN=ghp_..."}
|
||||
multiline
|
||||
height={80}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<TextField
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Headers" hint="Name: value per line.">
|
||||
<TextField
|
||||
value={headers}
|
||||
onChangeText={setHeaders}
|
||||
placeholder={"Authorization: Bearer ..."}
|
||||
multiline
|
||||
height={80}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
|
||||
{busy ? "Creating…" : "Add connector"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ErrorNotice message={error ?? loadError} />
|
||||
|
||||
{connectors.length === 0 && !loading ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No connectors yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{connectors.map((c, i) => (
|
||||
<View key={c.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{c.name}
|
||||
</Mono>
|
||||
<Badge tone="neutral">{c.transport}</Badge>
|
||||
{c.owner_user_id != null ? (
|
||||
<Badge tone="neutral">private</Badge>
|
||||
) : null}
|
||||
</View>
|
||||
<Mono
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: 12, color: colors.textMuted }}
|
||||
>
|
||||
{c.transport === "stdio"
|
||||
? [c.command ?? "", ...(c.args ?? [])].join(" ").trim()
|
||||
: c.url ?? ""}
|
||||
</Mono>
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Switch value={c.enabled} onValueChange={(v) => toggle(c, v)} />
|
||||
<Button size="sm" variant="danger" onPress={() => confirmDelete(c)}>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { text } from "../../theme/tokens";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Select } from "../../components/Select";
|
||||
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 { ClaudeModel } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Models — registered model backends, the mobile counterpart of the web
|
||||
* dashboard's Claude → Models tab. API keys are write-only server-side:
|
||||
* rows only carry has_api_key, so the edit form never prefills the key and
|
||||
* offers "new key" / "clear key" verbs instead. Mutations need admin — a
|
||||
* non-admin token gets an inline 403, never a sign-out.
|
||||
*/
|
||||
|
||||
const HARNESS_OPTIONS = [
|
||||
{ value: "claude", label: "claude (Anthropic-compatible endpoint)" },
|
||||
{ value: "pi", label: "pi (bare OpenAI-compatible endpoint)" },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
smallFastModel: string;
|
||||
harness: "claude" | "pi";
|
||||
apiKey: string;
|
||||
clearKey: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
model: "",
|
||||
smallFastModel: "",
|
||||
harness: "claude",
|
||||
apiKey: "",
|
||||
clearKey: false,
|
||||
};
|
||||
|
||||
export function ModelsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const {
|
||||
data: models,
|
||||
error: loadError,
|
||||
loading,
|
||||
reload,
|
||||
} = useResource<ClaudeModel[]>("/claude/models");
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<ClaudeModel | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const set = (patch: Partial<FormState>) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
function closeForm() {
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
|
||||
function startEdit(m: ClaudeModel) {
|
||||
setEditing(m);
|
||||
setShowForm(true);
|
||||
setError(null);
|
||||
setForm({
|
||||
name: m.name,
|
||||
baseUrl: m.base_url,
|
||||
model: m.model,
|
||||
smallFastModel: m.small_fast_model ?? "",
|
||||
harness: m.harness ?? "claude",
|
||||
apiKey: "", // write-only; blank = keep the stored key
|
||||
clearKey: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!client) return;
|
||||
if (!form.name.trim() || !form.baseUrl.trim() || !form.model.trim()) {
|
||||
setError("Name, base URL, and model id are all required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const fields = {
|
||||
name: form.name.trim(),
|
||||
base_url: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
small_fast_model: form.smallFastModel.trim() || null,
|
||||
harness: form.harness,
|
||||
};
|
||||
if (editing) {
|
||||
await client.api(`/claude/models/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
...fields,
|
||||
...(form.apiKey.trim()
|
||||
? { api_key: form.apiKey.trim() }
|
||||
: form.clearKey
|
||||
? { clear_api_key: true }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await client.api("/claude/models", {
|
||||
body: {
|
||||
...fields,
|
||||
...(form.apiKey.trim() ? { api_key: form.apiKey.trim() } : {}),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t save the model.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEnabled(m: ClaudeModel, enabled: boolean) {
|
||||
if (!client) return;
|
||||
client
|
||||
.api(`/claude/models/${m.id}`, { method: "PATCH", body: { enabled } })
|
||||
.then(reload)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
|
||||
}
|
||||
|
||||
function confirmDelete(m: ClaudeModel) {
|
||||
Alert.alert(
|
||||
"Delete model?",
|
||||
`Remove ${m.name} from the spawn and schedule forms. This can’t be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
client
|
||||
?.api(`/claude/models/${m.id}`, { method: "DELETE" })
|
||||
.then(reload)
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Delete failed."),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const list = models ?? [];
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Models"
|
||||
subtitle="Anthropic-API-compatible model backends the spawn and schedule forms offer next to the Claude subscription."
|
||||
>
|
||||
<View style={styles.headRow}>
|
||||
<SectionLabel>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: `${list.length} model${list.length === 1 ? "" : "s"}`}
|
||||
</SectionLabel>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showForm ? "secondary" : "primary"}
|
||||
onPress={() => {
|
||||
if (showForm) closeForm();
|
||||
else {
|
||||
setError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showForm ? "Cancel" : "New"}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{showForm ? (
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||
{editing ? (
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>
|
||||
Edit model · {editing.name}
|
||||
</Text>
|
||||
) : null}
|
||||
<Field label="Name" hint="What the spawn dropdown shows.">
|
||||
<TextField
|
||||
value={form.name}
|
||||
onChangeText={(v) => set({ name: v })}
|
||||
placeholder="qwen3-coder"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Base URL">
|
||||
<TextField
|
||||
value={form.baseUrl}
|
||||
onChangeText={(v) => set({ baseUrl: v })}
|
||||
placeholder="http://llm.lan:4000"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Model" hint="Model id as the endpoint serves it.">
|
||||
<TextField
|
||||
value={form.model}
|
||||
onChangeText={(v) => set({ model: v })}
|
||||
placeholder="qwen3-coder-30b"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Small/fast model"
|
||||
hint="Optional — defaults to the main model."
|
||||
>
|
||||
<TextField
|
||||
value={form.smallFastModel}
|
||||
onChangeText={(v) => set({ smallFastModel: v })}
|
||||
placeholder="qwen3-1.7b"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Select
|
||||
label="Harness"
|
||||
options={HARNESS_OPTIONS}
|
||||
value={form.harness}
|
||||
onChange={(v) => set({ harness: v as "claude" | "pi" })}
|
||||
/>
|
||||
<Field
|
||||
label={editing ? "New API key" : "API key"}
|
||||
hint={
|
||||
editing
|
||||
? "Optional — blank keeps the stored key. Stored encrypted, never shown again."
|
||||
: "Optional. Stored encrypted, never shown again."
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
value={form.apiKey}
|
||||
onChangeText={(v) => set({ apiKey: v })}
|
||||
placeholder="sk-…"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
{editing?.has_api_key ? (
|
||||
<View style={styles.switchRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>
|
||||
Clear stored key
|
||||
</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
Drop the stored key on save (ignored if a new key is set).
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={form.clearKey}
|
||||
onValueChange={(v) => set({ clearKey: v })}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : save}>
|
||||
{busy ? "Saving…" : editing ? "Save changes" : "Create model"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ErrorNotice message={error ?? loadError} />
|
||||
|
||||
{!loading && list.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No model backends yet — agents run on the Claude subscription.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{list.length > 0 ? (
|
||||
<Card>
|
||||
{list.map((m, i) => (
|
||||
<View key={m.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{m.name}
|
||||
</Mono>
|
||||
{m.harness === "pi" ? (
|
||||
<Badge tone="warning">pi harness</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">claude</Badge>
|
||||
)}
|
||||
{m.owner_user_id != null ? (
|
||||
<Badge tone="neutral">private</Badge>
|
||||
) : null}
|
||||
</View>
|
||||
<Mono style={{ fontSize: 12, color: colors.textHeading }}>
|
||||
{m.model}
|
||||
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""}
|
||||
</Mono>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 12, color: colors.textMuted }}
|
||||
>
|
||||
{m.base_url}
|
||||
</Mono>
|
||||
{m.has_api_key ? (
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
key stored
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Switch
|
||||
value={m.enabled}
|
||||
onValueChange={(v) => toggleEnabled(m, v)}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onPress={() => startEdit(m)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onPress={() => confirmDelete(m)}>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 : "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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { 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 { ClaudePlugin } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Plugins — Claude Code marketplace plugins, the mobile counterpart of the web
|
||||
* dashboard's Claude → Plugins panel. Each row pins a plugin to the marketplace
|
||||
* serving it; generated settings declare the marketplace and enable the plugin,
|
||||
* so headless runs install both on boot. 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 PluginsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const {
|
||||
data,
|
||||
error: loadError,
|
||||
loading,
|
||||
reload,
|
||||
} = useResource<ClaudePlugin[]>("/claude/plugins");
|
||||
const plugins = data ?? [];
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [marketplace, setMarketplace] = useState("");
|
||||
const [marketplaceRepo, setMarketplaceRepo] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function create() {
|
||||
if (!client) return;
|
||||
if (!name.trim() || !marketplace.trim() || !marketplaceRepo.trim()) {
|
||||
setError("Name, marketplace, and marketplace repo are all required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.api<ClaudePlugin>("/claude/plugins", {
|
||||
body: {
|
||||
name: name.trim(),
|
||||
marketplace: marketplace.trim(),
|
||||
marketplace_repo: marketplaceRepo.trim(),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
setName("");
|
||||
setMarketplace("");
|
||||
setMarketplaceRepo("");
|
||||
setShowForm(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t create the plugin.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(p: ClaudePlugin, enabled: boolean) {
|
||||
if (!client) return;
|
||||
client
|
||||
.api(`/claude/plugins/${p.id}`, { method: "PATCH", body: { enabled } })
|
||||
.then(reload)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Update failed."));
|
||||
}
|
||||
|
||||
function confirmDelete(p: ClaudePlugin) {
|
||||
Alert.alert(
|
||||
"Delete plugin?",
|
||||
`Runs stop installing ${p.name}@${p.marketplace} at their next launch. This can’t be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
client
|
||||
?.api(`/claude/plugins/${p.id}`, { method: "DELETE" })
|
||||
.then(reload)
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Delete failed."),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Plugins"
|
||||
subtitle="Claude Code plugins, pinned to the marketplace serving them. Generated settings declare the marketplace and enable the plugin, so headless runs install both on boot."
|
||||
>
|
||||
<View style={styles.headRow}>
|
||||
<SectionLabel>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: `${plugins.length} plugin${plugins.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="Plugin name">
|
||||
<TextField
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="code-reviewer"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Marketplace key">
|
||||
<TextField
|
||||
value={marketplace}
|
||||
onChangeText={setMarketplace}
|
||||
placeholder="acme-tools"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Marketplace repo" hint="owner/repo or a git URL.">
|
||||
<TextField
|
||||
value={marketplaceRepo}
|
||||
onChangeText={setMarketplaceRepo}
|
||||
placeholder="acme/claude-marketplace"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
|
||||
{busy ? "Creating…" : "Add plugin"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ErrorNotice message={error ?? loadError} />
|
||||
|
||||
{plugins.length === 0 && !loading ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No plugins yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{plugins.map((p, i) => (
|
||||
<View key={p.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{`${p.name}@${p.marketplace}`}
|
||||
</Mono>
|
||||
{p.owner_user_id != null ? (
|
||||
<Badge tone="neutral">private</Badge>
|
||||
) : null}
|
||||
</View>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 12, color: colors.textMuted }}
|
||||
>
|
||||
{p.marketplace_repo}
|
||||
</Mono>
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Switch value={p.enabled} onValueChange={(v) => toggle(p, v)} />
|
||||
<Button size="sm" variant="danger" onPress={() => confirmDelete(p)}>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { 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 { SegmentedControl } from "../../components/SegmentedControl";
|
||||
import { Select } from "../../components/Select";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { TextField } from "../../components/TextField";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
|
||||
import { timeAgo } from "../../api/format";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import { useResource } from "../../state/useResource";
|
||||
import type { Command, Host, Project } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Repositories — register + sync the repos Handler manages, the mobile
|
||||
* counterpart of the web dashboard's Repositories section. The preferred path
|
||||
* is git-server mode: pick a configured forge host and type owner/name; the
|
||||
* server derives the remote, clones under PROJECTS_ROOT, and keeps it fresh.
|
||||
* Manual mode registers an existing checkout by absolute path.
|
||||
*/
|
||||
|
||||
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
||||
|
||||
type Mode = "server" | "manual";
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
export function RepositoriesScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client, refresh } = useAppState();
|
||||
const projectsRes = useResource<Project[]>("/projects");
|
||||
const hostsRes = useResource<Host[]>("/hosts");
|
||||
const projects = projectsRes.data ?? [];
|
||||
const hosts = hostsRes.data ?? [];
|
||||
const hostnames = hosts.map((h) => h.hostname);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>("server");
|
||||
const [gitServer, setGitServer] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [initMise, setInitMise] = useState(false);
|
||||
const [rootDir, setRootDir] = useState("");
|
||||
const [gitRemote, setGitRemote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Project id of the last enqueued sync — shows a transient "sync queued" note.
|
||||
const [syncedId, setSyncedId] = useState<string | null>(null);
|
||||
|
||||
// Default to the first git server once hosts load (or if the pick vanished).
|
||||
useEffect(() => {
|
||||
if (hostnames.length > 0 && !hostnames.includes(gitServer)) {
|
||||
setGitServer(hostnames[0]);
|
||||
}
|
||||
}, [hostnames, gitServer]);
|
||||
|
||||
function resetForm() {
|
||||
setRepo("");
|
||||
setProjectId("");
|
||||
setInitMise(false);
|
||||
setRootDir("");
|
||||
setGitRemote("");
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (!client) return;
|
||||
if (mode === "server") {
|
||||
if (!gitServer) {
|
||||
setError("Pick a git server first (add one under Git servers).");
|
||||
return;
|
||||
}
|
||||
if (!REPO_RE.test(repo.trim())) {
|
||||
setError("Repository must be owner/name.");
|
||||
return;
|
||||
}
|
||||
} else if (!projectId.trim() || !rootDir.trim()) {
|
||||
setError("Project id and root dir are both required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const body =
|
||||
mode === "server"
|
||||
? {
|
||||
git_server: gitServer,
|
||||
repo: repo.trim(),
|
||||
id: projectId.trim() || undefined,
|
||||
init_mise: initMise,
|
||||
}
|
||||
: {
|
||||
id: projectId.trim(),
|
||||
root_dir: rootDir.trim(),
|
||||
git_remote: gitRemote.trim() || undefined,
|
||||
};
|
||||
await client.api<Project>("/projects", { body });
|
||||
resetForm();
|
||||
await refresh();
|
||||
projectsRes.reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t register the repository.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sync(p: Project) {
|
||||
if (!client) return;
|
||||
setError(null);
|
||||
try {
|
||||
await client.api<Command>(`/projects/${enc(p.id)}/sync`, { method: "POST" });
|
||||
setSyncedId(p.id);
|
||||
setTimeout(() => setSyncedId((cur) => (cur === p.id ? null : cur)), 4000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Sync failed.");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(p: Project) {
|
||||
Alert.alert(
|
||||
"Delete repository?",
|
||||
`Unregister ${p.id} from Handler. The checkout on disk is not touched.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
client
|
||||
?.api(`/projects/${enc(p.id)}`, { method: "DELETE" })
|
||||
.then(() => {
|
||||
projectsRes.reload();
|
||||
return refresh();
|
||||
})
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Delete failed."),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Repositories"
|
||||
subtitle="Repos Handler manages. Each carries its own agents, history, and credentials."
|
||||
>
|
||||
<View style={styles.headRow}>
|
||||
<SectionLabel>
|
||||
{`${projects.length} repositor${projects.length === 1 ? "y" : "ies"}`}
|
||||
</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 }}>
|
||||
<SegmentedControl<Mode>
|
||||
segments={[
|
||||
{ value: "server", label: "Git server" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{mode === "server" ? (
|
||||
<>
|
||||
{hostnames.length > 0 ? (
|
||||
<Select
|
||||
label="Git server"
|
||||
options={hosts.map((h) => ({
|
||||
value: h.hostname,
|
||||
label: `${h.hostname} (${h.forge_type})`,
|
||||
}))}
|
||||
value={gitServer || hostnames[0]}
|
||||
onChange={setGitServer}
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Git server"
|
||||
hint="No git servers configured — add one under Git servers first."
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
None registered yet.
|
||||
</Text>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="Repository"
|
||||
hint="The server derives the remote, clones it, and keeps it fresh before every run."
|
||||
>
|
||||
<TextField
|
||||
value={repo}
|
||||
onChangeText={setRepo}
|
||||
placeholder="owner/name"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Project id" hint="Optional — defaults to a slug of the repo name.">
|
||||
<TextField
|
||||
value={projectId}
|
||||
onChangeText={setProjectId}
|
||||
placeholder="coolproj"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<View style={styles.switchRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>
|
||||
Initialize mise
|
||||
</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
Queues a bootstrap agent that authors .mise.toml with a test
|
||||
task and pushes it.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch value={initMise} onValueChange={setInitMise} />
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Project id">
|
||||
<TextField
|
||||
value={projectId}
|
||||
onChangeText={setProjectId}
|
||||
placeholder="leeworks-api"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Root dir" hint="Absolute path on the control container.">
|
||||
<TextField
|
||||
value={rootDir}
|
||||
onChangeText={setRootDir}
|
||||
placeholder="/var/lib/handler/projects/leeworks"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Git remote" hint="Optional — needed for Sync and pushes.">
|
||||
<TextField
|
||||
value={gitRemote}
|
||||
onChangeText={setGitRemote}
|
||||
placeholder="git@github.com:user/repo.git"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : register}>
|
||||
{busy ? "Registering…" : mode === "server" ? "Add & pull" : "Register"}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ErrorNotice message={error ?? projectsRes.error} />
|
||||
|
||||
{projectsRes.loading && projects.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading…</Text>
|
||||
) : projects.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No repositories registered.
|
||||
</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{projects.map((p, i) => (
|
||||
<View key={p.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{p.id}
|
||||
</Mono>
|
||||
{p.owner_user_id != null ? (
|
||||
<Badge tone="neutral">private</Badge>
|
||||
) : null}
|
||||
</View>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 12, color: colors.textMuted }}
|
||||
>
|
||||
{p.git_remote || p.root_dir}
|
||||
</Mono>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
added {timeAgo(p.created_at)} ago
|
||||
</Text>
|
||||
{syncedId === p.id ? (
|
||||
<Text style={[text.caption, { color: colors.positive }]}>
|
||||
sync queued
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
{p.git_remote ? (
|
||||
<Button size="sm" variant="secondary" onPress={() => sync(p)}>
|
||||
Sync
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="danger" onPress={() => confirmDelete(p)}>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</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: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
rowActions: {
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
gap: 10,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, 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 { TextField } from "../../components/TextField";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import { useResource } from "../../state/useResource";
|
||||
import { timeAgo } from "../../api/format";
|
||||
import type { SharedContext } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Shared context — the cross-project key/value store every agent can read
|
||||
* (README 3.4). Reads use the normal token; writing a key needs the
|
||||
* higher-trust shared-context write token, so a normal or even admin token
|
||||
* may 403 — the server's message is surfaced verbatim.
|
||||
*/
|
||||
|
||||
export function SharedContextScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
|
||||
const { data, error: loadError, loading, reload } =
|
||||
useResource<SharedContext[]>("/shared/context");
|
||||
|
||||
const [key, setKey] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
async function set() {
|
||||
if (!client) return;
|
||||
if (!key.trim() || !value.trim()) {
|
||||
setError("Key and value are both required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.api<SharedContext>(
|
||||
`/shared/context/${encodeURIComponent(key.trim())}`,
|
||||
{ method: "PUT", body: { value: value.trim() } },
|
||||
);
|
||||
setKey("");
|
||||
setValue("");
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t set the key.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Shared context"
|
||||
subtitle="Key/value facts every agent across every project can read."
|
||||
>
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||
<SectionLabel>Set a key</SectionLabel>
|
||||
<Field label="Key">
|
||||
<TextField
|
||||
value={key}
|
||||
onChangeText={setKey}
|
||||
placeholder="staging_url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Value"
|
||||
hint="Requires the shared-context write token (or admin/global if unset)."
|
||||
>
|
||||
<TextField
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder="value"
|
||||
multiline
|
||||
height={80}
|
||||
/>
|
||||
</Field>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : set}>
|
||||
{busy ? "Setting…" : "Set"}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<ErrorNotice message={error ?? loadError} />
|
||||
|
||||
<SectionLabel style={{ marginBottom: 8 }}>
|
||||
{`${rows.length} key${rows.length === 1 ? "" : "s"}`}
|
||||
</SectionLabel>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
{loading ? "Loading…" : "No shared context set."}
|
||||
</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{rows.map((c, i) => (
|
||||
<View key={c.key}>
|
||||
{i > 0 && <Divider />}
|
||||
<Pressable
|
||||
style={styles.row}
|
||||
onPress={() => setExpanded((k) => (k === c.key ? null : c.key))}
|
||||
>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{c.key}
|
||||
</Mono>
|
||||
{expanded === c.key ? (
|
||||
<View
|
||||
style={[
|
||||
styles.valueBlock,
|
||||
{
|
||||
backgroundColor: colors.surfaceSunken,
|
||||
borderColor: colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Mono style={{ fontSize: 12, color: colors.textBody }}>
|
||||
{c.value}
|
||||
</Mono>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[text.bodySm, { color: colors.textMuted }]}
|
||||
>
|
||||
{c.value}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
{c.set_by_agent_id != null
|
||||
? `agent #${c.set_by_agent_id}`
|
||||
: "operator"}
|
||||
{` · updated ${timeAgo(c.updated_at)} ago`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
gap: 4,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
valueBlock: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 10,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
@@ -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 : "Couldn’t 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 can’t 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 worker’s ~/.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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { fonts, 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 { timeAgo } from "../../api/format";
|
||||
import { AuthError, type ResetLink, type User, type UserCreated } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Users — admin-only account management (Settings → Manage → Users), the mobile
|
||||
* counterpart of the web dashboard's Users section. Invite by email (the invitee
|
||||
* sets their own password through a one-shot link, emailed when SMTP is configured
|
||||
* and always shown here), toggle admin/disabled, mint reset links, delete. The
|
||||
* server guards the last active admin and self-deletes; those refusals surface
|
||||
* inline. A non-admin session gets a 403 on the list itself.
|
||||
*/
|
||||
|
||||
/* The invite/reset link the last mutation minted, kept visible until the next one. */
|
||||
interface LastLink {
|
||||
email: string;
|
||||
url: string;
|
||||
emailed: boolean;
|
||||
}
|
||||
|
||||
export function UsersScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const { data: users, error: listError, loading, reload } = useResource<User[]>(
|
||||
"/auth/users",
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [inviteAdmin, setInviteAdmin] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastLink, setLastLink] = useState<LastLink | null>(null);
|
||||
|
||||
async function invite() {
|
||||
if (!client) return;
|
||||
if (!email.trim()) {
|
||||
setError("An email address is required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const created = await client.api<UserCreated>("/auth/users", {
|
||||
body: { email: email.trim(), is_admin: inviteAdmin },
|
||||
});
|
||||
setLastLink({
|
||||
email: created.user.email,
|
||||
url: created.invite_url,
|
||||
emailed: created.emailed,
|
||||
});
|
||||
setEmail("");
|
||||
setInviteAdmin(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return; // handled by onUnauthorized
|
||||
setError(e instanceof Error ? e.message : "Invite failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchUser(id: number, fields: Partial<Pick<User, "is_admin" | "disabled">>) {
|
||||
if (!client) return;
|
||||
setError(null);
|
||||
try {
|
||||
await client.api<User>(`/auth/users/${id}`, { method: "PATCH", body: fields });
|
||||
reload();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setError(e instanceof Error ? e.message : "Update failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function mintResetLink(u: User) {
|
||||
if (!client) return;
|
||||
setError(null);
|
||||
try {
|
||||
const link = await client.api<ResetLink>(`/auth/users/${u.id}/reset-link`, {
|
||||
method: "POST",
|
||||
});
|
||||
setLastLink({ email: u.email, url: link.reset_url, emailed: link.emailed });
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setError(e instanceof Error ? e.message : "Couldn’t mint a reset link.");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(u: User) {
|
||||
Alert.alert(
|
||||
"Remove user?",
|
||||
`Remove ${u.email}. Their projects, skills, and tools become shared.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Remove",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (!client) return;
|
||||
client
|
||||
.api(`/auth/users/${u.id}`, { method: "DELETE" })
|
||||
.then(() => reload())
|
||||
.catch((e) => {
|
||||
if (e instanceof AuthError) return;
|
||||
// Server refuses last-admin and self deletes; surface its reason.
|
||||
setError(e instanceof Error ? e.message : "Delete failed.");
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const count = users?.length ?? 0;
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Users"
|
||||
subtitle="Accounts for this Handler. Each user's projects, skills, and tools are theirs alone; shared resources are admin-managed."
|
||||
>
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||
<Field label="Email" hint="The invitee sets their own password through a one-shot link.">
|
||||
<TextField
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="new-user@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Field>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>Admin</Text>
|
||||
<Switch value={inviteAdmin} onValueChange={setInviteAdmin} />
|
||||
</View>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : invite}>
|
||||
{busy ? "Inviting…" : "Invite user"}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<ErrorNotice message={error} />
|
||||
|
||||
{lastLink ? (
|
||||
<Card style={{ padding: 16, marginBottom: 16, gap: 8 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>
|
||||
One-shot set-password link for {lastLink.email}
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
style={{ fontFamily: fonts.monoRegular, fontSize: 12, color: colors.textBody }}
|
||||
>
|
||||
{lastLink.url}
|
||||
</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
{lastLink.emailed
|
||||
? "Also emailed to them. It expires; share over a channel you trust."
|
||||
: "SMTP is off — hand this link over yourself. It expires."}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SectionLabel style={{ marginBottom: 8 }}>
|
||||
{`${count} user${count === 1 ? "" : "s"}`}
|
||||
</SectionLabel>
|
||||
|
||||
{listError ? (
|
||||
<ErrorNotice message={listError} />
|
||||
) : loading && !users ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading…</Text>
|
||||
) : count === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>No users yet.</Text>
|
||||
) : (
|
||||
<Card>
|
||||
{(users ?? []).map((u, i) => (
|
||||
<View key={u.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||
{u.email}
|
||||
</Mono>
|
||||
{u.is_admin ? <Badge tone="positive">admin</Badge> : null}
|
||||
{u.disabled ? <Badge tone="danger">disabled</Badge> : null}
|
||||
{!u.has_password ? <Badge tone="warning">invite pending</Badge> : null}
|
||||
</View>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
created {timeAgo(u.created_at)} ago
|
||||
</Text>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>admin</Text>
|
||||
<Switch
|
||||
value={u.is_admin}
|
||||
onValueChange={(v) => void patchUser(u.id, { is_admin: v })}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>disabled</Text>
|
||||
<Switch
|
||||
value={u.disabled}
|
||||
onValueChange={(v) => void patchUser(u.id, { disabled: v })}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Button size="sm" variant="secondary" onPress={() => void mintResetLink(u)}>
|
||||
Reset link
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onPress={() => confirmDelete(u)}>
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
rowActions: {
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
gap: 10,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
@@ -127,6 +127,7 @@ interface AppStateValue {
|
||||
/* The agent-memory note graph; null until the memory screen first loads it. */
|
||||
memory: MemoryGraph | null;
|
||||
memoryError: string | null;
|
||||
reloadMemory: () => void;
|
||||
waiting: WaitingItem[];
|
||||
recent: RecentItem[];
|
||||
counts: { running: number; waiting: number; done: number };
|
||||
@@ -434,6 +435,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
// open) rather than on the fleet poll — it changes slowly and can be large.
|
||||
const [memory, setMemory] = useState<MemoryGraph | null>(null);
|
||||
const [memoryError, setMemoryError] = useState<string | null>(null);
|
||||
const [memoryNonce, setMemoryNonce] = useState(0);
|
||||
const reloadMemory = useCallback(() => setMemoryNonce((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
if (!client || screen !== "memory") return;
|
||||
let stale = false;
|
||||
@@ -450,7 +453,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [client, screen]);
|
||||
}, [client, screen, memoryNonce]);
|
||||
|
||||
// ---- Selected agent's run events ----------------------------------------
|
||||
// Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only
|
||||
@@ -645,6 +648,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
schedules,
|
||||
memory,
|
||||
memoryError,
|
||||
reloadMemory,
|
||||
waiting,
|
||||
recent,
|
||||
counts,
|
||||
@@ -677,6 +681,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
schedules,
|
||||
memory,
|
||||
memoryError,
|
||||
reloadMemory,
|
||||
waiting,
|
||||
recent,
|
||||
counts,
|
||||
|
||||
Reference in New Issue
Block a user