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
+15
View File
@@ -392,6 +392,21 @@ a:hover {
font-family: var(--font-mono);
}
/* Inline text button (the auth gate's "Forgot password?" / mode switches). */
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--lw-blue-200, #90cdf4);
font-size: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link:hover {
opacity: 0.85;
}
/* ---------------- Callout ---------------- */
.callout {
border-radius: var(--radius-md);
+99
View File
@@ -0,0 +1,99 @@
/* Public set-password page — where invite and reset links land (/reset?token=…).
* Outside the auth gate by design: the person arriving here has no session yet.
* Success stores the fresh session token and drops the user into the dashboard. */
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authApi, type ApiError, type SessionResponse } from "@/lib/api";
function ResetForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Passwords don't match.");
return;
}
setBusy(true);
try {
const session = await authApi<SessionResponse>("/auth/reset", { token, password });
window.localStorage.setItem("handler_token", session.token);
router.replace("/");
} catch (err) {
setError((err as ApiError).message || "Something went wrong.");
} finally {
setBusy(false);
}
};
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
{token ? (
<>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Choose a password for your account. The link you followed is one-shot once
set, sign in with your email and this password.
</p>
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="New password (8+ characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
autoFocus
required
/>
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
minLength={8}
required
/>
{error && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{error}
</p>
)}
<button className="btn btn-primary" type="submit" disabled={busy}>
Set password and sign in
</button>
</>
) : (
<p className="callout callout-danger" style={{ margin: 0 }}>
This page needs a reset link (/reset?token=). Ask an admin for one, or use
Forgot password? on the sign-in page.
</p>
)}
</form>
</div>
);
}
export default function ResetPage() {
// useSearchParams requires a Suspense boundary under the static export.
return (
<Suspense fallback={null}>
<ResetForm />
</Suspense>
);
}
+12
View File
@@ -0,0 +1,12 @@
/* Users page (admin only — the sidebar hides it otherwise; the API enforces it). */
"use client";
import { UsersSection } from "@/components/sections/UsersSection";
export default function UsersPage() {
return (
<div className="main-scroll">
<UsersSection />
</div>
);
}
+30 -6
View File
@@ -1,15 +1,18 @@
/* Auth frame: token gate → shell. Lives in the root layout so it wraps every page and
/* Auth frame: sign-in gate → shell. Lives in the root layout so it wraps every page and
* persists across client-side navigation. Client-only; the exported HTML is a shell and
* every byte of data is fetched by the browser from the authed API after the token is
* supplied. A 401 from any call clears the token and re-prompts with an error. */
* every byte of data is fetched by the browser from the authed API after sign-in. The
* bearer is a user session token from /auth/login (or a raw legacy API token via the
* gate's fallback) — either way it rides Authorization on every call, and a 401 clears
* it and re-prompts. The /reset route is public (it's how invite/reset links land). */
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { DashboardProvider } from "@/components/store";
import { Shell } from "@/components/Shell";
import { TokenGate } from "@/components/TokenGate";
import { AuthGate } from "@/components/AuthGate";
import { sectionFromPath } from "@/lib/nav";
import { type SessionResponse } from "@/lib/api";
const TOKEN_KEY = "handler_token";
@@ -30,7 +33,22 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
setToken(t);
}, []);
const onSession = useCallback(
(s: SessionResponse) => {
saveToken(s.token);
},
[saveToken],
);
const signOut = useCallback(() => {
const t = window.localStorage.getItem(TOKEN_KEY);
if (t) {
// Best-effort server-side revocation; a legacy env token treats this as a no-op.
void fetch("/auth/logout", {
method: "POST",
headers: { Authorization: `Bearer ${t}` },
}).catch(() => undefined);
}
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
}, []);
@@ -38,11 +56,17 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setError("Invalid token — please try again.");
setError("Session expired or token rejected — please sign in again.");
}, []);
// Invite/reset links must render without a session — that's their whole point.
const isPublicRoute = pathname.replace(/\/+$/, "") === "/reset";
if (isPublicRoute) {
return <>{children}</>;
}
if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />;
return <AuthGate error={error} onSession={onSession} onToken={saveToken} />;
}
return (
+211
View File
@@ -0,0 +1,211 @@
/* Auth gate: email + password sign-in, shown until a session exists. On a fresh
* deployment (no accounts yet) it becomes the first-run setup form — the account
* created there is the admin. A collapsible fallback still accepts a raw API token
* for headless/legacy setups. Holds no data; the session token lives in localStorage. */
"use client";
import { useEffect, useState } from "react";
import { authApi, type ApiError, type AuthStatus, type SessionResponse } from "@/lib/api";
type Mode = "loading" | "setup" | "login" | "forgot" | "token";
export function AuthGate({
error,
onSession,
onToken,
}: {
error?: string;
/* A fresh session from login/setup: token + user. */
onSession: (s: SessionResponse) => void;
/* Raw API-token fallback (legacy/scripts). */
onToken: (token: string) => void;
}) {
const [mode, setMode] = useState<Mode>("loading");
const [status, setStatus] = useState<AuthStatus | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [rawToken, setRawToken] = useState("");
const [message, setMessage] = useState("");
const [formError, setFormError] = useState(error ?? "");
const [busy, setBusy] = useState(false);
useEffect(() => {
authApi<AuthStatus>("/auth/status")
.then((s) => {
setStatus(s);
setMode(s.initialized ? "login" : "setup");
})
.catch(() => {
// API unreachable or very old server — fall back to the raw token prompt.
setMode("token");
});
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setMessage("");
if (mode === "token") {
if (rawToken.trim()) onToken(rawToken.trim());
return;
}
setBusy(true);
try {
if (mode === "setup") {
if (password !== confirm) {
setFormError("Passwords don't match.");
return;
}
onSession(await authApi<SessionResponse>("/auth/setup", { email, password }));
} else if (mode === "login") {
onSession(await authApi<SessionResponse>("/auth/login", { email, password }));
} else if (mode === "forgot") {
const r = await authApi<{ ok: boolean; emailed: boolean }>("/auth/forgot", { email });
setMessage(
r.emailed
? "If that address has an account, a reset link is on its way."
: "Email isn't configured on this deployment — ask an admin to generate a reset link for you.",
);
}
} catch (err) {
setFormError((err as ApiError).message || "Something went wrong.");
} finally {
setBusy(false);
}
};
if (mode === "loading") {
return (
<div className="gate">
<div className="gate-card">
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Loading
</p>
</div>
</div>
);
}
const heading =
mode === "setup"
? "Welcome — create the first account. It becomes the admin; everyone else is invited by you."
: mode === "forgot"
? "Enter your account email and we'll send a password reset link."
: mode === "token"
? "Paste a raw API token (legacy / script access)."
: "Sign in with your email and password.";
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
{heading}
</p>
{mode !== "token" && (
<input
className="input"
type="email"
autoComplete="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
)}
{(mode === "login" || mode === "setup") && (
<input
className="input"
type="password"
autoComplete={mode === "setup" ? "new-password" : "current-password"}
placeholder={mode === "setup" ? "Password (8+ characters)" : "Password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
)}
{mode === "setup" && (
<input
className="input"
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
minLength={8}
required
/>
)}
{mode === "token" && (
<input
className="input"
type="password"
autoComplete="off"
placeholder="API token"
value={rawToken}
onChange={(e) => setRawToken(e.target.value)}
autoFocus
/>
)}
{formError && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{formError}
</p>
)}
{message && (
<p className="callout callout-info" style={{ margin: 0 }}>
{message}
</p>
)}
<button className="btn btn-primary" type="submit" disabled={busy}>
{mode === "setup"
? "Create admin account"
: mode === "forgot"
? "Send reset link"
: mode === "token"
? "Continue"
: "Sign in"}
</button>
<div
className="hstack muted"
style={{ justifyContent: "space-between", fontSize: "var(--text-xs)" }}
>
{mode === "login" && (
<button type="button" className="btn-link" onClick={() => setMode("forgot")}>
Forgot password?
</button>
)}
{(mode === "forgot" || mode === "token") && status?.initialized !== false && (
<button type="button" className="btn-link" onClick={() => setMode("login")}>
Back to sign in
</button>
)}
{mode !== "token" && (
<button type="button" className="btn-link" onClick={() => setMode("token")}>
Use an API token
</button>
)}
{mode === "token" && status?.initialized === false && (
<button type="button" className="btn-link" onClick={() => setMode("setup")}>
Back to setup
</button>
)}
</div>
</form>
</div>
);
}
+22 -2
View File
@@ -35,6 +35,7 @@ const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
users: { count: (s) => s.users.length },
};
export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) {
@@ -55,7 +56,7 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children
<span className="logo" />
Claude Monitor
</div>
{NAV_ROUTES.map((n) => {
{NAV_ROUTES.filter((n) => n.key !== "users" || s.me?.is_admin).map((n) => {
const badge = BADGES[n.key];
const c = badge?.count(s) ?? 0;
const isAccent = badge?.accent?.(s) ?? false;
@@ -74,11 +75,30 @@ export function Shell({ onSignOut, children }: { onSignOut: () => void; children
})}
<div className="sidebar-spacer" />
<div className="sidebar-foot">
{s.me && (
<div
className="nav-item"
style={{ cursor: "default", opacity: 0.8 }}
title={s.me.kind === "token" ? "Authenticated with an API token" : "Signed in"}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "var(--text-xs)",
}}
>
{s.me.kind === "token" ? "API token" : s.me.email}
{s.me.is_admin ? " · admin" : ""}
</span>
</div>
)}
<button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span>
<span className="count"></span>
</button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
<button className="nav-item" onClick={onSignOut} title="Sign out">
<span>Sign out</span>
</button>
</div>
-48
View File
@@ -1,48 +0,0 @@
/* Token gate: shown until an API token is supplied. Holds no data. Management actions
* (spawn, approve, edit repos/servers) need the admin token; read-only views need the
* plain auth token. The token lives only in localStorage on this device. */
"use client";
import { useState } from "react";
export function TokenGate({ error, onSubmit }: { error?: string; onSubmit: (token: string) => void }) {
const [value, setValue] = useState("");
const submit = (e: React.FormEvent) => {
e.preventDefault();
const t = value.trim();
if (t) onSubmit(t);
};
return (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Paste your API token to continue. Management actions require the admin token; read-only
views work with the plain auth token.
</p>
<input
className="input"
type="password"
autoComplete="current-password"
placeholder="API token"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus
/>
{error && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{error}
</p>
)}
<button className="btn btn-primary" type="submit">
Continue
</button>
</form>
</div>
);
}
@@ -0,0 +1,161 @@
/* Users — admin-only account management. 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, disable, delete, and mint reset links. Deleting a user turns
* their projects/skills/tools into shared resources rather than removing them. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import { Button } from "@/components/ui";
import { fmtFull } from "@/lib/format";
export function UsersSection() {
const s = useDashboard();
const [email, setEmail] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [lastLink, setLastLink] = useState<{ email: string; url: string } | null>(null);
const invite = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
const created = await s.createUser(email, isAdmin);
if (created) {
setLastLink({ email: created.user.email, url: created.invite_url });
setEmail("");
setIsAdmin(false);
}
};
const resetLink = async (id: number, userEmail: string) => {
const link = await s.mintResetLink(id);
if (link) setLastLink({ email: userEmail, url: link.reset_url });
};
const isSelf = (id: number) => s.me?.kind === "user" && s.me.user_id === id;
return (
<>
<div className="section-head">
<div className="section-title">Users</div>
<div className="section-desc">
Accounts for this Handler. Each user&apos;s projects, skills, and tools are
theirs alone; shared (unowned) resources are visible to everyone and managed by
admins.
</div>
</div>
<div className="section-body vstack" style={{ gap: 16 }}>
<form className="hstack" style={{ gap: 8, flexWrap: "wrap" }} onSubmit={invite}>
<input
className="input"
type="email"
placeholder="new-user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ maxWidth: 320 }}
required
/>
<label className="hstack muted" style={{ gap: 6, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
/>
admin
</label>
<Button type="submit" disabled={s.cmd.busy}>
Invite user
</Button>
</form>
{lastLink && (
<div className="callout callout-info vstack" style={{ gap: 6 }}>
<span>
One-shot set-password link for <strong>{lastLink.email}</strong> (share it over
a channel you trust; it expires):
</span>
<code
className="mono"
style={{ wordBreak: "break-all", userSelect: "all", fontSize: "var(--text-xs)" }}
>
{lastLink.url}
</code>
</div>
)}
{s.users.length === 0 ? (
<div className="empty">No users loaded (admin access required).</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{s.users.map((u) => (
<tr key={u.id}>
<td className="mono">
{u.email}
{isSelf(u.id) ? <span className="faint"> (you)</span> : null}
</td>
<td>{u.is_admin ? "admin" : "user"}</td>
<td className={u.disabled ? "faint" : undefined}>
{u.disabled
? "disabled"
: u.has_password
? "active"
: "invited — awaiting password"}
</td>
<td className="faint nowrap">{fmtFull(u.created_at)}</td>
<td>
<div className="hstack" style={{ gap: 6, justifyContent: "flex-end" }}>
<Button
variant="secondary"
onClick={() => void s.updateUser(u.id, { is_admin: !u.is_admin })}
>
{u.is_admin ? "Demote" : "Make admin"}
</Button>
<Button
variant="secondary"
onClick={() => void s.updateUser(u.id, { disabled: !u.disabled })}
>
{u.disabled ? "Enable" : "Disable"}
</Button>
<Button
variant="secondary"
onClick={() => void resetLink(u.id, u.email)}
>
Reset link
</Button>
<Button
variant="danger"
disabled={isSelf(u.id)}
onClick={() => {
if (
window.confirm(
`Remove ${u.email}? Their projects, skills, and tools become shared.`,
)
) {
void s.deleteUser(u.id);
}
}}
>
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+128 -3
View File
@@ -29,11 +29,15 @@ import {
type Command,
type Host,
type LogEntry,
type Me,
type MemoryGraph,
type NoteKind,
type Project,
type ResetLink,
type Schedule,
type SharedContext,
type User,
type UserCreated,
} from "@/lib/api";
export type Section =
@@ -46,7 +50,8 @@ export type Section =
| "activity"
| "shared"
| "memory"
| "claude";
| "claude"
| "users";
/* The claude web-login flow, driven through the login_start / login_submit commands.
* idle → starting → awaiting (have URL) → submitting → done | error */
@@ -108,6 +113,17 @@ interface StoreValue {
lastError: string;
loading: boolean;
/* Who this session belongs to (null until /auth/me answers). Legacy env tokens come
* back as kind "token"; admin-ness drives the Users nav and admin-only controls. */
me: Me | null;
/* User accounts (admin only; empty for everyone else). */
users: User[];
createUser: (email: string, isAdmin: boolean) => Promise<UserCreated | null>;
updateUser: (id: number, b: { is_admin?: boolean; disabled?: boolean }) => Promise<boolean>;
deleteUser: (id: number) => Promise<void>;
mintResetLink: (id: number) => Promise<ResetLink | null>;
refresh: () => void;
// actions
@@ -338,6 +354,8 @@ export function DashboardProvider({
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [users, setUsers] = useState<User[]>([]);
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -494,6 +512,23 @@ export function DashboardProvider({
}
}, []);
const loadMe = useCallback(async () => {
try {
setMe(await clientRef.current.api<Me>("/auth/me"));
} catch (e) {
if (!(e instanceof AuthError)) setMe(null);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setUsers(await clientRef.current.api<User[]>("/auth/users"));
} catch {
// Non-admins get a 403 here; the section is hidden for them anyway.
setUsers([]);
}
}, []);
const loadClaude = useCallback(async () => {
try {
const [skills, connectors, plugins, permissions] = await Promise.all([
@@ -538,10 +573,15 @@ export function DashboardProvider({
if (s === "shared") await loadShared();
if (s === "memory") await loadMemory();
if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels]);
if (s === "users") await loadUsers();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels, loadUsers]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
useEffect(() => {
void loadMe();
}, [loadMe]);
useEffect(() => {
let alive = true;
(async () => {
@@ -570,8 +610,9 @@ export function DashboardProvider({
if (s === "shared") void loadShared();
if (s === "memory") void loadMemory();
if (s === "claude") void loadClaude();
if (s === "users") void loadUsers();
},
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels, loadUsers],
);
const selectProject = useCallback(
@@ -1400,6 +1441,84 @@ export function DashboardProvider({
[memoryWrite],
);
// ---- user accounts (admin management; direct writes like the Claude pages) ----
const createUser = useCallback(
async (email: string, isAdmin: boolean): Promise<UserCreated | null> => {
try {
const created = await clientRef.current.api<UserCreated>("/auth/users", {
method: "POST",
body: { email: email.trim(), is_admin: isAdmin },
});
setCmd({
text: created.emailed
? `invited ${created.user.email} — an email with their set-password link is on its way`
: `invited ${created.user.email} — email is not configured, hand them the link below`,
error: false,
busy: false,
});
await loadUsers();
return created;
} catch (e) {
if (e instanceof AuthError) return null;
setCmd({ text: (e as Error).message, error: true, busy: false });
return null;
}
},
[loadUsers],
);
const updateUser = useCallback(
async (id: number, b: { is_admin?: boolean; disabled?: boolean }) => {
try {
await clientRef.current.api(`/auth/users/${id}`, { method: "PATCH", body: b });
await loadUsers();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadUsers],
);
const deleteUser = useCallback(
async (id: number) => {
try {
const r = await clientRef.current.api<{ deleted: string; note: string }>(
`/auth/users/${id}`,
{ method: "DELETE" },
);
setCmd({ text: `removed ${r.deleted}${r.note}`, error: false, busy: false });
await loadUsers();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadUsers],
);
const mintResetLink = useCallback(async (id: number): Promise<ResetLink | null> => {
try {
const link = await clientRef.current.api<ResetLink>(`/auth/users/${id}/reset-link`, {
method: "POST",
});
setCmd({
text: link.emailed
? "reset link emailed to the user (also shown below)"
: "reset link generated — email is not configured, hand it over yourself",
error: false,
busy: false,
});
return link;
} catch (e) {
if (e instanceof AuthError) return null;
setCmd({ text: (e as Error).message, error: true, busy: false });
return null;
}
}, []);
const value: StoreValue = {
section,
setSection,
@@ -1424,6 +1543,12 @@ export function DashboardProvider({
cmd,
lastError,
loading,
me,
users,
createUser,
updateUser,
deleteUser,
mintResetLink,
refresh,
spawnAgent,
killAgent,
+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 {
+2
View File
@@ -20,6 +20,8 @@ export const NAV_ROUTES: NavRoute[] = [
{ key: "shared", href: "/shared", label: "Shared" },
{ key: "memory", href: "/memory", label: "Memory" },
{ key: "claude", href: "/claude", label: "Claude" },
// Admin-only: the Shell hides this entry for non-admin sessions.
{ key: "users", href: "/users", label: "Users" },
];
/* Map a browser path back to its section key. Trailing slashes (Next emits them under