mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-06 20:46:24 +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,
|
type Screen as ScreenName,
|
||||||
} from "./src/state/AppState";
|
} from "./src/state/AppState";
|
||||||
import { ManageScreen } from "./src/screens/manage/ManageScreen";
|
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 { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
||||||
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
||||||
import { FleetScreen } from "./src/screens/FleetScreen";
|
import { FleetScreen } from "./src/screens/FleetScreen";
|
||||||
@@ -47,9 +59,7 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
|
|||||||
function Router() {
|
function Router() {
|
||||||
const { screen } = useAppState();
|
const { screen } = useAppState();
|
||||||
|
|
||||||
// Partial while the management subscreens land one by one; anything unmapped
|
const screens: Record<ScreenName, () => React.JSX.Element> = {
|
||||||
// falls back to the fleet so a half-wired build never renders undefined.
|
|
||||||
const screens: Partial<Record<ScreenName, () => React.JSX.Element>> = {
|
|
||||||
connect: ConnectScreen,
|
connect: ConnectScreen,
|
||||||
fleet: FleetScreen,
|
fleet: FleetScreen,
|
||||||
detail: AgentDetailScreen,
|
detail: AgentDetailScreen,
|
||||||
@@ -60,8 +70,20 @@ function Router() {
|
|||||||
log: LogScreen,
|
log: LogScreen,
|
||||||
settings: SettingsScreen,
|
settings: SettingsScreen,
|
||||||
manage: ManageScreen,
|
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 />;
|
return <Screen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
@@ -10,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { text } from "../theme/tokens";
|
import { text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
|
import { SegmentedControl } from "../components/SegmentedControl";
|
||||||
import { TextField } from "../components/TextField";
|
import { TextField } from "../components/TextField";
|
||||||
import { SectionLabel } from "../components/primitives";
|
import { SectionLabel } from "../components/primitives";
|
||||||
import {
|
import {
|
||||||
@@ -17,13 +20,27 @@ import {
|
|||||||
useServerConfig,
|
useServerConfig,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
} from "../state/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
|
* First-open configuration screen, shown whenever no server config is stored (and after
|
||||||
* Sign out or a persistent 401). Verifies connectivity (GET /health) and the token
|
* a Sign out or a persistent 401). Two ways in, mirroring the web dashboard's gate:
|
||||||
* (GET /projects) before persisting, distinguishing an unreachable endpoint from a bad
|
*
|
||||||
* token in the inline error. Matches the SpawnScreen layout.
|
* - **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() {
|
export function ConnectScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
@@ -31,55 +48,161 @@ export function ConnectScreen() {
|
|||||||
const { lastEndpoint, save } = useServerConfig();
|
const { lastEndpoint, save } = useServerConfig();
|
||||||
|
|
||||||
const [endpoint, setEndpoint] = useState(lastEndpoint || DEFAULT_ENDPOINT);
|
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 [token, setToken] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
|
||||||
async function connect() {
|
function isApiError(e: unknown): e is ApiError {
|
||||||
const ep = endpoint.trim();
|
return e instanceof Error && typeof (e as ApiError).status === "number";
|
||||||
const tok = token.trim();
|
}
|
||||||
if (!ep) {
|
|
||||||
setError("Enter an endpoint.");
|
async function connectWithEmail(ep: string) {
|
||||||
|
if (!email.trim() || !password) {
|
||||||
|
setError("Enter your email and password.");
|
||||||
return;
|
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) {
|
if (!tok) {
|
||||||
setError("Enter an API token.");
|
setError("Enter an API token.");
|
||||||
return;
|
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);
|
setError(null);
|
||||||
|
setNote(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const client = createClient(ep, tok, () => {});
|
if (method === "email") {
|
||||||
|
await connectWithEmail(ep);
|
||||||
// 1. Connectivity — /health needs no auth, so a failure here is the endpoint.
|
} else {
|
||||||
try {
|
await connectWithToken(ep);
|
||||||
await client.api<{ status: string }>("/health");
|
|
||||||
} catch {
|
|
||||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// 2. Auth — a 401 on /projects is the token, not the endpoint.
|
setError(e instanceof Error ? e.message : "Couldn't connect.");
|
||||||
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.
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
<View style={{ height: insets.top }} />
|
<View style={{ height: insets.top }} />
|
||||||
@@ -87,7 +210,12 @@ export function ConnectScreen() {
|
|||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
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}>
|
<View style={styles.heading}>
|
||||||
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
|
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
|
||||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||||
@@ -110,19 +238,97 @@ export function ConnectScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
{!setup ? (
|
||||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
<SegmentedControl
|
||||||
API token
|
segments={[
|
||||||
</Text>
|
{ value: "email", label: "Email" },
|
||||||
<TextField
|
{ value: "token", label: "API token" },
|
||||||
value={token}
|
]}
|
||||||
onChangeText={setToken}
|
value={method}
|
||||||
placeholder="Bearer token"
|
onChange={(v) => {
|
||||||
secureTextEntry
|
setMethod(v as "email" | "token");
|
||||||
autoCapitalize="none"
|
setError(null);
|
||||||
autoCorrect={false}
|
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 ? (
|
{error ? (
|
||||||
<View
|
<View
|
||||||
@@ -139,16 +345,16 @@ export function ConnectScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ marginTop: "auto" }}>
|
<View style={{ marginTop: 28 }}>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
onPress={busy ? undefined : connect}
|
onPress={busy ? undefined : connect}
|
||||||
>
|
>
|
||||||
{busy ? "Connecting…" : "Connect"}
|
{buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -157,11 +363,16 @@ export function ConnectScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
flex: { flex: 1 },
|
flex: { flex: 1 },
|
||||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
content: { flexGrow: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||||
heading: { marginTop: 12, marginBottom: 24 },
|
heading: { marginTop: 12, marginBottom: 24 },
|
||||||
errorBox: {
|
errorBox: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
},
|
},
|
||||||
|
noteBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { text } from "../theme/tokens";
|
import { radius, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
import { Chip } from "../components/Chip";
|
import { Chip } from "../components/Chip";
|
||||||
|
import { Select } from "../components/Select";
|
||||||
import { TabBar } from "../components/TabBar";
|
import { TabBar } from "../components/TabBar";
|
||||||
|
import { TextField } from "../components/TextField";
|
||||||
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||||
import { useAppState, type BadgeTone } from "../state/AppState";
|
import { useAppState, type BadgeTone } from "../state/AppState";
|
||||||
import { timeAgo } from "../api/format";
|
import { timeAgo } from "../api/format";
|
||||||
import type { MemoryNote } from "../api/client";
|
import type { MemoryNote } from "../api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Memory — a read view over the agent-memory note graph (the web dashboard's
|
* Memory — the agent-memory note graph (the web dashboard's Memory page).
|
||||||
* Memory page). Notes are the distilled facts/decisions/gotchas/runbooks agents
|
* Notes are the distilled facts/decisions/gotchas/runbooks agents leave for
|
||||||
* leave for each other; tapping a note expands its body, tags, and links.
|
* each other; tapping a note expands its body, tags, and links. Operators can
|
||||||
* Authoring stays on the web dashboard (admin token) and in the agents' own
|
* author and delete notes here too (admin-gated server-side — notes feed every
|
||||||
* MCP server — the phone is for looking things up.
|
* future agent's context); link editing stays on the web dashboard's graph UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const KIND_FILTERS = ["all", "fact", "decision", "gotcha", "runbook"];
|
const KIND_FILTERS = ["all", "fact", "decision", "gotcha", "runbook"];
|
||||||
@@ -31,11 +41,81 @@ const KIND_TONES: Record<string, BadgeTone> = {
|
|||||||
export function MemoryScreen() {
|
export function MemoryScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { memory, memoryError } = useAppState();
|
const { client, memory, memoryError, reloadMemory, projects } = useAppState();
|
||||||
|
|
||||||
const [kind, setKind] = useState("all");
|
const [kind, setKind] = useState("all");
|
||||||
const [openId, setOpenId] = useState<number | null>(null);
|
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 notes = useMemo(() => {
|
||||||
const all = memory?.notes ?? [];
|
const all = memory?.notes ?? [];
|
||||||
const inKind = kind === "all" ? all : all.filter((n) => n.kind === kind);
|
const inKind = kind === "all" ? all : all.filter((n) => n.kind === kind);
|
||||||
@@ -56,9 +136,9 @@ export function MemoryScreen() {
|
|||||||
const out: { key: string; label: string }[] = [];
|
const out: { key: string; label: string }[] = [];
|
||||||
for (const l of links) {
|
for (const l of links) {
|
||||||
if (l.src_note_id === note.id) {
|
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) {
|
} 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;
|
return out;
|
||||||
@@ -69,10 +149,18 @@ export function MemoryScreen() {
|
|||||||
<View style={{ height: insets.top }} />
|
<View style={{ height: insets.top }} />
|
||||||
|
|
||||||
<View style={styles.header}>
|
<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 }]}>
|
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4 }]}>
|
||||||
Notes agents distill for every future run. Edit them from the web
|
Notes agents distill for every future run.
|
||||||
dashboard.
|
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -95,6 +183,77 @@ export function MemoryScreen() {
|
|||||||
contentContainerStyle={styles.body}
|
contentContainerStyle={styles.body}
|
||||||
showsVerticalScrollIndicator={false}
|
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 ? (
|
{memoryError ? (
|
||||||
<Text style={[text.bodySm, { color: colors.danger }]}>{memoryError}</Text>
|
<Text style={[text.bodySm, { color: colors.danger }]}>{memoryError}</Text>
|
||||||
) : memory === null ? (
|
) : memory === null ? (
|
||||||
@@ -155,6 +314,14 @@ export function MemoryScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={() => confirmDeleteNote(n)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</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}`;
|
return byId.get(id)?.title ?? `note #${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 14 },
|
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 14 },
|
||||||
|
headRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 },
|
filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 },
|
||||||
body: { paddingHorizontal: 20, paddingBottom: 24 },
|
body: { paddingHorizontal: 20, paddingBottom: 24 },
|
||||||
|
notice: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
row: { paddingVertical: 12, paddingHorizontal: 16 },
|
row: { paddingVertical: 12, paddingHorizontal: 16 },
|
||||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
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. */
|
/* The agent-memory note graph; null until the memory screen first loads it. */
|
||||||
memory: MemoryGraph | null;
|
memory: MemoryGraph | null;
|
||||||
memoryError: string | null;
|
memoryError: string | null;
|
||||||
|
reloadMemory: () => void;
|
||||||
waiting: WaitingItem[];
|
waiting: WaitingItem[];
|
||||||
recent: RecentItem[];
|
recent: RecentItem[];
|
||||||
counts: { running: number; waiting: number; done: number };
|
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.
|
// open) rather than on the fleet poll — it changes slowly and can be large.
|
||||||
const [memory, setMemory] = useState<MemoryGraph | null>(null);
|
const [memory, setMemory] = useState<MemoryGraph | null>(null);
|
||||||
const [memoryError, setMemoryError] = useState<string | null>(null);
|
const [memoryError, setMemoryError] = useState<string | null>(null);
|
||||||
|
const [memoryNonce, setMemoryNonce] = useState(0);
|
||||||
|
const reloadMemory = useCallback(() => setMemoryNonce((n) => n + 1), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || screen !== "memory") return;
|
if (!client || screen !== "memory") return;
|
||||||
let stale = false;
|
let stale = false;
|
||||||
@@ -450,7 +453,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
stale = true;
|
stale = true;
|
||||||
};
|
};
|
||||||
}, [client, screen]);
|
}, [client, screen, memoryNonce]);
|
||||||
|
|
||||||
// ---- Selected agent's run events ----------------------------------------
|
// ---- Selected agent's run events ----------------------------------------
|
||||||
// Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only
|
// 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,
|
schedules,
|
||||||
memory,
|
memory,
|
||||||
memoryError,
|
memoryError,
|
||||||
|
reloadMemory,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
@@ -677,6 +681,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
schedules,
|
schedules,
|
||||||
memory,
|
memory,
|
||||||
memoryError,
|
memoryError,
|
||||||
|
reloadMemory,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
|
|||||||
Reference in New Issue
Block a user