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).
This commit is contained in:
113
src/auth.tsx
113
src/auth.tsx
@@ -1,31 +1,110 @@
|
||||
// AuthProvider — minimal v1: dev-bypass only, no OIDC redirect yet.
|
||||
// AuthProvider — backend-mediated OIDC login.
|
||||
//
|
||||
// Real OIDC + Keycloak ships when the backend's OIDC middleware is
|
||||
// fully wired (Phase 4 in DIALECTIC-V2-DESIGN.md). Until then this
|
||||
// just surfaces whether dev-bypass is on so the UI can show a banner
|
||||
// ("dev mode — running as operator") and route admin-gated pages.
|
||||
// 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, useContext, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuthCtx {
|
||||
// Whether dev-bypass token is configured at build time.
|
||||
devBypass: boolean;
|
||||
// Display label for the "current user" — in dev-bypass mode this is
|
||||
// the literal env value (a placeholder until real OIDC).
|
||||
user: { id: string; label: string } | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
oidcEnabled: boolean;
|
||||
login: () => void;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthCtx>({
|
||||
devBypass: false,
|
||||
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 devBypass = Boolean(import.meta.env.VITE_OIDC_DEV_BYPASS);
|
||||
const user = devBypass
|
||||
? { id: 'dev-operator', label: 'dev-operator (bypass)' }
|
||||
: null;
|
||||
return <Ctx.Provider value={{ devBypass, user }}>{children}</Ctx.Provider>;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user