feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s

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>
This commit is contained in:
2026-05-30 12:51:11 -07:00
parent f9bdecbfdb
commit 43a9432b81
66 changed files with 7777 additions and 995 deletions

View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { Permission, Role, User } from "../api/types";
export interface AuthState {
user: User | null;
/** True while restoring a session from a stored token on first load. */
initializing: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAdmin: boolean;
hasRole: (role: Role) => boolean;
hasPermission: (perm: Permission) => boolean;
}
export const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}

View File

@@ -0,0 +1,100 @@
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>;
}

View File

@@ -0,0 +1,27 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Spinner } from "../components/ui/Spinner";
import { useAuth } from "./AuthContext";
/**
* Gate for authenticated routes. While restoring a session from a stored token
* we show a spinner (avoids a login-flash on reload). No user -> /login,
* preserving the attempted location for post-login return.
*/
export function ProtectedRoute() {
const { user, initializing } = useAuth();
const location = useLocation();
if (initializing) {
return (
<div className="auth-gate">
<Spinner label="Restoring session" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}