Files
GEWU/web/src/shell/Admin.tsx
T

272 lines
21 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useMemo, useState } from 'react';
import CredentialsSection from './CredentialsSection';
import AccountLogin from './AccountLogin';
import ProjectsSection from './ProjectsSection';
import { AccountsSection, PasswordSection } from './AccountControls';
import { AccountRequestError as AdminRequestError, accountRequest as request } from './account-api';
import type { components } from '../generated/api';
const adminTabs = [
{ id: 'projects', label: 'Projects' },
{ id: 'ingest-review', label: 'Ingest review' },
{ id: 'users', label: 'Users' },
{ id: 'credentials', label: 'Credentials' },
{ id: 'logs', label: 'Logs' },
{ id: 'settings', label: 'Admin Settings' },
] as const;
type AdminTab = typeof adminTabs[number]['id'];
type Credential = components['schemas']['CredentialSummary'];
type Activity = components['schemas']['AdminEvent'];
type Project = components['schemas']['ProjectRecord'];
type IngestReview = {
review_id: string; project_id: string; run_id: string; source_revision_id: string;
extraction_id: string; locator: { kind: string; block_id: string; start: number; end: number; page?: number | null };
excerpt: string; lens: string; summary: string; relevance_rationale: string;
jev_relation_probability?: number | null; jev_lens?: string | null;
jev_lens_confidence?: number | null; jev_model?: string | null;
jev_facet_scores?: { facet: 'tags' | 'audience' | 'use_for' | 'domain'; value: string; probability: number }[];
jev_facet_resolutions?: {
facet: 'tags' | 'audience' | 'use_for' | 'domain'; value: string;
fit_probability: number; selected_label?: string | null; selected_new_label: boolean;
new_probability: number;
choice_confidence: number; rank: number; added_to_project: boolean;
option_probabilities: { label_id?: string | null; label: string; probability: number }[];
}[];
relevance: 'borderline' | 'low'; status: string; created_at: string;
};
function IngestReviewSection({ csrf, projects, onError, onMessage }: {
csrf: string; projects: Project[]; onError: (message: string) => void; onMessage: (message: string) => void;
}) {
const [projectId, setProjectId] = useState('');
const [items, setItems] = useState<IngestReview[]>([]);
const [guidance, setGuidance] = useState<Record<string, string>>({});
const [busyId, setBusyId] = useState('');
const [rationales, setRationales] = useState<Record<string, string>>({});
const [saveGuidance, setSaveGuidance] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => {
if (!projectId) { setItems([]); return; }
try {
const rows = await request<IngestReview[]>(`/v1/ingest-reviews?project_id=${encodeURIComponent(projectId)}&status=needs-user-review&limit=100`, csrf);
setItems(rows);
} catch (failure) { onError(failure instanceof Error ? failure.message : 'Could not load review queue.'); }
}, [projectId, csrf, onError]);
useEffect(() => { void refresh(); }, [refresh]);
async function decide(item: IngestReview, decision: 'include' | 'exclude' | 'defer') {
const rationale = rationales[item.review_id]?.trim();
if (!rationale) { onError('Add a short reason for this decision.'); return; }
const wantsGuidance = saveGuidance[item.review_id] ?? false;
const guidanceRule = guidance[item.review_id]?.trim();
if (wantsGuidance && !guidanceRule) { onError('Write the reusable guidance rule to save it.'); return; }
setBusyId(item.review_id); onError('');
try {
await request(`/v1/ingest-reviews/${encodeURIComponent(item.review_id)}/decision`, csrf, {
method: 'POST',
body: JSON.stringify({ decision_id: crypto.randomUUID(), review_id: item.review_id, decision, rationale,
save_as_guidance: wantsGuidance, ...(wantsGuidance ? { guidance_rule: guidanceRule } : {}) }),
});
onMessage(decision === 'include' ? 'Relevance confirmed. Feedback saved; knowledge changes still use the normal proposal flow.' : decision === 'exclude' ? 'Marked not relevant. Feedback saved.' : 'Feedback deferred.');
await refresh();
} catch (failure) { onError(failure instanceof Error ? failure.message : 'Could not save review decision.'); }
finally { setBusyId(''); }
}
return <section className="admin-card" aria-labelledby="ingest-review-title">
<div className="admin-heading"><div><h2 id="ingest-review-title">Ingest review</h2><p className="muted-copy">These flags are recorded alongside normal ingestion and do not hold up proposals or publication. Your decision helps tune future project triage.</p><p className="muted-copy">If configured, Jev sends the project context and excerpt to OpenRouter for relevance, lens and candidate facet suggestions. These signals are advisory.</p></div>
<button className="quiet-button" disabled={busyId !== '' || !projectId} onClick={() => void refresh()}>Refresh</button></div>
<label>Project<select value={projectId} onChange={event => setProjectId(event.target.value)}><option value="">Choose a project</option>{projects.filter(project => project.status !== 'archived').map(project => <option key={project.project_id} value={project.project_id}>{project.title}</option>)}</select></label>
{!projectId ? <p>Select a project to view its review flags.</p> : items.length === 0 ? <p>No review flags need feedback.</p> : <ol className="admin-activity">{items.map(item => <li key={item.review_id}>
<strong>{item.lens.replaceAll('-', ' ')} · {item.relevance} relevance · {item.summary}</strong>
<p>{item.excerpt}</p><p className="muted-copy">{item.relevance_rationale}</p>
{item.jev_relation_probability != null ? <p className="muted-copy">Jev relevance: {Math.round(item.jev_relation_probability * 100)}% · lens: {item.jev_lens ?? 'unknown'} ({item.jev_lens_confidence != null ? `${Math.round(item.jev_lens_confidence * 100)}% confidence` : 'no confidence score'}) · {item.jev_model}</p> : <p className="muted-copy">Jev signal unavailable or not configured.</p>}
{!!item.jev_facet_scores?.length && <p className="muted-copy">Facet suggestions: {item.jev_facet_scores.map(score => `${score.facet.replace('_', ' ')} “${score.value}” (${Math.round(score.probability * 100)}%)`).join(' · ')}</p>}
{!!item.jev_facet_resolutions?.length && <p className="muted-copy">Label decisions: {item.jev_facet_resolutions.map(label => `${label.facet.replace('_', ' ')} “${label.value}” → ${label.added_to_project ? 'added as a new project label' : label.selected_label ? `reused “${label.selected_label}”` : label.selected_new_label ? 'new label suggested, not added' : 'no label selected'} (fit ${Math.round(label.fit_probability * 100)}%, new ${Math.round(label.new_probability * 100)}%, confidence ${Math.round(label.choice_confidence * 100)}%, top-N rank ${label.rank}; options: ${label.option_probabilities.map(option => `${option.label} ${Math.round(option.probability * 100)}%`).join(', ')})`).join(' · ')}</p>}
<p className="muted-copy">Source {item.source_revision_id} · {item.locator.kind.replaceAll('_', ' ')} {item.locator.page ? `· page ${item.locator.page}` : ''} · run {item.run_id}</p>
<label>Decision reason<textarea maxLength={2000} value={rationales[item.review_id] ?? ''} onChange={event => setRationales(value => ({ ...value, [item.review_id]: event.target.value }))} /></label>
<label><input type="checkbox" checked={saveGuidance[item.review_id] ?? false} onChange={event => setSaveGuidance(value => ({ ...value, [item.review_id]: event.target.checked }))} /> Save a reusable rule for this project and lens</label>
{saveGuidance[item.review_id] && <label>Project guidance<textarea maxLength={1000} value={guidance[item.review_id] ?? ''} onChange={event => setGuidance(value => ({ ...value, [item.review_id]: event.target.value }))} placeholder="For example: include historical show production and audience context when it informs dome practice." /></label>}
<div className="admin-actions"><button disabled={busyId !== ''} onClick={() => void decide(item, 'include')}>Relevant</button><button className="quiet-button" disabled={busyId !== ''} onClick={() => void decide(item, 'defer')}>Defer feedback</button><button className="quiet-button" disabled={busyId !== ''} onClick={() => void decide(item, 'exclude')}>Not relevant</button></div>
</li>)}</ol>}
</section>;
}
function Admin({ personal = false }: { personal?: boolean }) {
const [activeTab, setActiveTab] = useState<AdminTab>('projects');
const [passwordAccount, setPasswordAccount] = useState(false);
const [csrf, setCsrf] = useState('');
const [credentials, setCredentials] = useState<Credential[]>([]);
const [events, setEvents] = useState<Activity[]>([]);
const [logAccount, setLogAccount] = useState('');
const [logType, setLogType] = useState('');
const [logFrom, setLogFrom] = useState('');
const [logTo, setLogTo] = useState('');
const [projects, setProjects] = useState<{ project_id: string; title: string }[]>([]);
const [projectRecords, setProjectRecords] = useState<Project[]>([]);
const [currentPrincipal, setCurrentPrincipal] = useState('');
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState(personal ? 'Sign in to manage your agent keys.' : 'Sign in to manage accounts and service access.');
const [issued, setIssued] = useState<{ credential_id: string; token: string } | null>(null);
const load = useCallback(async (session: string, page = offset) => {
const [keys, activity, availableProjects, identity] = await Promise.all([
request<Credential[]>(`${personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials'}?limit=50&offset=${page}`, session),
personal ? Promise.resolve([] as Activity[]) : request<Activity[]>('/v1/admin/events?limit=50', session),
personal ? request<string[]>('/v1/projects', session).then(ids => ids.map(id => ({ project_id: id, title: id }))) : request<Project[]>('/v1/admin/projects', session),
request<{ principal_id: string }>('/v1/me', session),
]);
setCredentials(keys); setEvents(activity); setProjects(availableProjects); setOffset(page);
setCurrentPrincipal(identity.principal_id ?? '');
if (!personal) setProjectRecords(availableProjects as Project[]);
}, [personal, offset]);
const handleSessionError = useCallback((failure: unknown) => {
if (failure instanceof AdminRequestError && failure.status === 401) {
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
setMessage('Your session ended. Connect again to continue.');
}
}, []);
const refreshAdminData = useCallback(async () => {
try {
await load(csrf);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
const loadAdminPage = useCallback(async (page: number) => {
try {
await load(csrf, page);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
async function connect(session: { csrf_token: string }, isPasswordAccount: boolean, restored = false) {
setBusy(true); setError('');
try {
await load(session.csrf_token, 0);
setActiveTab('projects');
setCsrf(session.csrf_token); setPasswordAccount(isPasswordAccount);
setMessage(personal ? 'Signed in. Manage the keys for your agents below.' : 'Connected. Credentials and recent activity are up to date.');
} catch (failure) {
if (!restored) await request('/v1/session', session.csrf_token, { method: 'DELETE' }).catch(() => undefined);
throw failure;
} finally { setBusy(false); }
}
async function perform(action: () => Promise<void>) {
setBusy(true); setError('');
try { await action(); }
catch (failure) {
if (failure instanceof AdminRequestError && failure.status === 401) {
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
setMessage('Your session ended. Connect again to continue.');
}
setError(failure instanceof Error ? failure.message : 'Request failed.');
}
finally { setBusy(false); }
}
const userProjectOptions = useMemo(() => projects.filter(project =>
!projectRecords.some(record => record.project_id === project.project_id && record.status === 'archived')
), [projects, projectRecords]);
const filteredEvents = useMemo(() => events.filter(event =>
(!logAccount || event.actor === logAccount) &&
(!logType || event.action === logType) &&
(!logFrom || event.occurred_at.slice(0, 10) >= logFrom) &&
(!logTo || event.occurred_at.slice(0, 10) <= logTo)
), [events, logAccount, logType, logFrom, logTo]);
return <div className="app-shell">
<a className="skip-link" href="#main-content">Skip to administration</a>
<aside className="rail" aria-label="Primary navigation">
<a className="brand" href="/"><span className="brand__mark" aria-hidden="true">G</span><span>GEWU<span className="brand__sub">RESEARCH LIBRARY</span></span></a>
<nav className="rail-nav" aria-label="Library sections">
<a className="rail-nav__item" href="/">⌕ Search</a>
<a className={`rail-nav__item ${personal ? '' : 'rail-nav__item--active'}`} href="/admin" aria-current={personal ? undefined : 'page'}>Administration</a>
<a className={`rail-nav__item ${personal ? 'rail-nav__item--active' : ''}`} href="/agents" aria-current={personal ? 'page' : undefined}>Agent credentials</a>
</nav>
<div className="rail__footer"><p className="eyebrow">SERVICE ACCESS</p><p>Credentials & activity</p><p className="scope-note">Access management for your GEWU service.</p></div>
</aside>
<main id="main-content" className="workspace">
<header className="topbar"><div><p className="eyebrow">SERVICE / ADMINISTRATION</p><h1>{personal ? 'Connect your agents.' : 'Manage access to your library.'}</h1></div>
{csrf && <button className="quiet-button" disabled={busy} onClick={() => void perform(async () => {
await request('/v1/session', csrf, { method: 'DELETE' });
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
setMessage('Disconnected.');
})}>Disconnect</button>}
</header>
<p role="status">{busy ? 'Working…' : message}</p>
{error && <p className="admin-error" role="alert">{error}</p>}
{!csrf ? <section className="connect-card" aria-labelledby="admin-connect-title">
<div><p className="eyebrow">{personal ? 'YOUR ACCOUNT' : 'ADMINISTRATOR ACCESS'}</p><h2 id="admin-connect-title">Sign in to your account</h2><p>{personal ? 'Manage separate keys for each agent you use. Your administrator assigns your project access.' : 'Sign in with your username and password. For initial setup, use your existing administrator credential to create the first account.'}</p></div>
<AccountLogin onLogin={connect} busy={busy} credentialLabel="GEWU administrator credential" onError={setError} />
</section> : <>
{!personal && <div className="admin-tabs" role="tablist" aria-label="Administration sections">
{adminTabs.map((tab, index) => <button key={tab.id} id={`admin-tab-${tab.id}`} type="button" role="tab"
aria-selected={activeTab === tab.id} aria-controls={`admin-panel-${tab.id}`} tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)} onKeyDown={event => {
let next = index;
if (event.key === 'ArrowRight') next = (index + 1) % adminTabs.length;
else if (event.key === 'ArrowLeft') next = (index - 1 + adminTabs.length) % adminTabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = adminTabs.length - 1;
else return;
event.preventDefault();
setActiveTab(adminTabs[next].id);
document.getElementById(`admin-tab-${adminTabs[next].id}`)?.focus();
}}>{tab.label}</button>)}
</div>}
{!personal && <div id="admin-panel-projects" role="tabpanel" aria-labelledby="admin-tab-projects" hidden={activeTab !== 'projects'} tabIndex={0}>
<ProjectsSection csrf={csrf} projects={projectRecords} currentOwner={currentPrincipal} onCreated={refreshAdminData} onError={setError} onMessage={setMessage} />
</div>}
{!personal && <div id="admin-panel-ingest-review" role="tabpanel" aria-labelledby="admin-tab-ingest-review" hidden={activeTab !== 'ingest-review'} tabIndex={0}>
<IngestReviewSection csrf={csrf} projects={projectRecords} onError={setError} onMessage={setMessage} />
</div>}
{!personal && <div id="admin-panel-users" role="tabpanel" aria-labelledby="admin-tab-users" hidden={activeTab !== 'users'} tabIndex={0}>
<AccountsSection csrf={csrf} projects={userProjectOptions} onMessage={setMessage} onError={setError} />
</div>}
<div id={personal ? undefined : 'admin-panel-credentials'} role={personal ? undefined : 'tabpanel'} aria-labelledby={personal ? undefined : 'admin-tab-credentials'} hidden={!personal && activeTab !== 'credentials'} tabIndex={personal ? undefined : 0}>
{issued && <section className="admin-card admin-secret" aria-labelledby="secret-title">
<h2 id="secret-title">Save your new credential</h2><p>The secret for <strong>{issued.credential_id}</strong> is shown once. Store it securely before dismissing it.</p>
<label htmlFor="issued-token">New bearer credential</label><input id="issued-token" readOnly value={issued.token} autoComplete="off" spellCheck={false} />
<button className="quiet-button" onClick={() => { setIssued(null); setMessage('Secret dismissed.'); }}>I have saved it — dismiss</button>
</section>}
<CredentialsSection csrf={csrf} personal={personal} credentials={credentials} projects={projects}
offset={offset} onPageChange={loadAdminPage} onRefresh={refreshAdminData} onIssued={setIssued} onError={setError} onMessage={setMessage} />
</div>
{!personal && <div id="admin-panel-logs" role="tabpanel" aria-labelledby="admin-tab-logs" hidden={activeTab !== 'logs'} tabIndex={0}>
<section className="admin-card" aria-labelledby="activity-title"><div className="admin-heading"><div><h2 id="activity-title">Logs</h2><p className="muted-copy">Filter the latest 50 account and credential events.</p></div><button className="quiet-button" disabled={busy} onClick={() => void perform(async () => { await load(csrf); setMessage('Recent activity refreshed.'); })}>Refresh</button></div>
<div className="admin-filter-bar" aria-label="Filter logs">
<label>Account<select value={logAccount} onChange={event => setLogAccount(event.target.value)}><option value="">All accounts</option>{Array.from(new Set(events.map(item => item.actor))).sort().map(actor => <option key={actor} value={actor}>{actor}</option>)}</select></label>
<label>Type<select value={logType} onChange={event => setLogType(event.target.value)}><option value="">All types</option>{Array.from(new Set(events.map(item => item.action))).sort().map(action => <option key={action} value={action}>{action.replaceAll('.', ' ')}</option>)}</select></label>
<label>From<input type="date" value={logFrom} onChange={event => setLogFrom(event.target.value)} /></label>
<label>To<input type="date" value={logTo} onChange={event => setLogTo(event.target.value)} /></label>
<button className="text-button" type="button" onClick={() => { setLogAccount(''); setLogType(''); setLogFrom(''); setLogTo(''); }}>Clear filters</button>
</div>
{filteredEvents.length ? <ol className="admin-activity">{filteredEvents.map(event => <li key={event.event_id}><strong>{event.action.replaceAll('.', ' ')} · {event.credential_id}</strong><span>{event.actor} · <time dateTime={event.occurred_at}>{new Date(event.occurred_at).toLocaleString()}</time></span></li>)}</ol> : <p>{events.length ? 'No events match these filters.' : 'No account or credential activity recorded yet.'}</p>}
</section>
</div>}
<div id={personal ? undefined : 'admin-panel-settings'} role={personal ? undefined : 'tabpanel'} aria-labelledby={personal ? undefined : 'admin-tab-settings'} hidden={!personal && activeTab !== 'settings'} tabIndex={personal ? undefined : 0}>
{!personal && <section className="admin-card" aria-labelledby="settings-title">
<h2 id="settings-title">Admin Settings</h2>
<p>Signed in as <strong>{currentPrincipal || 'administrator'}</strong>.</p>
<p className="muted-copy">{passwordAccount ? 'Manage the password for your administrator account below.' : 'You are connected with an administrator credential. To manage a password, create an administrator account in Users, then sign in with its username and password.'}</p>
</section>}
{passwordAccount && <PasswordSection csrf={csrf} onError={setError} onChanged={() => {
setCsrf(''); setCredentials([]); setEvents([]); setIssued(null); setProjects([]);
setMessage('Password changed. Sign in with your new password.');
}} />}
</div>
</>}
</main>
</div>;
}
export default Admin;