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
This commit is contained in:
Claude
2026-08-12 19:37:08 +00:00
parent 110772580a
commit 722a2f344c
11 changed files with 751 additions and 59 deletions
+71
View File
@@ -15,6 +15,8 @@ export interface Project {
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;
@@ -156,6 +158,7 @@ export interface ClaudeSkill {
/* 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;
}
@@ -172,6 +175,7 @@ export interface ClaudeConnector {
url?: string | null;
headers?: Record<string, string> | null;
enabled: boolean;
owner_user_id?: number | null;
created_at: string;
}
@@ -181,6 +185,7 @@ export interface ClaudePlugin {
marketplace: string;
marketplace_repo: string;
enabled: boolean;
owner_user_id?: number | null;
created_at: string;
}
@@ -199,6 +204,7 @@ export interface ClaudeModel {
env?: Record<string, string> | null;
enabled: boolean;
has_api_key: boolean;
owner_user_id?: number | null;
created_at: string;
}
@@ -250,6 +256,71 @@ export interface SharedContext {
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 {