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).
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { getTopic, getVerdict } from '../api';
|
|
import type { TopicDetail, Verdict } from '../types';
|
|
import { fmtTime } from '../util';
|
|
|
|
export function VerdictPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [topic, setTopic] = useState<TopicDetail | null>(null);
|
|
const [verdict, setVerdict] = useState<Verdict | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
const ac = new AbortController();
|
|
setLoading(true);
|
|
Promise.all([getTopic(id, ac.signal), getVerdict(id, ac.signal)])
|
|
.then(([t, v]) => {
|
|
setTopic(t);
|
|
setVerdict(v);
|
|
setErr(null);
|
|
})
|
|
.catch((e: Error) => {
|
|
if (e.name !== 'AbortError') setErr(e.message);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
return () => ac.abort();
|
|
}, [id]);
|
|
|
|
if (!id) return <div className="err">missing topic id</div>;
|
|
if (loading) return <div className="muted">loading…</div>;
|
|
if (err) return <div className="err">{err}</div>;
|
|
if (!topic) return <div className="err">topic not found</div>;
|
|
|
|
return (
|
|
<div className="td">
|
|
<div>
|
|
<span className="eyebrow">verdict permalink</span>
|
|
<h1>{topic.title}</h1>
|
|
<p className="td-summary">{topic.summary}</p>
|
|
<p className="muted" style={{ marginTop: 8 }}>
|
|
schema: <code>{topic.verdict_schema_id}</code>
|
|
{' · '}debate window: {fmtTime(topic.debate_start_at)} → {fmtTime(topic.debate_end_at)}
|
|
{' · '}
|
|
<Link to={`/topics/${topic.id}`}>back to topic</Link>
|
|
</p>
|
|
</div>
|
|
|
|
{!verdict ? (
|
|
<div className="panel td-empty">
|
|
no verdict yet — topic status is <span className={`pill pill-${topic.status}`}>{topic.status}</span>
|
|
{topic.status === 'debating' && ', judge has not submitted'}
|
|
{topic.status === 'cancelled' && ', topic was cancelled before a verdict could be reached'}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="panel">
|
|
<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: {verdict.judge_agent_id}</span>
|
|
<span className="spacer" />
|
|
<span>produced: {fmtTime(verdict.produced_at)}</span>
|
|
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
|
|
<span>tokens: {verdict.tokens_input} in / {verdict.tokens_output} out</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="panel">
|
|
<div className="panel-header">rationale</div>
|
|
<div className="vd-rationale">{verdict.rationale}</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|