/** * @file AuthGate.tsx * @description Server mode's login gate around the whole app (docs/SERVER-MODE.md). * Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local * mode — the default — it renders the app and nothing else changes, at once * when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until * someone logs in, and again whenever a request answers 401 `EAUTH` * (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the * previous user's data. `useAuth()` gives the sidebar the user and `logout`; * `usePermissions()` says what that user's role may do, so pages hide the rest. * Once an account is known (local mode is one), its own UI preferences are * loaded into this browser (`lib/userPrefs.ts`). * The login page follows the OS's light/dark (`applySystemTheme`). * @author Nguyễn Ngọc Trí Vĩ */ import { createContext, useContext, useEffect, useLayoutEffect, useState, type FormEvent, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { Eye, EyeOff } from "lucide-react"; import { BRAND_LOGO } from "../lib/brand"; import { applySystemTheme } from "../hooks/useTheme"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; import { hydrate } from "../lib/userPrefs"; type AuthValue = { state: AuthState; logout: () => Promise }; const AuthContext = createContext({ state: { mode: "local" }, logout: async () => {} }); /** The current login; `{mode: "local"}` outside server mode (and in tests). */ export const useAuth = () => useContext(AuthContext); /** * What the logged-in user may do — the server's rules (server/lib/auth.js, * scope.js) mirrored so the UI hides what would only be refused. Local mode: * everything. `isAdmin`: the host's own setup (Settings → System, updates, * AI tools, proxies, users, alert rules…). `seesAll`: every user's rows * (admin, analyst). `canEditLane`: an admin any workspace, everyone else * only their own — the one that matters is an analyst looking at another * user's. */ export function usePermissions() { const { state } = useAuth(); const user = state.mode === "server" ? state.user : null; const local = state.mode !== "server"; const isAdmin = local || user?.role === "admin"; return { isAdmin, seesAll: local || (!!user && user.role !== "developer"), // A developer only ever sees their own rows, so everything shown is theirs. canEditLane: (lane?: { owner_id?: number | null } | null) => isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id), }; } /** The mode this browser last saw, so a local dashboard paints at once. */ const MODE_KEY = "agenthub.authMode"; const RETRY_MS = 2000; function cachedState(): AuthState | null { try { return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null; } catch { return null; } } export function AuthGate({ children }: { children: ReactNode }) { // null = still asking the server. A browser that last saw local mode starts // there and is corrected by the answer (or by a 401) if that changed. const [state, setState] = useState(cachedState); useEffect(() => { let timer: number | undefined; let alive = true; const ask = () => { api.auth .me() .then((next) => { if (!alive) return; try { localStorage.setItem(MODE_KEY, next.mode); } catch { /* storage blocked */ } setState(next); }) .catch((err: { status?: number; code?: string }) => { if (!alive) return; // An answer that is not this build's: an older server still running // under a freshly built UI (404, or its index.html → EBADRESPONSE), or // DASHBOARD_TOKEN's own 401 — neither is server mode, so render the // app as before and let it show its own state. if ( err?.status === 404 || err?.code === "EBADRESPONSE" || (err?.status === 401 && err?.code !== "EAUTH") ) { setState({ mode: "local" }); return; } // No answer at all (a restart, a proxy's 502): ask again rather than // guess — "local" would hide a server-mode dashboard's login page. timer = window.setTimeout(ask, RETRY_MS); }); }; ask(); // Any request answering 401 EAUTH means server mode with no valid login. const onRequired = () => setState({ mode: "server", user: null }); window.addEventListener(AUTH_REQUIRED_EVENT, onRequired); return () => { alive = false; window.clearTimeout(timer); window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired); }; }, []); // The signed-in account's own preferences (lib/userPrefs.ts): loaded once // per account, so a second login in this browser brings that account's. const account = state?.mode === "local" ? "local" : state?.mode === "server" && state.user ? `u${state.user.id}` : null; useEffect(() => { if (account) void hydrate(); }, [account]); if (!state) return null; if (state.mode === "server" && !state.user) { return setState({ mode: "server", user })} />; } const logout = async () => { await api.auth.logout().catch(() => {}); setState({ mode: "server", user: null }); }; return {children}; } function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Nobody is signed in, so no account's theme applies: follow the OS, live. useLayoutEffect(() => { applySystemTheme(); const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); mq?.addEventListener?.("change", applySystemTheme); return () => mq?.removeEventListener?.("change", applySystemTheme); }, []); const submit = async (e: FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { onLoggedIn((await api.auth.login(username, password)).user); } catch (err) { const code = (err as { code?: string }).code; setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed")); setPassword(""); } finally { setBusy(false); } }; return (
{/* Accent glow and a grid fading out from the top, drawn from the theme's own accent so it holds in every theme. */}
SmartGift

{t("auth.title")}

{t("auth.subtitle")}

setPassword(e.target.value)} />
{error && (

{error}

)} {/* Inverted fill (text colour behind the surface colour): black in a light theme, light in a dark one, like the reference design. */}

{t("auth.noAccount")}

); }