Files
Dialectic.Frontend/src/auth.tsx
hzhang d1d2ae2fb1 feat(auth): real OIDC login + remove agents page
Replaces the dev-bypass-only AuthProvider with a real backend-mediated
OIDC client. Backend ships the OIDC plumbing in
Dialectic.Backend@2463129; this SPA drives it.

Auth flow:
  1. On mount: GET /api/auth/oidc/status (is OIDC configured?) + GET
     /api/auth/me (who am I — reads the dialectic_session HttpOnly
     cookie if present).
  2. Anon + click 'login' → window.location = /api/auth/oidc/start
     (backend redirects to IdP authorize URL with PKCE + state).
  3. IdP returns to backend /api/auth/oidc/callback → backend redirects
     here to /oidc/callback#oidc_ticket=<base64url>.
  4. OidcCallbackPage POSTs the ticket to /api/auth/oidc/exchange →
     backend Set-Cookie's the session JWT → refresh() AuthProvider →
     navigate to /.
  5. Header shows user.name + 'logout' button. Logout → POST
     /api/auth/logout (cookie cleared by backend) → user state cleared.

UI changes:
  - Header gets a Login button (acid primary style) when anon + OIDC
    enabled; logged-in pill (name + logout) when authenticated.
  - 'oidc not configured' message when backend reports
    /api/auth/oidc/status enabled=false (operator needs to run
    dialectic-cli config oidc first).
  - Removed /agents/:id route + AgentActivity page + nav link entirely
    (per user request; admin endpoint stays on backend for curl).
  - Removed Link to /agents/* from TopicDetail + Verdict pages.

api.ts:
  - Adds credentials:'same-origin' on every fetch so the session cookie
    rides along.
  - Drops the admin: opt + x-dialectic-admin-key header path (no more
    AgentActivity consumer).
  - Keeps dev-bypass header support gated on VITE_OIDC_DEV_BYPASS for
    dev convenience — backend's OIDC_ONLY=true env disables it server-
    side anyway.

types.ts: AgentSummary type removed.

Build delta: 178KB → 179KB / gzip 57.12 → 57.70 (oidc-client overhead
is just the AuthProvider polling code; no oidc-client-ts lib needed
since the backend drives the redirect flow).
2026-05-24 01:43:06 +01:00

113 lines
2.9 KiB
TypeScript

// AuthProvider — backend-mediated OIDC login.
//
// Flow:
// 1. On mount, GET /api/auth/oidc/status to learn if OIDC is configured.
// 2. GET /api/auth/me — if session cookie exists + valid → set user.
// 3. If anon + click Login → window.location = /api/auth/oidc/start
// 4. Backend 302's to IdP → IdP 302's back to /api/auth/oidc/callback
// → backend 302's to /oidc/callback#oidc_ticket=... on this SPA.
// 5. <OidcCallbackPage> reads the fragment, POST /api/auth/oidc/exchange,
// backend sets the dialectic_session cookie, redirects to /.
// 6. Logout: POST /api/auth/logout (clears cookie) → useAuth refetches.
//
// The session JWT lives only in an HttpOnly cookie; the SPA never sees
// the raw token. /api/auth/me is the canonical "who am I" probe.
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
interface User {
id: string;
email: string;
name: string;
}
interface AuthCtx {
user: User | null;
loading: boolean;
oidcEnabled: boolean;
login: () => void;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const Ctx = createContext<AuthCtx>({
user: null,
loading: true,
oidcEnabled: false,
login: () => {},
logout: async () => {},
refresh: async () => {},
});
const API_BASE = import.meta.env.VITE_DIALECTIC_API_BASE ?? '/api';
async function fetchStatus(): Promise<boolean> {
try {
const r = await fetch(`${API_BASE}/auth/oidc/status`);
if (!r.ok) return false;
const j = await r.json();
return Boolean(j.enabled);
} catch {
return false;
}
}
async function fetchMe(): Promise<User | null> {
try {
const r = await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin' });
if (r.status === 401) return null;
if (!r.ok) return null;
return (await r.json()) as User;
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [oidcEnabled, setOidcEnabled] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
const [enabled, me] = await Promise.all([fetchStatus(), fetchMe()]);
setOidcEnabled(enabled);
setUser(me);
setLoading(false);
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const login = useCallback(() => {
// Hard navigation — backend builds the IdP URL + redirects.
window.location.href = `${API_BASE}/auth/oidc/start`;
}, []);
const logout = useCallback(async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'same-origin',
});
setUser(null);
}, []);
return (
<Ctx.Provider value={{ user, loading, oidcEnabled, login, logout, refresh }}>
{children}
</Ctx.Provider>
);
}
export function useAuth(): AuthCtx {
return useContext(Ctx);
}