feat(dashboard-auth): Phase 7 — SPA AuthWidget + /api/status auth fields

Phase 7 surfaces the OAuth gate state to users.

web/src/components/AuthWidget.tsx (new):
  Sidebar widget that fetches /api/auth/me on mount and renders a
  compact 'Logged in as <user_id…> via <provider>' row with a logout
  icon. Contract V1 (Nous Portal) emits no email/display_name claims,
  so user_id is the display value (truncated to 14 chars + ellipsis);
  display_name and email fallthroughs are forward-compat for OQ-C1.
  Renders nothing on 401 from /api/auth/me — that's the signal the
  gate isn't engaged (loopback mode), in which case the widget would
  be confusing.
  Logout POSTs /auth/logout (which clears cookies + redirects to
  /login) then full-page-navigates to /login itself; the SPA's fetch
  wrapper doesn't follow that redirect, so the navigation is explicit.

web/src/App.tsx: mounts <AuthWidget /> above <SidebarFooter />.
  Component is self-hiding in loopback mode so there's no need for a
  conditional mount.

web/src/lib/api.ts:
  - getAuthMe() + logout() helpers
  - AuthMeResponse type
  - StatusResponse gets optional auth_required + auth_providers fields
    so the existing StatusPage can render a gated/loopback badge.

hermes_cli/web_server.py: /api/status payload now includes
  - auth_required: bool — whether app.state.auth_required is True
  - auth_providers: list[str] — registered DashboardAuthProvider names
  Lazy-imports list_providers so early-startup status calls don't
  crash if the dashboard_auth module is still being set up.

tests/hermes_cli/test_dashboard_auth_status_endpoint.py: 3 new tests
covering the new status fields in both gated and loopback modes plus
a regression that no existing field got dropped from the payload.

The hermes status CLI is unchanged in this commit — that command
tracks model providers + OAuth credentials, not running-dashboard
state. The /api/status endpoint is the canonical place to query
dashboard auth-gate state, consumed by the React StatusPage already.
This commit is contained in:
Ben 2026-05-21 17:00:08 +10:00 committed by Teknium
parent 5e9308b5b8
commit 2fc4615fc4
5 changed files with 319 additions and 0 deletions

View file

@ -153,6 +153,27 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
* Identity probe for the dashboard auth gate (Phase 7).
*
* Returns the verified Session as JSON when gated mode is active and a
* valid cookie is attached. Loopback mode is unaffected the endpoint
* still exists but is never useful there (no Session, no cookie). The
* AuthWidget component swallows 401s from this call: if the gate isn't
* engaged, /api/auth/me returns 401 and the widget renders nothing.
*/
getAuthMe: () => fetchJSON<AuthMeResponse>("/api/auth/me"),
logout: () =>
fetch(`${BASE}/auth/logout`, {
method: "POST",
credentials: "include",
}).then((r) => {
// /auth/logout returns 302 → /login. Follow that with a full-page
// navigation rather than letting fetch() opaquely consume the
// redirect — the SPA needs to leave the protected area.
window.location.assign("/login");
return r;
}),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
@ -433,6 +454,23 @@ export const api = {
}),
};
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
*
* Returned by the dashboard's gated middleware when a valid session cookie
* is attached. ``email`` and ``display_name`` are empty strings under the
* Nous Portal contract V1 (the access token has no email/name claims
* see Contract Anchor C4 in the plan). The AuthWidget surfaces a
* truncated ``user_id`` instead.
*/
export interface AuthMeResponse {
user_id: string;
email: string;
display_name: string;
org_id: string;
provider: string;
expires_at: number;
}
export interface ActionResponse {
name: string;
ok: boolean;
@ -456,6 +494,14 @@ export interface PlatformStatus {
export interface StatusResponse {
active_sessions: number;
/** Phase 7: ``true`` when the dashboard's OAuth gate is engaged
* (public bind, no ``--insecure``). Read alongside ``auth_providers``
* to render a "gated / loopback" badge. */
auth_required?: boolean;
/** Phase 7: registered ``DashboardAuthProvider`` names (e.g. ``["nous"]``).
* Empty in loopback mode; empty + ``auth_required=true`` is a
* fail-closed state (the dashboard will refuse to bind). */
auth_providers?: string[];
config_path: string;
config_version: number;
env_path: string;