diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7e25881..05d3f36 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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); diff --git a/frontend/app/reset/page.tsx b/frontend/app/reset/page.tsx new file mode 100644 index 0000000..9c29e1d --- /dev/null +++ b/frontend/app/reset/page.tsx @@ -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("/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 ( +
+
+
+ + Claude Monitor +
+ {token ? ( + <> +

+ Choose a password for your account. The link you followed is one-shot — once + set, sign in with your email and this password. +

+ setPassword(e.target.value)} + minLength={8} + autoFocus + required + /> + setConfirm(e.target.value)} + minLength={8} + required + /> + {error && ( +

+ {error} +

+ )} + + + ) : ( +

+ This page needs a reset link (…/reset?token=…). Ask an admin for one, or use + “Forgot password?” on the sign-in page. +

+ )} +
+
+ ); +} + +export default function ResetPage() { + // useSearchParams requires a Suspense boundary under the static export. + return ( + + + + ); +} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx new file mode 100644 index 0000000..9326453 --- /dev/null +++ b/frontend/app/users/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/components/AppFrame.tsx b/frontend/components/AppFrame.tsx index 10c99a4..aa8bfa7 100644 --- a/frontend/components/AppFrame.tsx +++ b/frontend/components/AppFrame.tsx @@ -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 ; + return ; } return ( diff --git a/frontend/components/AuthGate.tsx b/frontend/components/AuthGate.tsx new file mode 100644 index 0000000..cc765f7 --- /dev/null +++ b/frontend/components/AuthGate.tsx @@ -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("loading"); + const [status, setStatus] = useState(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("/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("/auth/setup", { email, password })); + } else if (mode === "login") { + onSession(await authApi("/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 ( +
+
+
+ + Claude Monitor +
+

+ Loading… +

+
+
+ ); + } + + 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 ( +
+
+
+ + Claude Monitor +
+

+ {heading} +

+ + {mode !== "token" && ( + setEmail(e.target.value)} + autoFocus + required + /> + )} + {(mode === "login" || mode === "setup") && ( + setPassword(e.target.value)} + minLength={8} + required + /> + )} + {mode === "setup" && ( + setConfirm(e.target.value)} + minLength={8} + required + /> + )} + {mode === "token" && ( + setRawToken(e.target.value)} + autoFocus + /> + )} + + {formError && ( +

+ {formError} +

+ )} + {message && ( +

+ {message} +

+ )} + + + +
+ {mode === "login" && ( + + )} + {(mode === "forgot" || mode === "token") && status?.initialized !== false && ( + + )} + {mode !== "token" && ( + + )} + {mode === "token" && status?.initialized === false && ( + + )} +
+
+
+ ); +} diff --git a/frontend/components/Shell.tsx b/frontend/components/Shell.tsx index d0399d3..2c6b00b 100644 --- a/frontend/components/Shell.tsx +++ b/frontend/components/Shell.tsx @@ -35,6 +35,7 @@ const BADGES: Partial 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 Claude Monitor - {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 })}
+ {s.me && ( +
+ + {s.me.kind === "token" ? "API token" : s.me.email} + {s.me.is_admin ? " · admin" : ""} + +
+ )} -
diff --git a/frontend/components/TokenGate.tsx b/frontend/components/TokenGate.tsx deleted file mode 100644 index 52a18f1..0000000 --- a/frontend/components/TokenGate.tsx +++ /dev/null @@ -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 ( -
-
-
- - Claude Monitor -
-

- Paste your API token to continue. Management actions require the admin token; read-only - views work with the plain auth token. -

- setValue(e.target.value)} - autoFocus - /> - {error && ( -

- {error} -

- )} - -
-
- ); -} diff --git a/frontend/components/sections/UsersSection.tsx b/frontend/components/sections/UsersSection.tsx new file mode 100644 index 0000000..fe57ff2 --- /dev/null +++ b/frontend/components/sections/UsersSection.tsx @@ -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 ( + <> +
+
Users
+
+ Accounts for this Handler. Each user's projects, skills, and tools are + theirs alone; shared (unowned) resources are visible to everyone and managed by + admins. +
+
+
+
+ setEmail(e.target.value)} + style={{ maxWidth: 320 }} + required + /> + + +
+ + {lastLink && ( +
+ + One-shot set-password link for {lastLink.email} (share it over + a channel you trust; it expires): + + + {lastLink.url} + +
+ )} + + {s.users.length === 0 ? ( +
No users loaded (admin access required).
+ ) : ( +
+ + + + + + + + + + + + {s.users.map((u) => ( + + + + + + + + ))} + +
EmailRoleStatusCreatedActions
+ {u.email} + {isSelf(u.id) ? (you) : null} + {u.is_admin ? "admin" : "user"} + {u.disabled + ? "disabled" + : u.has_password + ? "active" + : "invited — awaiting password"} + {fmtFull(u.created_at)} +
+ + + + +
+
+
+ )} +
+ + ); +} diff --git a/frontend/components/store.tsx b/frontend/components/store.tsx index c493202..02018a7 100644 --- a/frontend/components/store.tsx +++ b/frontend/components/store.tsx @@ -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; + updateUser: (id: number, b: { is_admin?: boolean; disabled?: boolean }) => Promise; + deleteUser: (id: number) => Promise; + mintResetLink: (id: number) => Promise; + refresh: () => void; // actions @@ -338,6 +354,8 @@ export function DashboardProvider({ const [claudePlugins, setClaudePlugins] = useState([]); const [claudePermissions, setClaudePermissions] = useState(null); const [claudeModels, setClaudeModels] = useState([]); + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); // 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("/auth/me")); + } catch (e) { + if (!(e instanceof AuthError)) setMe(null); + } + }, []); + + const loadUsers = useCallback(async () => { + try { + setUsers(await clientRef.current.api("/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 => { + try { + const created = await clientRef.current.api("/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 => { + try { + const link = await clientRef.current.api(`/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, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 3a7a006..59a8270 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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 | 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 | 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(path: string, body?: unknown): Promise { + 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 { diff --git a/frontend/lib/nav.ts b/frontend/lib/nav.ts index ec1e137..78c116e 100644 --- a/frontend/lib/nav.ts +++ b/frontend/lib/nav.ts @@ -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