Files
Claude 722a2f344c Frontend: email sign-in, first-run setup, reset links, Users admin page
- AuthGate replaces the raw token prompt: first-run setup form (creates
  the admin) when no accounts exist, email/password sign-in with a
  forgot-password flow, and a collapsible raw-API-token fallback for
  legacy/script setups.
- /reset is a public page where invite and password-reset links land;
  success stores the fresh session and enters the dashboard.
- Users section (admin-only nav): invite by email (link always shown,
  emailed when SMTP is configured), admin/disable toggles, reset links,
  and delete with the shared-resources handoff spelled out.
- Sidebar shows who is signed in; sign-out revokes the session
  server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
2026-08-12 19:37:08 +00:00

404 lines
11 KiB
TypeScript

/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
* pydantic schemas in src/handler/api/schemas.py). The browser calls the API same-origin
* with relative paths; set NEXT_PUBLIC_API_BASE to point `npm run dev` at another origin.
*
* NOTE: this file lives under frontend/lib/, now un-ignored in .gitignore so the source
* ships and the build works from a fresh clone (the built export under
* src/handler/api/static/ is what the package serves). */
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
export type CommandStatus = "queued" | "running" | "done" | "failed";
export interface Project {
id: string;
root_dir: string;
git_remote?: 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;
/* Present on the registration response in git-server mode: the enqueued clone. */
sync_command_id?: number | null;
/* Present on the registration response when "Initialize mise" was ticked: the
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
mise_init_command_id?: number | null;
}
export interface Agent {
id: number;
project_id: string;
name: string;
working_dir: string;
status: string;
role?: string | null;
/* Model backend the agent is pinned to (see ClaudeModel); null = the Claude
* subscription the worker is logged in to. */
model_id?: number | null;
/* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
* latest assistant text for headless runs. For a crashed agent this is the evidence
* frame — the last thing the process said. */
last_output?: string | null;
output_at?: string | null;
/* Headless runner: claude session UUID (null = legacy tmux agent) + supervising worker. */
session_id?: string | null;
worker_id?: string | null;
created_at: string;
}
/* One persisted stream-json event of a headless run (GET .../events, cursor-paged by id).
* `type` mirrors the stream (system/assistant/user/result) plus `worker` (runner notices)
* and `raw` (unparseable line kept verbatim). */
export interface AgentEvent {
id: number;
agent_id: number;
run_id: number;
session_id?: string | null;
seq: number;
type: string;
payload?: Record<string, unknown> | null;
created_at: string;
}
export interface Checkmark {
agent_id: number;
checkpoint_at: string;
status: string;
where_it_stopped?: string | null;
next_steps?: string[] | null;
open_question?: string | null;
log_entry_id?: number | null;
tests_status: string;
tested_at?: string | null;
build_status: string;
built_at?: string | null;
}
export interface LogEntry {
id: number;
agent_id: number;
created_at: string;
session_id?: string | null;
status: string;
summary?: string | null;
decisions?: string | null;
question?: string | null;
answer?: string | null;
visibility: string;
push_sha?: string | null;
ci_status: string;
ci_checked_at?: string | null;
}
export interface Approval {
id: number;
project_id: string;
branch: string;
approved_sha?: string | null;
pr_ref?: string | null;
status: string;
approved_by_agent_id?: number | null;
actor?: string | null;
note?: string | null;
created_at: string;
}
export interface Host {
hostname: string;
forge_type: string;
token_env_var?: string | null;
base_url?: string | null;
ssh_public_key?: string | null;
has_token: boolean;
created_at: string;
}
export interface Command {
id: number;
project_id?: string | null;
agent_name?: string | null;
type: string;
payload?: Record<string, unknown> | null;
status: CommandStatus;
result?: Record<string, unknown> | null;
error?: string | null;
requested_by?: string | null;
claimed_by?: string | null;
created_at: string;
claimed_at?: string | null;
finished_at?: string | null;
}
export interface Schedule {
id: number;
project_id: string;
name_prefix: string;
task: string;
role?: string | null;
worktree?: string | null;
subdir?: string | null;
/* Model backend every fired run spawns on (see ClaudeModel); null = subscription. */
model_id?: number | null;
interval_seconds: number;
enabled: boolean;
next_run_at: string;
last_run_at?: string | null;
last_command_id?: number | null;
created_at: string;
}
/* ---- Claude management (the 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
* 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). */
export interface ClaudeModel {
id: number;
name: string;
base_url: string;
model: string;
small_fast_model?: string | null;
/* Which agent binary runs against this backend: "claude" (Anthropic-compatible
endpoint required) or "pi" (bare OpenAI-compatible endpoint, lightweight). */
harness?: "claude" | "pi";
env?: Record<string, string> | null;
enabled: boolean;
has_api_key: boolean;
owner_user_id?: number | null;
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[];
}
/* ---- agent memory (the Memory page's note graph) ---- */
export type NoteKind = "fact" | "decision" | "gotcha" | "runbook";
export interface MemoryNote {
id: number;
project_id?: string | null; // null = global note
agent_id?: number | null; // authoring agent; null = operator-authored
title: string;
body: string;
kind: string;
tags?: string[] | null;
created_at: string;
updated_at: string;
}
export interface MemoryLink {
id: number;
src_note_id: number;
dst_note_id: number;
relation: string;
created_by_agent_id?: number | null;
created_at: string;
}
/* Everything the graph view draws, in one response (GET /memory/graph). */
export interface MemoryGraph {
notes: MemoryNote[];
links: MemoryLink[];
}
export interface SharedContext {
key: string;
value: string;
set_by_agent_id?: number | null;
updated_at: 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;
}
/* Unauthenticated auth calls (status/login/setup/forgot/reset) — used by the gate
* before any token exists, so they sit outside createClient. */
export async function authApi<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + 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;
}
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
* quiet while the app re-prompts for a token. */
export class AuthError extends Error {
constructor(message = "unauthorized") {
super(message);
this.name = "AuthError";
}
}
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
export interface ApiError extends Error {
status: number;
}
interface ApiOptions {
method?: string;
body?: unknown;
}
interface TrackOptions {
attempts?: number;
intervalMs?: number;
}
export interface ApiClient {
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
* budget (worker down or a very slow command). */
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
}
export function createClient(token: string, onUnauthorized: () => void): ApiClient {
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
const hasBody = opts?.body !== undefined && opts?.body !== null;
const res = await fetch(BASE + path, {
method: opts?.method ?? (hasBody ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${token}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
},
body: hasBody ? JSON.stringify(opts!.body) : undefined,
});
if (res.status === 401) {
onUnauthorized();
throw new AuthError();
}
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;
}
if (res.status === 204) return undefined as T;
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
const attempts = opts?.attempts ?? 60;
const intervalMs = opts?.intervalMs ?? 500;
for (let i = 0; i < attempts; i++) {
const cmd = await api<Command>(`/commands/${id}`);
if (cmd.status === "done" || cmd.status === "failed") return cmd;
await new Promise((r) => setTimeout(r, intervalMs));
}
return null;
}
return { api, trackCommand };
}