mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-09 07:06:24 +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);
|
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);
|
||||||
|
|||||||
@@ -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
|
* 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 (
|
||||||
|
|||||||
@@ -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.
|
// 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>
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user