feat(app): management foundation - client exposure, manage hub, shared shell

Groundwork for the full admin surface on mobile: the API client gains
the user-account and Claude-management types (skills, connectors,
plugins, permissions, users/auth, owner fields) plus an unauthenticated
authApi helper; AppState exposes the client and the new management
screen names; ManageShell/Field/ErrorNotice and a useResource hook give
the subscreens one shared page/fetch pattern; Settings gains Manage and
Account rows leading to the new hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
Claude
2026-08-13 13:48:50 +00:00
parent 25cf6c9f5b
commit 804baadddd
7 changed files with 447 additions and 4 deletions
+12 -3
View File
@@ -27,7 +27,12 @@ import {
} from "@expo-google-fonts/spline-sans-mono"; } from "@expo-google-fonts/spline-sans-mono";
import { useTheme } from "./src/theme/useTheme"; import { useTheme } from "./src/theme/useTheme";
import { AppStateProvider, useAppState } from "./src/state/AppState"; import {
AppStateProvider,
useAppState,
type Screen as ScreenName,
} from "./src/state/AppState";
import { ManageScreen } from "./src/screens/manage/ManageScreen";
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";
@@ -42,7 +47,9 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
function Router() { function Router() {
const { screen } = useAppState(); const { screen } = useAppState();
const Screen = { // Partial while the management subscreens land one by one; anything unmapped
// falls back to the fleet so a half-wired build never renders undefined.
const screens: Partial<Record<ScreenName, () => React.JSX.Element>> = {
connect: ConnectScreen, connect: ConnectScreen,
fleet: FleetScreen, fleet: FleetScreen,
detail: AgentDetailScreen, detail: AgentDetailScreen,
@@ -52,7 +59,9 @@ function Router() {
memory: MemoryScreen, memory: MemoryScreen,
log: LogScreen, log: LogScreen,
settings: SettingsScreen, settings: SettingsScreen,
}[screen]; manage: ManageScreen,
};
const Screen = screens[screen] ?? FleetScreen;
return <Screen />; return <Screen />;
} }
+125
View File
@@ -12,6 +12,8 @@ export interface Project {
root_dir: string; root_dir: string;
git_remote?: string | null; git_remote?: string | null;
credential_ref?: string | null; credential_ref?: string | null;
/* Owning user account; null = shared/legacy (visible to everyone, admin-managed). */
owner_user_id?: number | null;
created_at: string; created_at: string;
/* Present on the registration response in git-server mode: the enqueued clone. */ /* Present on the registration response in git-server mode: the enqueued clone. */
sync_command_id?: number | null; sync_command_id?: number | null;
@@ -142,6 +144,48 @@ export interface Schedule {
created_at: string; created_at: string;
} }
/* ---- Claude management (the web dashboard's Claude page) ---- */
export interface ClaudeSkill {
id: number;
name: string;
description?: string | null;
content: string;
enabled: boolean;
/* Relative paths of auxiliary files captured by an install-from-prompt import
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
files: string[];
owner_user_id?: number | null;
created_at: string;
updated_at: string;
}
export type McpTransport = "stdio" | "http" | "sse";
export interface ClaudeConnector {
id: number;
name: string;
transport: McpTransport;
command?: string | null;
args?: string[] | null;
env?: Record<string, string> | null;
url?: string | null;
headers?: Record<string, string> | null;
enabled: boolean;
owner_user_id?: number | null;
created_at: string;
}
export interface ClaudePlugin {
id: number;
name: string;
marketplace: string;
marketplace_repo: string;
enabled: boolean;
owner_user_id?: number | null;
created_at: string;
}
/* A registered model backend: an Anthropic-API-compatible endpoint (a local model /* A registered model backend: an Anthropic-API-compatible endpoint (a local model
* behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next * behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next
* to the Claude subscription. The API key is write-only server-side (has_api_key only). */ * to the Claude subscription. The API key is write-only server-side (has_api_key only). */
@@ -157,9 +201,60 @@ export interface ClaudeModel {
env?: Record<string, string> | null; env?: Record<string, string> | null;
enabled: boolean; enabled: boolean;
has_api_key: boolean; has_api_key: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
export interface ClaudePermissions {
default_mode?: string | null;
allow: string[];
deny: string[];
ask: string[];
base_mode: string;
base_allow: string[];
}
/* ---- user accounts (/auth) ---- */
export interface AuthStatus {
initialized: boolean; // any account exists; false => show the first-run setup form
smtp_configured: boolean;
}
export interface User {
id: number;
email: string;
is_admin: boolean;
disabled: boolean;
/* False until an invited user sets their password through their invite link. */
has_password: boolean;
created_at: string;
}
export interface Me {
kind: "user" | "token";
user_id?: number | null;
email?: string | null;
is_admin: boolean;
}
export interface SessionResponse {
token: string;
user: User;
}
export interface UserCreated {
user: User;
invite_url: string;
emailed: boolean;
}
export interface ResetLink {
reset_url: string;
emailed: boolean;
}
/* ---- agent memory (the note graph agents distill their learnings into) ---- */ /* ---- agent memory (the note graph agents distill their learnings into) ---- */
export interface MemoryNote { export interface MemoryNote {
@@ -237,6 +332,36 @@ function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, ""); return baseUrl.trim().replace(/\/+$/, "");
} }
/* Unauthenticated auth calls (status/login/setup/forgot/reset) — used by the connect
* flow before any token exists, so they sit outside createClient. Unlike the web
* client the endpoint is a parameter (the phone talks to a user-entered URL). */
export async function authApi<T>(
baseUrl: string,
path: string,
body?: unknown,
): Promise<T> {
const res = await fetch(normalizeBaseUrl(baseUrl) + path, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail: string = res.statusText;
try {
const j = await res.json();
if (j && typeof j.detail !== "undefined") {
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
}
} catch {
/* non-JSON error body; keep statusText */
}
const err = new Error(detail) as ApiError;
err.status = res.status;
throw err;
}
return (await res.json()) as T;
}
export function createClient( export function createClient(
baseUrl: string, baseUrl: string,
token: string, token: string,
+116
View File
@@ -0,0 +1,116 @@
import React from "react";
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { radius, text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { PageHeader } from "./PageHeader";
import { useAppState, type Screen } from "../state/AppState";
/**
* Page chrome shared by every management subscreen (Settings → Manage → …):
* safe-area top, back header, title/subtitle, keyboard-aware scroll body.
*/
export function ManageShell({
title,
subtitle,
backTo = "manage",
children,
}: {
title: string;
subtitle?: string;
backTo?: Screen;
children: React.ReactNode;
}) {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go } = useAppState();
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
style={styles.flex}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 24 },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<PageHeader leading="back" onLeadingPress={() => go(backTo)} title={title} />
{subtitle ? (
<Text style={[text.bodySm, { color: colors.textMuted, marginBottom: 16 }]}>
{subtitle}
</Text>
) : null}
{children}
</ScrollView>
</KeyboardAvoidingView>
</View>
);
}
/** Inline danger notice used by the manage screens for mutation errors. */
export function ErrorNotice({ message }: { message: string | null }) {
const { colors } = useTheme();
if (!message) return null;
return (
<View
style={[
styles.notice,
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
]}
>
<Text style={[text.bodySm, { color: colors.danger }]}>{message}</Text>
</View>
);
}
/** Labelled block wrapping a TextField (the manage forms' field-with-label pattern). */
export function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
const { colors } = useTheme();
return (
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
{label}
</Text>
{children}
{hint ? (
<Text style={[text.caption, { color: colors.textMuted, marginTop: 6 }]}>
{hint}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
flex: { flex: 1 },
content: { paddingTop: 8, paddingHorizontal: 20 },
notice: {
borderWidth: 1,
borderRadius: radius.md,
padding: 12,
marginBottom: 16,
},
});
+40
View File
@@ -12,9 +12,12 @@ import {
SectionLabel, SectionLabel,
StatusDot, StatusDot,
} from "../components/primitives"; } from "../components/primitives";
import { Icon } from "../components/Icon";
import { TabBar } from "../components/TabBar"; import { TabBar } from "../components/TabBar";
import { useAppState } from "../state/AppState";
import { useServerConfig } from "../state/ServerConfig"; import { useServerConfig } from "../state/ServerConfig";
import { createClient } from "../api/client"; import { createClient } from "../api/client";
import { Pressable } from "react-native";
type Ping = type Ping =
| { state: "checking" } | { state: "checking" }
@@ -25,6 +28,7 @@ export function SettingsScreen() {
const { colors } = useTheme(); const { colors } = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { config, clear } = useServerConfig(); const { config, clear } = useServerConfig();
const { go } = useAppState();
// Notification toggles stay local (no server-side counterpart yet). // Notification toggles stay local (no server-side counterpart yet).
const [pushWait, setPushWait] = useState(true); const [pushWait, setPushWait] = useState(true);
@@ -94,6 +98,21 @@ export function SettingsScreen() {
</View> </View>
</Card> </Card>
<SectionLabel style={{ marginBottom: 10 }}>Control</SectionLabel>
<Card style={{ marginBottom: 20 }}>
<NavRow
title="Manage"
subtitle="Models, skills, repos, users — the full admin surface"
onPress={() => go("manage")}
/>
<Divider />
<NavRow
title="Account"
subtitle="Who you're signed in as, change password"
onPress={() => go("account")}
/>
</Card>
<SectionLabel style={{ marginBottom: 10 }}>Notifications</SectionLabel> <SectionLabel style={{ marginBottom: 10 }}>Notifications</SectionLabel>
<Card style={{ marginBottom: 20 }}> <Card style={{ marginBottom: 20 }}>
<ToggleRow <ToggleRow
@@ -125,6 +144,27 @@ export function SettingsScreen() {
); );
} }
function NavRow({
title,
subtitle,
onPress,
}: {
title: string;
subtitle: string;
onPress: () => void;
}) {
const { colors } = useTheme();
return (
<Pressable style={[styles.infoRow, { paddingVertical: 12 }]} onPress={onPress}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[text.label, { color: colors.textHeading }]}>{title}</Text>
<Text style={[text.caption, { color: colors.textMuted }]}>{subtitle}</Text>
</View>
<Icon name="chevronRight" size={16} color={colors.textMuted} />
</Pressable>
);
}
function InfoRow({ label, value }: { label: string; value: string }) { function InfoRow({ label, value }: { label: string; value: string }) {
const { colors } = useTheme(); const { colors } = useTheme();
return ( return (
+86
View File
@@ -0,0 +1,86 @@
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { text } from "../../theme/tokens";
import { useTheme } from "../../theme/useTheme";
import { Icon } from "../../components/Icon";
import { ManageShell } from "../../components/ManageShell";
import { Card, Divider, SectionLabel } from "../../components/primitives";
import { useAppState, type Screen } from "../../state/AppState";
/**
* The management hub (Settings → Manage): the mobile counterpart of the web
* dashboard's admin surface. Most actions here need the admin token (or an
* admin account session); non-admin sessions get inline 403s on writes.
*/
interface Row {
screen: Screen;
title: string;
subtitle: string;
}
const CLAUDE_ROWS: Row[] = [
{ screen: "models", title: "Models", subtitle: "Model backends for spawns + schedules" },
{ screen: "skills", title: "Skills", subtitle: "Managed skills synced to every worker" },
{ screen: "connectors", title: "Connectors", subtitle: "MCP servers agents may reach" },
{ screen: "plugins", title: "Plugins", subtitle: "Marketplace plugins installed on boot" },
{ screen: "permissions", title: "Permissions", subtitle: "Default mode + allow/deny/ask rules" },
{ screen: "claudeLogin", title: "Claude login", subtitle: "Sign the worker into Claude Code" },
];
const SERVER_ROWS: Row[] = [
{ screen: "repositories", title: "Repositories", subtitle: "Register + sync project repos" },
{ screen: "gitServers", title: "Git servers", subtitle: "Forge hosts, tokens, deploy keys" },
{ screen: "approvals", title: "Approvals", subtitle: "Approve or reject protected branches" },
{ screen: "shared", title: "Shared context", subtitle: "Key/value context all agents see" },
{ screen: "users", title: "Users", subtitle: "Invite, promote, disable, reset" },
];
export function ManageScreen() {
return (
<ManageShell
title="Manage"
subtitle="The full control surface — everything the web dashboard can do."
backTo="settings"
>
<SectionLabel style={{ marginBottom: 8 }}>Claude</SectionLabel>
<RowsCard rows={CLAUDE_ROWS} />
<SectionLabel style={{ marginTop: 20, marginBottom: 8 }}>Server</SectionLabel>
<RowsCard rows={SERVER_ROWS} />
</ManageShell>
);
}
function RowsCard({ rows }: { rows: Row[] }) {
const { colors } = useTheme();
const { go } = useAppState();
return (
<Card>
{rows.map((r, i) => (
<View key={r.screen}>
{i > 0 && <Divider />}
<Pressable style={styles.row} onPress={() => go(r.screen)}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[text.label, { color: colors.textHeading }]}>{r.title}</Text>
<Text style={[text.caption, { color: colors.textMuted }]}>
{r.subtitle}
</Text>
</View>
<Icon name="chevronRight" size={16} color={colors.textMuted} />
</Pressable>
</View>
))}
</Card>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
minHeight: 44,
},
});
+22 -1
View File
@@ -44,7 +44,21 @@ export type Screen =
| "schedules" | "schedules"
| "memory" | "memory"
| "log" | "log"
| "settings"; | "settings"
/* Management subscreens, reached from Settings → Manage. */
| "manage"
| "models"
| "skills"
| "connectors"
| "plugins"
| "permissions"
| "repositories"
| "gitServers"
| "approvals"
| "shared"
| "users"
| "account"
| "claudeLogin";
export type DetailTab = "state" | "events" | "log"; export type DetailTab = "state" | "events" | "log";
export type BadgeTone = "neutral" | "positive" | "warning" | "danger"; export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
@@ -87,6 +101,11 @@ interface Selected {
} }
interface AppStateValue { interface AppStateValue {
/* The API client for the configured endpoint/token. Management screens fetch and
* mutate through it directly (with their own local state) rather than growing this
* store; it is null only in the moment before ServerConfig loads. */
client: ApiClient | null;
// Navigation. // Navigation.
screen: Screen; screen: Screen;
detailTab: DetailTab; detailTab: DetailTab;
@@ -609,6 +628,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const value = useMemo<AppStateValue>( const value = useMemo<AppStateValue>(
() => ({ () => ({
client,
screen, screen,
detailTab, detailTab,
logFilter, logFilter,
@@ -644,6 +664,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
deleteSchedule, deleteSchedule,
}), }),
[ [
client,
screen, screen,
detailTab, detailTab,
logFilter, logFilter,
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
import { AuthError } from "../api/client";
import { useAppState } from "./AppState";
/**
* Fetch-on-mount helper for the management screens: GETs `path` through the app's
* client, tracks loading/error, and exposes `reload` for after a mutation.
* Pass null to fetch nothing (e.g. while a prerequisite is missing).
*/
export function useResource<T>(path: string | null): {
data: T | null;
error: string | null;
loading: boolean;
reload: () => void;
} {
const { client } = useAppState();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(path !== null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!client || path === null) return;
let stale = false;
setLoading(true);
setError(null);
client
.api<T>(path)
.then((d) => {
if (!stale) setData(d);
})
.catch((e) => {
if (e instanceof AuthError) return; // handled by onUnauthorized
if (!stale) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!stale) setLoading(false);
});
return () => {
stale = true;
};
}, [client, path, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []);
return { data, error, loading, reload };
}