mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-29 19:21:40 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user