All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import * as authApi from "../api/auth";
|
|
import { setTokenProvider, setUnauthorizedHandler } from "../api/client";
|
|
import type { Permission, Role, User } from "../api/types";
|
|
import { AuthContext, type AuthState } from "./AuthContext";
|
|
|
|
const STORAGE_KEY = "gc.token";
|
|
|
|
/**
|
|
* Token storage policy: the source of truth is an in-memory ref (survives
|
|
* re-renders, never serialized into React state to avoid accidental logging).
|
|
* It is mirrored into sessionStorage — NOT localStorage — so it clears when the
|
|
* tab closes and never leaks across browser sessions.
|
|
*/
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const tokenRef = useRef<string | null>(sessionStorage.getItem(STORAGE_KEY));
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [initializing, setInitializing] = useState(true);
|
|
|
|
const setToken = useCallback((token: string | null) => {
|
|
tokenRef.current = token;
|
|
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
|
else sessionStorage.removeItem(STORAGE_KEY);
|
|
}, []);
|
|
|
|
// Wire the API client to read our token and to notify us on 401.
|
|
useEffect(() => {
|
|
setTokenProvider(() => tokenRef.current);
|
|
}, []);
|
|
|
|
const clearSession = useCallback(() => {
|
|
setToken(null);
|
|
setUser(null);
|
|
}, [setToken]);
|
|
|
|
useEffect(() => {
|
|
setUnauthorizedHandler(clearSession);
|
|
return () => setUnauthorizedHandler(null);
|
|
}, [clearSession]);
|
|
|
|
// Restore session on first load if a token is present.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function restore() {
|
|
if (!tokenRef.current) {
|
|
setInitializing(false);
|
|
return;
|
|
}
|
|
try {
|
|
const me = await authApi.getMe();
|
|
if (!cancelled) setUser(me);
|
|
} catch {
|
|
// Invalid/expired token — clear it. The 401 handler also fires, but
|
|
// guard here for non-401 failures too.
|
|
if (!cancelled) clearSession();
|
|
} finally {
|
|
if (!cancelled) setInitializing(false);
|
|
}
|
|
}
|
|
void restore();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [clearSession]);
|
|
|
|
const login = useCallback(
|
|
async (username: string, password: string) => {
|
|
const res = await authApi.login({ username, password });
|
|
setToken(res.token);
|
|
setUser(res.user);
|
|
},
|
|
[setToken],
|
|
);
|
|
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
// Best-effort server-side revocation; clear locally regardless.
|
|
await authApi.logout();
|
|
} catch {
|
|
// ignore — token may already be invalid
|
|
} finally {
|
|
clearSession();
|
|
}
|
|
}, [clearSession]);
|
|
|
|
const value = useMemo<AuthState>(() => {
|
|
const role = user?.role;
|
|
return {
|
|
user,
|
|
initializing,
|
|
login,
|
|
logout,
|
|
isAdmin: role === "admin",
|
|
hasRole: (r: Role) => role === r,
|
|
hasPermission: (p: Permission) => user?.permissions.includes(p) ?? false,
|
|
};
|
|
}, [user, initializing, login, logout]);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|