diff --git a/src/App.tsx b/src/App.tsx
index b1025a0..4f56a5f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
- {devBypass && (
-
- DEV BYPASS ACTIVE — auto-attaching x-dev-bypass on
- every request; do not run this build in production
-
- )}
} />
} />
} />
- } />
+ } />
} />
diff --git a/src/api.ts b/src/api.ts
index 6c66558..e741fc1 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -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: ` 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(
method: string,
path: string,
- opts: { body?: unknown; admin?: boolean; signal?: AbortSignal } = {},
+ opts: { body?: unknown; signal?: AbortSignal } = {},
): Promise {
const headers: Record = {};
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(
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 {
- const t = await request('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('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 {
@@ -126,19 +119,4 @@ export function getVerdict(
});
}
-// --------------------------------------------------------------------
-// Admin
-
-export async function getAgentSummary(
- agentId: string,
- signal?: AbortSignal,
-): Promise {
- const r = await request(
- 'GET',
- `/admin/agents/${encodeURIComponent(agentId)}`,
- { admin: true, signal },
- );
- return { ...r, recent_topics: r.recent_topics ?? [] };
-}
-
export { HttpError };
diff --git a/src/auth.tsx b/src/auth.tsx
index bc95c8d..554736b 100644
--- a/src/auth.tsx
+++ b/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. 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;
+ refresh: () => Promise;
}
const Ctx = createContext({
- 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 {
+ 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 {
+ 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 {children};
+ const [user, setUser] = useState(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 (
+
+ {children}
+
+ );
}
export function useAuth(): AuthCtx {
diff --git a/src/pages/AgentActivity.tsx b/src/pages/AgentActivity.tsx
deleted file mode 100644
index 18dcf91..0000000
--- a/src/pages/AgentActivity.tsx
+++ /dev/null
@@ -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(null);
- const [err, setErr] = useState(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 missing agent id
;
-
- return (
-
-
agent
-
{id}
-
- {needsKey && (
-
-
- this page calls an admin endpoint — paste your
- x-dialectic-admin-key (stored in localStorage; never
- sent except to /api/admin/*).
-
-
setPendingKey(e.target.value)}
- placeholder="admin key…"
- />
-
-
- key never leaves this browser
-
-
- )}
-
- {err &&
{err}
}
-
- {data && (
- <>
-
-
-
- {data.key_provisioned ? 'yes' : 'no'}
-
-
key provisioned
-
-
-
{data.signups_count}
-
signups
-
-
-
{data.arguments_count}
-
arguments
-
-
-
{data.verdicts_count}
-
verdicts
-
-
-
-
recent topics
- {data.recent_topics.length === 0 ? (
-
no recent topics for this agent
- ) : (
-
-
-
- | topic |
- role |
- status |
- last action |
-
-
-
- {data.recent_topics.map((t) => (
-
- |
- {t.title}
- |
-
- {t.role}
- |
-
- {t.status}
- |
-
- {fmtRelative(t.last_action_at)}
- |
-
- ))}
-
-
- )}
- >
- )}
-
- );
-}
diff --git a/src/pages/OidcCallback.tsx b/src/pages/OidcCallback.tsx
new file mode 100644
index 0000000..1156eda
--- /dev/null
+++ b/src/pages/OidcCallback.tsx
@@ -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= on success, OR
+// #oidc_error= 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(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 (
+
+ {err ? (
+ <>
+
login failed
+
+ {err}
+
+
+ back to topics
+
+ >
+ ) : (
+ <>
+
authenticating
+
signing in…
+
exchanging one-time ticket for session cookie
+ >
+ )}
+
+ );
+}
diff --git a/src/pages/TopicDetail.tsx b/src/pages/TopicDetail.tsx
index 8a274a4..7f3b682 100644
--- a/src/pages/TopicDetail.tsx
+++ b/src/pages/TopicDetail.tsx
@@ -111,31 +111,19 @@ export function TopicDetailPage() {
pro
- {proCamp ? (
- {proCamp.agent_id}
- ) : (
- —
- )}
+ {proCamp ? proCamp.agent_id : —}
con
- {conCamp ? (
- {conCamp.agent_id}
- ) : (
- —
- )}
+ {conCamp ? conCamp.agent_id : —}
judge
- {judgeCamp ? (
- {judgeCamp.agent_id}
- ) : (
- —
- )}
+ {judgeCamp ? judgeCamp.agent_id : —}
@@ -157,9 +145,7 @@ export function TopicDetailPage() {
{a.camp.toUpperCase()}
-
- {a.agent_id}
-
+
{a.agent_id}
{fmtRelative(a.posted_at)}
diff --git a/src/pages/Verdict.tsx b/src/pages/Verdict.tsx
index ffb5f62..fae57a6 100644
--- a/src/pages/Verdict.tsx
+++ b/src/pages/Verdict.tsx
@@ -59,7 +59,7 @@ export function VerdictPage() {
verdict (structured)
{JSON.stringify(verdict.verdict, null, 2)}
- judge: {verdict.judge_agent_id}
+ judge: {verdict.judge_agent_id}
produced: {fmtTime(verdict.produced_at)}
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
diff --git a/src/styles/app.css b/src/styles/app.css
index ddff83c..5aa4e1b 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -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);
diff --git a/src/types.ts b/src/types.ts
index 6355153..4715167 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
- }>;
-}