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

@ -653,6 +653,19 @@ async def get_status():
except Exception:
pass
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
# and which providers are registered so ``hermes status`` and the
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
auth_providers = [p.name for p in _list_providers()]
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
return {
"version": __version__,
"release_date": __release_date__,
@ -669,6 +682,8 @@ async def get_status():
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
}

View file

@ -0,0 +1,106 @@
"""Phase 7 — /api/status exposes auth-gate state + AuthWidget integration.
The dashboard's status endpoint now reports ``auth_required`` and
``auth_providers`` so the AuthWidget + StatusPage can render the
correct "gated / loopback" badge without a separate round trip. This
test asserts both shapes (gated and loopback).
The AuthWidget itself is .tsx no Python test here. The widget's
behaviour (renders nothing on 401, shows truncated user_id, etc.) is
documented in AuthWidget.tsx; covered manually via the Phase 4.2
smoke test against staging Portal.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# These tests mutate ``web_server.app.state.auth_required`` so they share
# the same xdist group as the other dashboard-auth gated_app tests.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
@pytest.fixture
def gated_client():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def loopback_client():
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 8080
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _login(client: TestClient) -> None:
"""Drive the stub OAuth round trip so the gated client is authed."""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}", follow_redirects=False
)
assert r2.status_code == 302
def test_status_reports_auth_required_in_gated_mode(gated_client):
_login(gated_client)
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is True
assert body["auth_providers"] == ["stub"]
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
r = loopback_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is False
# Loopback mode has no registered providers (the Nous plugin's env
# vars aren't set in test).
assert body["auth_providers"] == []
def test_status_preserves_existing_fields(loopback_client):
"""Defence-in-depth: adding auth_required/auth_providers must not
have dropped any previous field (the dashboard's React StatusPage
relies on the full payload shape)."""
r = loopback_client.get("/api/status")
body = r.json()
expected_keys = {
"version", "release_date", "hermes_home", "config_path", "env_path",
"config_version", "latest_config_version", "gateway_running",
"gateway_pid", "gateway_health_url", "gateway_state",
"gateway_platforms", "gateway_exit_reason", "gateway_updated_at",
"active_sessions", "auth_required", "auth_providers",
}
missing = expected_keys - set(body.keys())
assert not missing, f"/api/status dropped fields: {missing}"

View file

@ -52,6 +52,7 @@ import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
@ -583,6 +584,7 @@ export default function App() {
</div>
</div>
<AuthWidget />
<SidebarFooter />
</aside>

View file

@ -0,0 +1,150 @@
/**
* 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<AuthMeResponse | null>(null);
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div
className={cn(
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
className,
)}
>
{error}
</div>
);
}
if (!me) {
// Loading. Reserve the row height so the sidebar doesn't flicker
// when the data arrives.
return (
<div
className={cn(
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
className,
)}
aria-busy="true"
>
</div>
);
}
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 (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2",
"border-t border-current/10",
"text-[0.65rem] tracking-[0.05em]",
className,
)}
role="status"
aria-label={`Logged in as ${label}`}
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
{label}
</span>
<span className="truncate text-muted-foreground/70">
via {me.provider}
</span>
</div>
<button
type="button"
onClick={handleLogout}
className={cn(
"shrink-0 rounded p-1.5 text-muted-foreground/70",
"transition-colors hover:bg-current/10 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
)}
aria-label="Log out"
title="Log out"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
);
}

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;