/** * AuthWidget — sidebar "Logged in as …" affordance for the dashboard * OAuth gate (Phase 7 of .hermes/plans/2026-05-21-dashboard-oauth-auth.md). * * Renders nothing in loopback / --insecure mode. In gated mode, fetches * /api/auth/me on mount and surfaces: * * - the user_id (truncated to 14 chars + ellipsis) since the Nous Portal * contract V1 doesn't emit email/display_name claims (Contract Anchor * C4 in the plan; the API responds with empty strings for those * fields, so we use user_id as the display value) * - the provider's display_name (looked up from /api/auth/providers, * defaults to the bare provider key) * - a logout button that POSTs /auth/logout and full-page-navigates to * /login (the dashboard becomes inaccessible again) * * Failure modes: * - 401 from /api/auth/me means we're not gated (or the gate is on but * we have no cookie — in that case the gate's middleware would have * redirected us before App.tsx renders, so we won't see this). The * widget renders nothing. * - Network error: shows a minimal "auth status unavailable" message * so the user knows the widget tried. */ import { useEffect, useState } from "react"; import { api, type AuthMeResponse } from "@/lib/api"; import { cn } from "@/lib/utils"; import { LogOut } from "lucide-react"; interface AuthWidgetProps { className?: string; } /** Truncate ``user_id`` to fit a small UI without revealing the full * opaque identifier. 14 chars is enough to disambiguate users in a * small org and short enough to fit a single sidebar row. */ function truncateUserId(id: string): string { if (id.length <= 14) return id; return `${id.slice(0, 14)}…`; } export function AuthWidget({ className }: AuthWidgetProps) { const [me, setMe] = useState(null); const [hidden, setHidden] = useState(false); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; api .getAuthMe() .then((data) => { if (cancelled) return; setMe(data); }) .catch((err: unknown) => { if (cancelled) return; // 401 from /api/auth/me means the gate isn't engaged in this // process (loopback mode) — render nothing. fetchJSON throws an // Error with the status code as a prefix; the global 401 // handler only redirects on the structured envelope, so a plain // 401 from /api/auth/me with no envelope bubbles up here. const msg = err instanceof Error ? err.message : String(err); if (msg.startsWith("401:") || msg.startsWith("403:")) { setHidden(true); return; } setError("auth status unavailable"); }); return () => { cancelled = true; }; }, []); if (hidden) return null; if (error) { return (
{error}
); } if (!me) { // Loading. Reserve the row height so the sidebar doesn't flicker // when the data arrives. return (
); } const handleLogout = () => { void api.logout(); }; // Prefer display_name → email → truncated user_id. Contract V1 only // populates user_id; the fallthroughs are forward-compat for a future // Portal that adds a userinfo endpoint (OQ-C1 in the plan). const label = me.display_name || me.email || truncateUserId(me.user_id); return (
{label} via {me.provider}
); }