Files
Claude 722a2f344c 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
2026-08-12 19:37:08 +00:00

100 lines
3.2 KiB
TypeScript

/* 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>
);
}