mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:36: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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user