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); 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 ---------------- */
.callout { .callout {
border-radius: var(--radius-md); 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 * 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 * every byte of data is fetched by the browser from the authed API after sign-in. The
* supplied. A 401 from any call clears the token and re-prompts with an error. */ * 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"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { DashboardProvider } from "@/components/store"; import { DashboardProvider } from "@/components/store";
import { Shell } from "@/components/Shell"; import { Shell } from "@/components/Shell";
import { TokenGate } from "@/components/TokenGate"; import { AuthGate } from "@/components/AuthGate";
import { sectionFromPath } from "@/lib/nav"; import { sectionFromPath } from "@/lib/nav";
import { type SessionResponse } from "@/lib/api";
const TOKEN_KEY = "handler_token"; const TOKEN_KEY = "handler_token";
@@ -30,7 +33,22 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
setToken(t); setToken(t);
}, []); }, []);
const onSession = useCallback(
(s: SessionResponse) => {
saveToken(s.token);
},
[saveToken],
);
const signOut = useCallback(() => { 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); window.localStorage.removeItem(TOKEN_KEY);
setToken(null); setToken(null);
}, []); }, []);
@@ -38,11 +56,17 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
const onUnauthorized = useCallback(() => { const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(TOKEN_KEY);
setToken(null); 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) { if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />; return <AuthGate error={error} onSession={onSession} onToken={saveToken} />;
} }
return ( 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. // Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done", accent: (s) => s.claudeLogin.status !== "done",
}, },
users: { count: (s) => s.users.length },
}; };
export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) { 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" /> <span className="logo" />
Claude Monitor Claude Monitor
</div> </div>
{NAV_ROUTES.map((n) => { {NAV_ROUTES.filter((n) => n.key !== "users" || s.me?.is_admin).map((n) => {
const badge = BADGES[n.key]; const badge = BADGES[n.key];
const c = badge?.count(s) ?? 0; const c = badge?.count(s) ?? 0;
const isAccent = badge?.accent?.(s) ?? false; 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-spacer" />
<div className="sidebar-foot"> <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"> <button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span> <span>Refresh</span>
<span className="count"></span> <span className="count"></span>
</button> </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> <span>Sign out</span>
</button> </button>
</div> </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 Command,
type Host, type Host,
type LogEntry, type LogEntry,
type Me,
type MemoryGraph, type MemoryGraph,
type NoteKind, type NoteKind,
type Project, type Project,
type ResetLink,
type Schedule, type Schedule,
type SharedContext, type SharedContext,
type User,
type UserCreated,
} from "@/lib/api"; } from "@/lib/api";
export type Section = export type Section =
@@ -46,7 +50,8 @@ export type Section =
| "activity" | "activity"
| "shared" | "shared"
| "memory" | "memory"
| "claude"; | "claude"
| "users";
/* The claude web-login flow, driven through the login_start / login_submit commands. /* The claude web-login flow, driven through the login_start / login_submit commands.
* idle → starting → awaiting (have URL) → submitting → done | error */ * idle → starting → awaiting (have URL) → submitting → done | error */
@@ -108,6 +113,17 @@ interface StoreValue {
lastError: string; lastError: string;
loading: boolean; 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; refresh: () => void;
// actions // actions
@@ -338,6 +354,8 @@ export function DashboardProvider({
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]); const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null); const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]); 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. // Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section); 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 () => { const loadClaude = useCallback(async () => {
try { try {
const [skills, connectors, plugins, permissions] = await Promise.all([ const [skills, connectors, plugins, permissions] = await Promise.all([
@@ -538,10 +573,15 @@ export function DashboardProvider({
if (s === "shared") await loadShared(); if (s === "shared") await loadShared();
if (s === "memory") await loadMemory(); if (s === "memory") await loadMemory();
if (s === "claude") await loadClaude(); 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 // 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. // active section) up front, so the Runs inbox is filled without waiting a poll interval.
useEffect(() => {
void loadMe();
}, [loadMe]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { (async () => {
@@ -570,8 +610,9 @@ export function DashboardProvider({
if (s === "shared") void loadShared(); if (s === "shared") void loadShared();
if (s === "memory") void loadMemory(); if (s === "memory") void loadMemory();
if (s === "claude") void loadClaude(); 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( const selectProject = useCallback(
@@ -1400,6 +1441,84 @@ export function DashboardProvider({
[memoryWrite], [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 = { const value: StoreValue = {
section, section,
setSection, setSection,
@@ -1424,6 +1543,12 @@ export function DashboardProvider({
cmd, cmd,
lastError, lastError,
loading, loading,
me,
users,
createUser,
updateUser,
deleteUser,
mintResetLink,
refresh, refresh,
spawnAgent, spawnAgent,
killAgent, killAgent,
+71
View File
@@ -15,6 +15,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;
@@ -156,6 +158,7 @@ export interface ClaudeSkill {
/* Relative paths of auxiliary files captured by an install-from-prompt import /* Relative paths of auxiliary files captured by an install-from-prompt import
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */ * (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
files: string[]; files: string[];
owner_user_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -172,6 +175,7 @@ export interface ClaudeConnector {
url?: string | null; url?: string | null;
headers?: Record<string, string> | null; headers?: Record<string, string> | null;
enabled: boolean; enabled: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
@@ -181,6 +185,7 @@ export interface ClaudePlugin {
marketplace: string; marketplace: string;
marketplace_repo: string; marketplace_repo: string;
enabled: boolean; enabled: boolean;
owner_user_id?: number | null;
created_at: string; created_at: string;
} }
@@ -199,6 +204,7 @@ 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;
} }
@@ -250,6 +256,71 @@ export interface SharedContext {
updated_at: string; 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 /* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
* quiet while the app re-prompts for a token. */ * quiet while the app re-prompts for a token. */
export class AuthError extends Error { export class AuthError extends Error {
+2
View File
@@ -20,6 +20,8 @@ export const NAV_ROUTES: NavRoute[] = [
{ key: "shared", href: "/shared", label: "Shared" }, { key: "shared", href: "/shared", label: "Shared" },
{ key: "memory", href: "/memory", label: "Memory" }, { key: "memory", href: "/memory", label: "Memory" },
{ key: "claude", href: "/claude", label: "Claude" }, { 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 /* Map a browser path back to its section key. Trailing slashes (Next emits them under