Compare commits
2 Commits
7dca2c3110
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ad425ffc3 | |||
| d1d2ae2fb1 |
34
src/App.tsx
34
src/App.tsx
@@ -3,12 +3,12 @@ import { useAuth } from './auth';
|
|||||||
import { TopicListPage } from './pages/TopicList';
|
import { TopicListPage } from './pages/TopicList';
|
||||||
import { TopicDetailPage } from './pages/TopicDetail';
|
import { TopicDetailPage } from './pages/TopicDetail';
|
||||||
import { VerdictPage } from './pages/Verdict';
|
import { VerdictPage } from './pages/Verdict';
|
||||||
import { AgentActivityPage } from './pages/AgentActivity';
|
|
||||||
import { NotFoundPage } from './pages/NotFound';
|
import { NotFoundPage } from './pages/NotFound';
|
||||||
|
import { OidcCallbackPage } from './pages/OidcCallback';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { user, devBypass } = useAuth();
|
const { user, loading, oidcEnabled, login, logout } = useAuth();
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
@@ -20,29 +20,37 @@ export function App() {
|
|||||||
<NavLink to="/" end>
|
<NavLink to="/" end>
|
||||||
topics
|
topics
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/agents/recruiter">agents</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="app-user">
|
<div className="app-user">
|
||||||
{user ? (
|
{loading ? (
|
||||||
<span className="eyebrow">{user.label}</span>
|
<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>
|
||||||
</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>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<TopicListPage />} />
|
<Route path="/" element={<TopicListPage />} />
|
||||||
<Route path="/topics/:id" element={<TopicDetailPage />} />
|
<Route path="/topics/:id" element={<TopicDetailPage />} />
|
||||||
<Route path="/topics/:id/verdict" element={<VerdictPage />} />
|
<Route path="/topics/:id/verdict" element={<VerdictPage />} />
|
||||||
<Route path="/agents/:id" element={<AgentActivityPage />} />
|
<Route path="/oidc/callback" element={<OidcCallbackPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
50
src/api.ts
50
src/api.ts
@@ -1,22 +1,20 @@
|
|||||||
// Thin fetch wrapper for the Dialectic backend.
|
// Thin fetch wrapper for the Dialectic backend.
|
||||||
//
|
//
|
||||||
// Auth: this SPA targets human operators / observers. v1 auth model
|
// Auth model:
|
||||||
// is dev-bypass only — `x-dev-bypass: <token>` is auto-attached when
|
// - Anonymous: works for public topics (backend's optional-auth path).
|
||||||
// VITE_OIDC_DEV_BYPASS is set at build time. Real OIDC + Keycloak
|
// - OIDC: backend sets dialectic_session HttpOnly cookie on
|
||||||
// redirect ships as v2; dev-bypass covers the entire MVP scope.
|
// /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
|
// Backend base: configurable via VITE_DIALECTIC_API_BASE (default
|
||||||
// '/api', which works both behind the vite dev proxy and behind nginx
|
// '/api', which works both behind the vite dev proxy and behind nginx
|
||||||
// in prod).
|
// 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 {
|
import type {
|
||||||
Argument,
|
Argument,
|
||||||
AgentSummary,
|
|
||||||
Topic,
|
Topic,
|
||||||
TopicDetail,
|
TopicDetail,
|
||||||
TopicStatus,
|
TopicStatus,
|
||||||
@@ -36,14 +34,10 @@ class HttpError extends Error {
|
|||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
opts: { body?: unknown; admin?: boolean; signal?: AbortSignal } = {},
|
opts: { body?: unknown; signal?: AbortSignal } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (DEV_BYPASS) headers['x-dev-bypass'] = DEV_BYPASS;
|
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';
|
if (opts.body !== undefined) headers['content-type'] = 'application/json';
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
@@ -51,6 +45,9 @@ async function request<T>(
|
|||||||
headers,
|
headers,
|
||||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
signal: opts.signal,
|
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) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
@@ -94,11 +91,7 @@ export async function getTopic(
|
|||||||
id: string,
|
id: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<TopicDetail> {
|
): Promise<TopicDetail> {
|
||||||
const t = await request<TopicDetail>('GET', `/topics/${encodeURIComponent(id)}`, { signal });
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listArguments(
|
export async function listArguments(
|
||||||
@@ -113,7 +106,7 @@ export async function listArguments(
|
|||||||
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
|
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVerdict(
|
export async function getVerdict(
|
||||||
topicId: string,
|
topicId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<Verdict | null> {
|
): 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 };
|
export { HttpError };
|
||||||
|
|||||||
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
|
// Flow:
|
||||||
// fully wired (Phase 4 in DIALECTIC-V2-DESIGN.md). Until then this
|
// 1. On mount, GET /api/auth/oidc/status to learn if OIDC is configured.
|
||||||
// just surfaces whether dev-bypass is on so the UI can show a banner
|
// 2. GET /api/auth/me — if session cookie exists + valid → set user.
|
||||||
// ("dev mode — running as operator") and route admin-gated pages.
|
// 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 {
|
interface AuthCtx {
|
||||||
// Whether dev-bypass token is configured at build time.
|
user: User | null;
|
||||||
devBypass: boolean;
|
loading: boolean;
|
||||||
// Display label for the "current user" — in dev-bypass mode this is
|
oidcEnabled: boolean;
|
||||||
// the literal env value (a placeholder until real OIDC).
|
login: () => void;
|
||||||
user: { id: string; label: string } | null;
|
logout: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = createContext<AuthCtx>({
|
const Ctx = createContext<AuthCtx>({
|
||||||
devBypass: false,
|
|
||||||
user: null,
|
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 }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const devBypass = Boolean(import.meta.env.VITE_OIDC_DEV_BYPASS);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const user = devBypass
|
const [loading, setLoading] = useState(true);
|
||||||
? { id: 'dev-operator', label: 'dev-operator (bypass)' }
|
const [oidcEnabled, setOidcEnabled] = useState(false);
|
||||||
: null;
|
|
||||||
return <Ctx.Provider value={{ devBypass, user }}>{children}</Ctx.Provider>;
|
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 {
|
export function useAuth(): AuthCtx {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
85
src/pages/OidcCallback.tsx
Normal file
85
src/pages/OidcCallback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +1,46 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { getTopic, listArguments } from '../api';
|
import { getTopic, getVerdict, listArguments } from '../api';
|
||||||
import type { Argument, TopicDetail } from '../types';
|
import type { Argument, TopicDetail, Verdict } from '../types';
|
||||||
import { fmtTime, fmtRelative } from '../util';
|
import { fmtTime } from '../util';
|
||||||
|
|
||||||
// Polling interval for the live transcript. 8s strikes a balance — fast
|
// v0.3.1: completed-only view. Loads topic + arguments + verdict
|
||||||
// enough that a new argument appears within an attentive viewer's
|
// concurrently on mount. No polling — completed debates don't change.
|
||||||
// attention span, slow enough that idle tabs don't hammer the backend.
|
|
||||||
const POLL_MS = 8000;
|
|
||||||
|
|
||||||
export function TopicDetailPage() {
|
export function TopicDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [topic, setTopic] = useState<TopicDetail | null>(null);
|
const [topic, setTopic] = useState<TopicDetail | null>(null);
|
||||||
const [args, setArgs] = useState<Argument[]>([]);
|
const [args, setArgs] = useState<Argument[]>([]);
|
||||||
|
const [verdict, setVerdict] = useState<Verdict | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [lastTick, setLastTick] = useState<number>(0);
|
const [loading, setLoading] = useState(true);
|
||||||
const stopped = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
stopped.current = false;
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
async function tick() {
|
Promise.all([
|
||||||
try {
|
getTopic(id, ac.signal),
|
||||||
const [t, a] = await Promise.all([
|
listArguments(id, ac.signal),
|
||||||
getTopic(id!, ac.signal),
|
getVerdict(id, ac.signal),
|
||||||
listArguments(id!, ac.signal),
|
])
|
||||||
]);
|
.then(([t, a, v]) => {
|
||||||
if (stopped.current) return;
|
|
||||||
setTopic(t);
|
setTopic(t);
|
||||||
setArgs(a.arguments ?? []);
|
setArgs(a.arguments);
|
||||||
setLastTick(Date.now());
|
setVerdict(v);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
} catch (e) {
|
})
|
||||||
const err = e as Error;
|
.catch((e: Error) => {
|
||||||
if (err.name !== 'AbortError') setErr(err.message);
|
if (e.name !== 'AbortError') setErr(e.message);
|
||||||
}
|
})
|
||||||
}
|
.finally(() => setLoading(false));
|
||||||
|
return () => ac.abort();
|
||||||
tick();
|
|
||||||
const h = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
stopped.current = true;
|
|
||||||
ac.abort();
|
|
||||||
clearInterval(h);
|
|
||||||
};
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (!id) return <div className="err">missing topic id in url</div>;
|
if (!id) return <div className="err">missing topic id in url</div>;
|
||||||
|
if (loading) return <div className="muted">loading…</div>;
|
||||||
if (err && !topic) return <div className="err">{err}</div>;
|
if (err && !topic) return <div className="err">{err}</div>;
|
||||||
if (!topic) return <div className="muted">loading…</div>;
|
if (!topic) return <div className="err">topic not found</div>;
|
||||||
|
|
||||||
const proCamp = topic.camps?.find((c) => c.camp === 'pro');
|
const proCamp = topic.camps?.find((c) => c.camp === 'pro');
|
||||||
const conCamp = topic.camps?.find((c) => c.camp === 'con');
|
const conCamp = topic.camps?.find((c) => c.camp === 'con');
|
||||||
@@ -60,7 +50,7 @@ export function TopicDetailPage() {
|
|||||||
<div className="td">
|
<div className="td">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">
|
<span className="eyebrow">
|
||||||
topic · <span className={`pill pill-${topic.status}`}>{topic.status}</span>
|
debate · <span className={`pill pill-${topic.status}`}>{topic.status}</span>
|
||||||
</span>
|
</span>
|
||||||
<h1>{topic.title}</h1>
|
<h1>{topic.title}</h1>
|
||||||
<p className="td-summary">{topic.summary}</p>
|
<p className="td-summary">{topic.summary}</p>
|
||||||
@@ -69,19 +59,13 @@ export function TopicDetailPage() {
|
|||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">metadata</div>
|
<div className="panel-header">metadata</div>
|
||||||
<div className="td-meta">
|
<div className="td-meta">
|
||||||
<div>
|
|
||||||
<div className="label">visibility</div>
|
|
||||||
<div className="value">{topic.visibility}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="label">verdict schema</div>
|
<div className="label">verdict schema</div>
|
||||||
<div className="value">{topic.verdict_schema_id}</div>
|
<div className="value">{topic.verdict_schema_id}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="label">signup window</div>
|
<div className="label">visibility</div>
|
||||||
<div className="value">
|
<div className="value">{topic.visibility}</div>
|
||||||
{fmtTime(topic.signup_open_at)} → {fmtTime(topic.signup_close_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="label">debate window</div>
|
<div className="label">debate window</div>
|
||||||
@@ -93,49 +77,29 @@ export function TopicDetailPage() {
|
|||||||
<div className="label">creator</div>
|
<div className="label">creator</div>
|
||||||
<div className="value">{topic.creator_user_id}</div>
|
<div className="value">{topic.creator_user_id}</div>
|
||||||
</div>
|
</div>
|
||||||
{topic.cancelled_reason && (
|
|
||||||
<div>
|
|
||||||
<div className="label">cancelled reason</div>
|
|
||||||
<div className="value err" style={{ padding: '4px 8px' }}>
|
|
||||||
{topic.cancelled_reason}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topic.camps && topic.camps.length > 0 && (
|
{topic.camps && topic.camps.length > 0 && (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">camps (allocated)</div>
|
<div className="panel-header">camps</div>
|
||||||
<div className="td-camps">
|
<div className="td-camps">
|
||||||
<div className="td-camp">
|
<div className="td-camp">
|
||||||
<div className="td-camp-label camp-pro">pro</div>
|
<div className="td-camp-label camp-pro">pro</div>
|
||||||
<div className="td-camp-agent">
|
<div className="td-camp-agent">
|
||||||
{proCamp ? (
|
{proCamp ? proCamp.agent_id : <span className="muted">—</span>}
|
||||||
<Link to={`/agents/${proCamp.agent_id}`}>{proCamp.agent_id}</Link>
|
|
||||||
) : (
|
|
||||||
<span className="muted">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="td-camp">
|
<div className="td-camp">
|
||||||
<div className="td-camp-label camp-con">con</div>
|
<div className="td-camp-label camp-con">con</div>
|
||||||
<div className="td-camp-agent">
|
<div className="td-camp-agent">
|
||||||
{conCamp ? (
|
{conCamp ? conCamp.agent_id : <span className="muted">—</span>}
|
||||||
<Link to={`/agents/${conCamp.agent_id}`}>{conCamp.agent_id}</Link>
|
|
||||||
) : (
|
|
||||||
<span className="muted">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="td-camp">
|
<div className="td-camp">
|
||||||
<div className="td-camp-label camp-judge">judge</div>
|
<div className="td-camp-label camp-judge">judge</div>
|
||||||
<div className="td-camp-agent">
|
<div className="td-camp-agent">
|
||||||
{judgeCamp ? (
|
{judgeCamp ? judgeCamp.agent_id : <span className="muted">—</span>}
|
||||||
<Link to={`/agents/${judgeCamp.agent_id}`}>{judgeCamp.agent_id}</Link>
|
|
||||||
) : (
|
|
||||||
<span className="muted">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,26 +107,18 @@ export function TopicDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header row">
|
<div className="panel-header">transcript ({args.length})</div>
|
||||||
<span>transcript ({args.length})</span>
|
|
||||||
<span className="spacer" />
|
|
||||||
<span className="muted">
|
|
||||||
polling every {POLL_MS / 1000}s · last refresh {fmtRelative(new Date(lastTick).toISOString())}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{args.length === 0 ? (
|
{args.length === 0 ? (
|
||||||
<div className="td-empty">no arguments posted yet</div>
|
<div className="td-empty">no arguments were posted</div>
|
||||||
) : (
|
) : (
|
||||||
args.map((a) => (
|
args.map((a) => (
|
||||||
<div key={a.id} className={`td-arg td-arg-${a.camp}`}>
|
<div key={a.id} className={`td-arg td-arg-${a.camp}`}>
|
||||||
<div className="td-arg-meta row">
|
<div className="td-arg-meta row">
|
||||||
<span className={`camp-${a.camp}`}>{a.camp.toUpperCase()}</span>
|
<span className={`camp-${a.camp}`}>{a.camp.toUpperCase()}</span>
|
||||||
<Link to={`/agents/${a.agent_id}`} className="muted">
|
<span className="muted">{a.agent_id}</span>
|
||||||
{a.agent_id}
|
|
||||||
</Link>
|
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted" title={fmtTime(a.posted_at)}>
|
<span className="muted" title={fmtTime(a.posted_at)}>
|
||||||
{fmtRelative(a.posted_at)}
|
{fmtTime(a.posted_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="td-arg-content">{a.content}</div>
|
<div className="td-arg-content">{a.content}</div>
|
||||||
@@ -171,14 +127,34 @@ export function TopicDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topic.status === 'completed' && (
|
{verdict && (
|
||||||
<div className="panel">
|
<>
|
||||||
<div className="panel-header">verdict</div>
|
<div className="panel">
|
||||||
<Link to={`/topics/${topic.id}/verdict`}>view verdict + rationale →</Link>
|
<div className="panel-header">verdict</div>
|
||||||
</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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{err && <div className="err">refresh error: {err}</div>}
|
{!verdict && topic.status === 'completed' && (
|
||||||
|
<div className="panel td-empty">
|
||||||
|
no verdict was recorded (judge may have failed to submit)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { listTopics } from '../api';
|
import { listTopics } from '../api';
|
||||||
import type { Topic, TopicStatus } from '../types';
|
import type { Topic } from '../types';
|
||||||
import { fmtTime, fmtRelative } from '../util';
|
import { fmtTime, fmtRelative } from '../util';
|
||||||
|
|
||||||
const STATUS_OPTIONS: Array<TopicStatus | 'all'> = [
|
// v0.3.1: this list only shows COMPLETED debates. Topics in earlier
|
||||||
'all',
|
// lifecycle states (signup_open / signup_closed / debating) are agent
|
||||||
'signup_open',
|
// internals — human readers care about the finished record (full
|
||||||
'signup_closed',
|
// transcript + verdict). Status filtering UI removed.
|
||||||
'debating',
|
|
||||||
'completed',
|
|
||||||
'cancelled',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TopicListPage() {
|
export function TopicListPage() {
|
||||||
const [status, setStatus] = useState<TopicStatus | 'all'>('all');
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([]);
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -23,61 +18,44 @@ export function TopicListPage() {
|
|||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
listTopics({
|
listTopics({ status: 'completed', limit: 100, signal: ac.signal })
|
||||||
status: status === 'all' ? undefined : status,
|
|
||||||
limit: 100,
|
|
||||||
signal: ac.signal,
|
|
||||||
})
|
|
||||||
.then((r) => setTopics(r.topics))
|
.then((r) => setTopics(r.topics))
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
if (e.name !== 'AbortError') setErr(e.message);
|
if (e.name !== 'AbortError') setErr(e.message);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [status]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">debates</span>
|
<span className="eyebrow">archive</span>
|
||||||
<h1>topics</h1>
|
<h1>completed debates</h1>
|
||||||
<p className="muted" style={{ marginBottom: 20 }}>
|
<p className="muted" style={{ marginBottom: 20 }}>
|
||||||
Public + visible-to-you topics. Filter by lifecycle status. Click a row for the
|
Each entry is a finished debate — click for the full transcript + verdict.
|
||||||
live transcript.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="tl-filters">
|
<div className="tl-filters">
|
||||||
<label>
|
|
||||||
status
|
|
||||||
<select
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => setStatus(e.target.value as TopicStatus | 'all')}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{loading ? 'loading…' : `${topics.length} ${topics.length === 1 ? 'topic' : 'topics'}`}
|
{loading
|
||||||
|
? 'loading…'
|
||||||
|
: `${topics.length} ${topics.length === 1 ? 'debate' : 'debates'}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <div className="err">{err}</div>}
|
{err && <div className="err">{err}</div>}
|
||||||
|
|
||||||
{!err && !loading && topics.length === 0 && (
|
{!err && !loading && topics.length === 0 && (
|
||||||
<div className="td-empty">no topics match this filter</div>
|
<div className="td-empty">no completed debates yet</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{topics.length > 0 && (
|
{topics.length > 0 && (
|
||||||
<table className="tl-table">
|
<table className="tl-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '60%' }}>topic</th>
|
<th style={{ width: '60%' }}>debate</th>
|
||||||
<th>status</th>
|
<th>schema</th>
|
||||||
<th>signup close</th>
|
|
||||||
<th>debate window</th>
|
<th>debate window</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -90,14 +68,12 @@ export function TopicListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="tl-row-summary">{t.summary}</div>
|
<div className="tl-row-summary">{t.summary}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="tl-row-time">{t.verdict_schema_id}</td>
|
||||||
<span className={`pill pill-${t.status}`}>{t.status}</span>
|
<td
|
||||||
</td>
|
className="tl-row-time"
|
||||||
<td className="tl-row-time" title={fmtTime(t.signup_close_at)}>
|
title={`${fmtTime(t.debate_start_at)} → ${fmtTime(t.debate_end_at)}`}
|
||||||
{fmtRelative(t.signup_close_at)}
|
>
|
||||||
</td>
|
{fmtRelative(t.debate_end_at)}
|
||||||
<td className="tl-row-time" title={`${fmtTime(t.debate_start_at)} → ${fmtTime(t.debate_end_at)}`}>
|
|
||||||
{fmtTime(t.debate_start_at)} → {fmtTime(t.debate_end_at)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function VerdictPage() {
|
|||||||
<div className="panel-header">verdict (structured)</div>
|
<div className="panel-header">verdict (structured)</div>
|
||||||
<pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
|
<pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
|
||||||
<div className="row muted" style={{ marginTop: 8, fontSize: '0.85em' }}>
|
<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 className="spacer" />
|
||||||
<span>produced: {fmtTime(verdict.produced_at)}</span>
|
<span>produced: {fmtTime(verdict.produced_at)}</span>
|
||||||
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
|
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
|
||||||
|
|||||||
@@ -66,6 +66,33 @@
|
|||||||
.app-user {
|
.app-user {
|
||||||
margin-left: auto;
|
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 {
|
.app-dev-banner {
|
||||||
background: rgba(255, 90, 82, 0.1);
|
background: rgba(255, 90, 82, 0.1);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
15
src/types.ts
15
src/types.ts
@@ -68,18 +68,3 @@ export interface Verdict {
|
|||||||
produced_at: string;
|
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;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user