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:
h z
2026-05-24 01:43:06 +01:00
parent 7dca2c3110
commit d1d2ae2fb1
9 changed files with 248 additions and 232 deletions

View File

@@ -3,12 +3,12 @@ import { useAuth } from './auth';
import { TopicListPage } from './pages/TopicList';
import { TopicDetailPage } from './pages/TopicDetail';
import { VerdictPage } from './pages/Verdict';
import { AgentActivityPage } from './pages/AgentActivity';
import { NotFoundPage } from './pages/NotFound';
import { OidcCallbackPage } from './pages/OidcCallback';
import './styles/app.css';
export function App() {
const { user, devBypass } = useAuth();
const { user, loading, oidcEnabled, login, logout } = useAuth();
return (
<div className="app">
<header className="app-header">
@@ -20,29 +20,37 @@ export function App() {
<NavLink to="/" end>
topics
</NavLink>
<NavLink to="/agents/recruiter">agents</NavLink>
</nav>
<div className="app-user">
{user ? (
<span className="eyebrow">{user.label}</span>
{loading ? (
<span className="eyebrow">loading</span>
) : user ? (
<span className="row" style={{ gap: 12 }}>
<span className="eyebrow" title={user.email}>
{user.name}
</span>
<button onClick={logout} className="auth-btn">
logout
</button>
</span>
) : oidcEnabled ? (
<button onClick={login} className="auth-btn auth-btn-primary">
login
</button>
) : (
<span className="eyebrow">readonly</span>
<span className="eyebrow" title="OIDC not configured — set up via dialectic-cli">
anonymous · oidc not configured
</span>
)}
</div>
</div>
{devBypass && (
<div className="app-dev-banner">
DEV BYPASS ACTIVE auto-attaching <code>x-dev-bypass</code> on
every request; do not run this build in production
</div>
)}
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<TopicListPage />} />
<Route path="/topics/:id" element={<TopicDetailPage />} />
<Route path="/topics/:id/verdict" element={<VerdictPage />} />
<Route path="/agents/:id" element={<AgentActivityPage />} />
<Route path="/oidc/callback" element={<OidcCallbackPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>

View File

@@ -1,22 +1,20 @@
// Thin fetch wrapper for the Dialectic backend.
//
// Auth: this SPA targets human operators / observers. v1 auth model
// is dev-bypass only — `x-dev-bypass: <token>` is auto-attached when
// VITE_OIDC_DEV_BYPASS is set at build time. Real OIDC + Keycloak
// redirect ships as v2; dev-bypass covers the entire MVP scope.
// Auth model:
// - Anonymous: works for public topics (backend's optional-auth path).
// - OIDC: backend sets dialectic_session HttpOnly cookie on
// /api/auth/oidc/exchange; we just pass credentials:'same-origin'
// on every call so the cookie rides along.
// - Dev bypass: still supported in dev mode when VITE_OIDC_DEV_BYPASS
// is set at build time AND backend's OIDC_ONLY env is not 'true'.
// The header is auto-attached. Prod images should NOT set this env.
//
// Backend base: configurable via VITE_DIALECTIC_API_BASE (default
// '/api', which works both behind the vite dev proxy and behind nginx
// in prod).
//
// Admin endpoints (under /api/admin/*) need a separate header
// `x-dialectic-admin-key`. The frontend reads it from
// localStorage['dialectic-admin-key'] (set via a one-liner in the
// AgentActivity page) so it never gets baked into the bundle.
import type {
Argument,
AgentSummary,
Topic,
TopicDetail,
TopicStatus,
@@ -36,14 +34,10 @@ class HttpError extends Error {
async function request<T>(
method: string,
path: string,
opts: { body?: unknown; admin?: boolean; signal?: AbortSignal } = {},
opts: { body?: unknown; signal?: AbortSignal } = {},
): Promise<T> {
const headers: Record<string, string> = {};
if (DEV_BYPASS) headers['x-dev-bypass'] = DEV_BYPASS;
if (opts.admin) {
const adminKey = localStorage.getItem('dialectic-admin-key') ?? '';
if (adminKey) headers['x-dialectic-admin-key'] = adminKey;
}
if (opts.body !== undefined) headers['content-type'] = 'application/json';
const res = await fetch(`${API_BASE}${path}`, {
@@ -51,6 +45,9 @@ async function request<T>(
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
// OIDC session cookie rides on every request. Same-origin since
// the SPA + /api/ are reverse-proxied through the same nginx vhost.
credentials: 'same-origin',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
@@ -94,11 +91,7 @@ export async function getTopic(
id: string,
signal?: AbortSignal,
): Promise<TopicDetail> {
const t = await request<TopicDetail>('GET', `/topics/${encodeURIComponent(id)}`, { signal });
// camps is nullable pre-allocation; keep null vs [] meaningful — null
// means "not yet allocated" (UI shows '—'), [] would mean "allocated
// but no rows" (impossible by allocator contract, but distinct).
return t;
return request<TopicDetail>('GET', `/topics/${encodeURIComponent(id)}`, { signal });
}
export async function listArguments(
@@ -113,7 +106,7 @@ export async function listArguments(
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
}
export function getVerdict(
export async function getVerdict(
topicId: string,
signal?: AbortSignal,
): Promise<Verdict | null> {
@@ -126,19 +119,4 @@ export function getVerdict(
});
}
// --------------------------------------------------------------------
// Admin
export async function getAgentSummary(
agentId: string,
signal?: AbortSignal,
): Promise<AgentSummary> {
const r = await request<AgentSummary & { recent_topics: AgentSummary['recent_topics'] | null }>(
'GET',
`/admin/agents/${encodeURIComponent(agentId)}`,
{ admin: true, signal },
);
return { ...r, recent_topics: r.recent_topics ?? [] };
}
export { HttpError };

View File

@@ -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 {

View File

@@ -1,132 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getAgentSummary, HttpError } from '../api';
import type { AgentSummary } from '../types';
import { Link } from 'react-router-dom';
import { fmtRelative, fmtTime } from '../util';
export function AgentActivityPage() {
const { id } = useParams<{ id: string }>();
const [data, setData] = useState<AgentSummary | null>(null);
const [err, setErr] = useState<string | null>(null);
const [needsKey, setNeedsKey] = useState(false);
const [pendingKey, setPendingKey] = useState('');
useEffect(() => {
if (!id) return;
const ac = new AbortController();
getAgentSummary(id, ac.signal)
.then((d) => {
setData(d);
setErr(null);
setNeedsKey(false);
})
.catch((e) => {
if ((e as Error).name === 'AbortError') return;
if (e instanceof HttpError && (e.status === 401 || e.status === 403)) {
setNeedsKey(true);
setErr(null);
} else {
setErr((e as Error).message);
}
});
return () => ac.abort();
}, [id]);
function saveKey() {
if (!pendingKey.trim()) return;
localStorage.setItem('dialectic-admin-key', pendingKey.trim());
setPendingKey('');
window.location.reload();
}
if (!id) return <div className="err">missing agent id</div>;
return (
<div>
<span className="eyebrow">agent</span>
<h1>{id}</h1>
{needsKey && (
<div className="aa-admin-prompt">
<div>
this page calls an <strong>admin endpoint</strong> paste your
<code> x-dialectic-admin-key</code> (stored in localStorage; never
sent except to <code>/api/admin/*</code>).
</div>
<input
type="password"
value={pendingKey}
onChange={(e) => setPendingKey(e.target.value)}
placeholder="admin key…"
/>
<div className="row" style={{ marginTop: 8 }}>
<button onClick={saveKey}>save + reload</button>
<span className="muted">key never leaves this browser</span>
</div>
</div>
)}
{err && <div className="err">{err}</div>}
{data && (
<>
<div className="aa-summary-grid">
<div className="aa-card">
<div className="aa-card-value">
{data.key_provisioned ? 'yes' : 'no'}
</div>
<div className="aa-card-label">key provisioned</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.signups_count}</div>
<div className="aa-card-label">signups</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.arguments_count}</div>
<div className="aa-card-label">arguments</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.verdicts_count}</div>
<div className="aa-card-label">verdicts</div>
</div>
</div>
<h2>recent topics</h2>
{data.recent_topics.length === 0 ? (
<div className="td-empty">no recent topics for this agent</div>
) : (
<table className="tl-table">
<thead>
<tr>
<th>topic</th>
<th>role</th>
<th>status</th>
<th>last action</th>
</tr>
</thead>
<tbody>
{data.recent_topics.map((t) => (
<tr key={t.topic_id}>
<td>
<Link to={`/topics/${t.topic_id}`}>{t.title}</Link>
</td>
<td>
<span className={`camp-${t.role}`}>{t.role}</span>
</td>
<td>
<span className={`pill pill-${t.status}`}>{t.status}</span>
</td>
<td title={fmtTime(t.last_action_at)}>
{fmtRelative(t.last_action_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
// /oidc/callback — exchange the one-time ticket for a session cookie.
//
// Backend's /api/auth/oidc/callback 302s here with
// #oidc_ticket=<base64url> on success, OR
// #oidc_error=<message> on failure (state expired, IdP rejected, etc)
//
// We POST the ticket to /api/auth/oidc/exchange, the backend Set-Cookie's
// the session JWT, then we refresh AuthProvider + navigate to '/'.
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth';
const API_BASE = import.meta.env.VITE_DIALECTIC_API_BASE ?? '/api';
export function OidcCallbackPage() {
const navigate = useNavigate();
const { refresh } = useAuth();
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
// Parse the URL fragment.
const frag = window.location.hash.replace(/^#/, '');
const params = new URLSearchParams(frag);
const ticket = params.get('oidc_ticket');
const oidcErr = params.get('oidc_error');
if (oidcErr) {
setErr(oidcErr);
return;
}
if (!ticket) {
setErr('no ticket in callback URL');
return;
}
let cancelled = false;
(async () => {
try {
const r = await fetch(`${API_BASE}/auth/oidc/exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ ticket }),
});
if (!r.ok) {
const text = await r.text();
throw new Error(`HTTP ${r.status}: ${text.slice(0, 200)}`);
}
if (cancelled) return;
// Refresh AuthProvider so the header shows the new user.
await refresh();
// Strip the fragment + bounce to root.
navigate('/', { replace: true });
} catch (e) {
if (!cancelled) setErr((e as Error).message);
}
})();
return () => {
cancelled = true;
};
}, [navigate, refresh]);
return (
<div className="td-empty">
{err ? (
<>
<h1 style={{ marginBottom: 16 }}>login failed</h1>
<p className="err" style={{ maxWidth: 600, margin: '0 auto' }}>
{err}
</p>
<p className="muted" style={{ marginTop: 16 }}>
<a href="/">back to topics</a>
</p>
</>
) : (
<>
<span className="eyebrow">authenticating</span>
<h1 style={{ marginBottom: 16 }}>signing in</h1>
<p className="muted">exchanging one-time ticket for session cookie</p>
</>
)}
</div>
);
}

View File

@@ -111,31 +111,19 @@ export function TopicDetailPage() {
<div className="td-camp">
<div className="td-camp-label camp-pro">pro</div>
<div className="td-camp-agent">
{proCamp ? (
<Link to={`/agents/${proCamp.agent_id}`}>{proCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
{proCamp ? proCamp.agent_id : <span className="muted"></span>}
</div>
</div>
<div className="td-camp">
<div className="td-camp-label camp-con">con</div>
<div className="td-camp-agent">
{conCamp ? (
<Link to={`/agents/${conCamp.agent_id}`}>{conCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
{conCamp ? conCamp.agent_id : <span className="muted"></span>}
</div>
</div>
<div className="td-camp">
<div className="td-camp-label camp-judge">judge</div>
<div className="td-camp-agent">
{judgeCamp ? (
<Link to={`/agents/${judgeCamp.agent_id}`}>{judgeCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
{judgeCamp ? judgeCamp.agent_id : <span className="muted"></span>}
</div>
</div>
</div>
@@ -157,9 +145,7 @@ export function TopicDetailPage() {
<div key={a.id} className={`td-arg td-arg-${a.camp}`}>
<div className="td-arg-meta row">
<span className={`camp-${a.camp}`}>{a.camp.toUpperCase()}</span>
<Link to={`/agents/${a.agent_id}`} className="muted">
{a.agent_id}
</Link>
<span className="muted">{a.agent_id}</span>
<span className="spacer" />
<span className="muted" title={fmtTime(a.posted_at)}>
{fmtRelative(a.posted_at)}

View File

@@ -59,7 +59,7 @@ export function VerdictPage() {
<div className="panel-header">verdict (structured)</div>
<pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
<div className="row muted" style={{ marginTop: 8, fontSize: '0.85em' }}>
<span>judge: <Link to={`/agents/${verdict.judge_agent_id}`}>{verdict.judge_agent_id}</Link></span>
<span>judge: {verdict.judge_agent_id}</span>
<span className="spacer" />
<span>produced: {fmtTime(verdict.produced_at)}</span>
{(verdict.tokens_input + verdict.tokens_output) > 0 && (

View File

@@ -66,6 +66,33 @@
.app-user {
margin-left: auto;
}
.auth-btn {
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 4px 12px;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
border-radius: var(--radius);
cursor: pointer;
transition: all 120ms ease;
}
.auth-btn:hover {
border-color: var(--acid);
color: var(--acid);
background: var(--acid-wash);
}
.auth-btn-primary {
background: var(--acid);
color: var(--on-acid);
border-color: var(--acid);
}
.auth-btn-primary:hover {
background: var(--acid-dim);
color: var(--on-acid);
border-color: var(--acid-dim);
}
.app-dev-banner {
background: rgba(255, 90, 82, 0.1);
color: var(--danger);

View File

@@ -68,18 +68,3 @@ export interface Verdict {
produced_at: string;
}
// Admin endpoint (next commit) shape — declare now so frontend compiles.
export interface AgentSummary {
agent_id: string;
key_provisioned: boolean;
signups_count: number;
arguments_count: number;
verdicts_count: number;
recent_topics: Array<{
topic_id: string;
title: string;
status: TopicStatus;
role: Camp | 'volunteer';
last_action_at: string;
}>;
}