feat: implement GEWU service and local agent workflows
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-14 19:21:35 +08:00
1 parent 4db3f45164
commit d25c04cdd2
175 files changed
+56243 -15

No files matched your search

File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router';
import App from './shell/App';
import Admin from './shell/Admin';
import './styles/base.css';
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes><Route path="/admin" element={<Admin />} /><Route path="/agents" element={<Admin personal />} /><Route path="*" element={<App />} /></Routes>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);
+115
View File
@@ -0,0 +1,115 @@
import { memo, useEffect, useRef, useState, type FormEvent } from 'react';
import { AdminDialog, AdminList, AdminListItem } from './AdminPrimitives';
import { accountRequest } from './account-api';
type User = { username: string; role: string; project_ids: string[]; active: boolean };
type Project = { project_id: string; title: string };
function AccountsSectionComponent({ csrf, projects, onMessage, onError }: {
csrf: string; projects: Project[]; onMessage: (message: string) => void; onError: (message: string) => void;
}) {
const [users, setUsers] = useState<User[]>([]);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('editor');
const [grants, setGrants] = useState<string[]>([]);
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [disable, setDisable] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
const [editRole, setEditRole] = useState('editor');
const [editGrants, setEditGrants] = useState<string[]>([]);
const addButtonRef = useRef<HTMLButtonElement>(null);
async function load(page = offset) {
const result = await accountRequest<User[]>(`/v1/admin/users?limit=50&offset=${page}`, csrf);
setUsers(result); setOffset(page);
}
useEffect(() => {
let active = true;
void accountRequest<User[]>('/v1/admin/users?limit=50&offset=0', csrf)
.then(result => { if (active) setUsers(result); })
.catch(error => { if (active) onError(error instanceof Error ? error.message : 'Could not load users.'); });
return () => { active = false; };
}, [csrf, onError]);
async function perform(action: () => Promise<void>) {
setBusy(true); onError('');
try { await action(); } catch (error) { onError(error instanceof Error ? error.message : 'Account operation failed.'); }
finally { setBusy(false); }
}
async function create(event: FormEvent) {
event.preventDefault();
await perform(async () => {
await accountRequest('/v1/admin/users', csrf, { method: 'POST', body: JSON.stringify({ username, password, role, project_ids: grants }) });
setPassword(''); setUsername(''); setGrants([]); setAddOpen(false);
onMessage(`Account created for ${username}. They can now sign in and manage their agents.`); await load(0);
});
setPassword('');
}
function startEdit(user: User) {
setEditing(user.username); setEditRole(user.role); setEditGrants([...user.project_ids]); setDisable(null);
}
async function saveEdit(event: FormEvent) {
event.preventDefault(); if (!editing) return;
await perform(async () => {
await accountRequest(`/v1/admin/users/${encodeURIComponent(editing)}`, csrf, { method: 'PATCH', body: JSON.stringify({ role: editRole, project_ids: editRole === 'admin' ? [] : editGrants }) });
onMessage(`Updated ${editing}.`); setEditing(null); await load();
});
}
return <section className="admin-card" aria-labelledby="users-title">
<div className="admin-heading"><div><h2 id="users-title">Users</h2><p className="muted-copy">Manage account roles and project access.</p></div>
<button ref={addButtonRef} className="quiet-button" type="button" onClick={() => setAddOpen(true)}>Add user</button>
<button className="text-button" disabled={busy} onClick={() => void perform(() => load())}>Refresh</button></div>
<AdminList label="User accounts">{users.map(user => <AdminListItem key={user.username} title={user.username}
summary={`${user.role} · ${user.role === 'admin' ? 'All projects' : user.project_ids.join(', ') || 'No projects'} · ${user.active ? 'Active' : 'Disabled'}`}
actions={<>{user.active && <><button className="text-button" type="button" onClick={() => startEdit(user)}>Edit</button><button className="text-button" type="button" onClick={() => { setDisable(user.username); setEditing(null); }}>Disable</button></>}{!user.active && <span className="muted-copy">Disabled</span>}</>}>
{editing === user.username && <form className="admin-form connect-form record-edit-form" onSubmit={saveEdit}>
<label>Account role<select value={editRole} onChange={event => { setEditRole(event.target.value); setEditGrants([]); }}><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label>
{editRole !== 'admin' && <fieldset><legend>Project access</legend>{projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={editGrants.includes(project.project_id)} onChange={event => setEditGrants(event.target.checked ? [...editGrants, project.project_id] : editGrants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>}
<div className="admin-wide"><button disabled={busy}>Save changes</button> <button className="text-button" type="button" onClick={() => setEditing(null)}>Cancel</button></div>
</form>}
{disable === user.username && <div className="record-confirm"><p>Disable this user and all their agent keys?</p><button className="quiet-button" disabled={busy} onClick={() => void perform(async () => { await accountRequest(`/v1/admin/users/${encodeURIComponent(user.username)}`, csrf, { method: 'DELETE' }); setDisable(null); await load(); onMessage(`Disabled ${user.username} and their agent keys.`); })}>Confirm disable</button> <button className="text-button" onClick={() => setDisable(null)}>Cancel</button></div>}
</AdminListItem>)}</AdminList>
{!users.length && <p>No user accounts yet.</p>}
<div className="admin-heading"><button className="quiet-button" disabled={busy || offset === 0} onClick={() => void perform(() => load(Math.max(0, offset - 50)))}>Previous</button><span>Page {offset / 50 + 1}</span><button className="quiet-button" disabled={busy || users.length < 50} onClick={() => void perform(() => load(offset + 50))}>Next</button></div>
<AdminDialog open={addOpen} title="Add user" onClose={() => setAddOpen(false)} returnFocusRef={addButtonRef}>
<p className="muted-copy">Users need a password with at least 12 characters. They can change it after signing in.</p>
<form className="admin-form connect-form" onSubmit={create}>
<label>Username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
<label>Initial password<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required minLength={12} maxLength={1024} /></label>
<label>Account role<select value={role} onChange={event => { setRole(event.target.value); setGrants([]); }}><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label>
<fieldset><legend>Project access</legend>{role === 'admin' ? <p>All projects</p> : projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={grants.includes(project.project_id)} onChange={event => setGrants(event.target.checked ? [...grants, project.project_id] : grants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>
<button disabled={busy}>Add user</button>
</form>
</AdminDialog>
</section>;
}
export const AccountsSection = memo(AccountsSectionComponent);
export function PasswordSection({ csrf, onChanged, onError }: {
csrf: string; onChanged: () => void; onError: (message: string) => void;
}) {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
async function change(event: FormEvent) {
event.preventDefault(); onError('');
if (next !== confirm) { onError('New passwords do not match.'); return; }
setBusy(true);
try {
await accountRequest('/v1/me/password', csrf, { method: 'POST', body: JSON.stringify({ current_password: current, new_password: next }) });
setCurrent(''); setNext(''); setConfirm(''); onChanged();
} catch (error) { onError(error instanceof Error ? error.message : 'Password change failed.'); }
finally { setCurrent(''); setBusy(false); }
}
return <section className="admin-card" aria-labelledby="password-title"><h2 id="password-title">Change your password</h2><p className="muted-copy">Changing your password signs out all your browser sessions. Agent keys remain valid.</p><form className="admin-form connect-form" onSubmit={change}>
<label>Current password<input type="password" autoComplete="current-password" value={current} onChange={e => setCurrent(e.target.value)} required /></label>
<label>New password<input type="password" autoComplete="new-password" value={next} onChange={e => setNext(e.target.value)} required minLength={12} maxLength={1024} /></label>
<label>Confirm new password<input type="password" autoComplete="new-password" value={confirm} onChange={e => setConfirm(e.target.value)} required minLength={12} maxLength={1024} /></label>
<button disabled={busy}>Change password</button>
</form></section>;
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
export default function AccountLogin({ onLogin, busy, credentialLabel, onError }: {
onLogin: (session: { csrf_token: string }, passwordAccount: boolean, restored?: boolean) => Promise<void>;
busy: boolean;
credentialLabel: string;
onError: (message: string) => void;
}) {
const [checking, setChecking] = useState(true);
const callbacks = useRef({ onLogin, onError });
useEffect(() => { callbacks.current = { onLogin, onError }; }, [onLogin, onError]);
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const response = await fetch('/v1/session', {
method: 'GET', credentials: 'same-origin', cache: 'no-store',
headers: { 'X-GEWU-Session': 'restore' }, signal: controller.signal,
});
if (response.status === 401) return;
if (!response.ok) throw new Error('Could not restore your session. Try signing in again.');
const session = await response.json() as { csrf_token: string; password_account: boolean };
if (!controller.signal.aborted) await callbacks.current.onLogin(session, session.password_account, true);
} catch (failure) {
if (!controller.signal.aborted) callbacks.current.onError(failure instanceof Error ? failure.message : 'Session restoration failed.');
} finally { if (!controller.signal.aborted) setChecking(false); }
})();
return () => controller.abort();
}, []);
const [legacy, setLegacy] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [token, setToken] = useState('');
const [working, setWorking] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault(); setWorking(true); onError('');
try {
const response = await fetch(legacy ? '/v1/session' : '/v1/users/session', {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: legacy ? { Authorization: `Bearer ${token.trim()}` } : { 'Content-Type': 'application/json' },
...(legacy ? {} : { body: JSON.stringify({ username, password }) }),
});
setPassword(''); setToken('');
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(typeof body.detail === 'string' ? body.detail : 'Sign-in failed.');
}
await onLogin(await response.json() as { csrf_token: string }, !legacy);
} catch (failure) { onError(failure instanceof Error ? failure.message : 'Sign-in failed.'); }
finally { setWorking(false); }
}
if (checking) return <p role="status">Restoring your session…</p>;
return <div>
<form className="connect-form" onSubmit={submit}>
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>
<label htmlFor="login-username">Username</label><input id="login-username" autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\-]*" />
<label htmlFor="login-password">Password</label><input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required maxLength={1024} />
</>}
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : 'Sign in'}</button>
</form>
<button className="text-button" disabled={busy || working} onClick={() => { setLegacy(!legacy); setPassword(''); setToken(''); }}>{legacy ? 'Use username and password' : 'Use an existing credential'}</button>
</div>;
}
+69
View File
@@ -0,0 +1,69 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('signs in with a password, creates an owned agent key and changes the password', async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'user-csrf' };
if (path === '/v1/projects') result = ['pilot'];
if (path === '/v1/me/agent-credentials' && init?.method === 'POST') result = { credential_id: 'agent:codex:fixture', token: 'agent-fixture-secret' };
if (path === '/v1/me/password') result = { status: 'password_changed' };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<Admin personal />);
await user.type(await screen.findByLabelText('Username'), 'alice');
await user.type(screen.getByLabelText('Password'), 'initial-password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByRole('heading', { name: 'Add an agent' });
expect(fetchMock).toHaveBeenCalledWith('/v1/users/session', expect.objectContaining({
method: 'POST', credentials: 'same-origin', body: JSON.stringify({ username: 'alice', password: 'initial-password-fixture' }),
}));
expect(screen.queryByRole('heading', { name: 'User accounts' })).toBeNull();
await user.type(screen.getByLabelText('Agent name'), 'codex-laptop');
await user.click(screen.getByLabelText('pilot'));
await user.click(screen.getByRole('button', { name: 'Create agent key' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('agent-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/me/agent-credentials', expect.objectContaining({
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'user-csrf' }),
body: JSON.stringify({ name: 'codex-laptop', project_ids: ['pilot'] }),
}));
await user.type(screen.getByLabelText('Current password'), 'initial-password-fixture');
await user.type(screen.getByLabelText('New password'), 'changed-password-fixture');
await user.type(screen.getByLabelText('Confirm new password'), 'changed-password-fixture');
await user.click(screen.getByRole('button', { name: 'Change password' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Password changed.');
});
it('lets the existing administrator create the first password account', async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
const result = path === '/v1/session' ? { csrf_token: 'operator-csrf' } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'operator-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Users' }));
const accounts = await screen.findByRole('region', { name: 'User accounts' });
await user.type(within(accounts).getByLabelText('New username'), 'vasily');
await user.type(within(accounts).getByLabelText('Initial password'), 'chosen-password-fixture');
await user.selectOptions(within(accounts).getByLabelText('Account role'), 'admin');
await user.click(within(accounts).getByRole('button', { name: 'Create user' }));
await screen.findByText(/Account created for vasily/);
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/users', expect.objectContaining({
body: JSON.stringify({ username: 'vasily', password: 'chosen-password-fixture', role: 'admin', project_ids: [] }),
}));
expect(within(accounts).getByLabelText('Initial password')).toHaveValue('');
});
+134
View File
@@ -0,0 +1,134 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
const key = { credential_id: 'reader-key', principal_id: 'reader', roles: ['viewer'], project_ids: ['pilot'], revoked: false };
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF', async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = {};
if (path === '/v1/session') result = { csrf_token: 'csrf-fixture' };
if (path.startsWith('/v1/admin/credentials?')) result = [key];
if (path.startsWith('/v1/admin/users?')) result = [];
if (path === '/v1/admin/projects') result = [{ project_id: 'pilot', title: 'Pilot research' }];
if (path === '/v1/admin/events?limit=50') result = [];
if (path === '/v1/admin/credentials' && init?.method === 'POST') result = { credential_id: 'new-key', token: 'one-time-fixture-secret' };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
await user.type(screen.getByLabelText('Credential ID'), 'new-key');
await user.type(screen.getByLabelText('Principal ID'), 'researcher');
await user.click(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('Pilot research'));
await user.click(screen.getByRole('button', { name: 'Create credential' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('one-time-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
body: JSON.stringify({ credential_id: 'new-key', principal_id: 'researcher', role: 'viewer', project_ids: ['pilot'] }),
}));
await user.click(screen.getByRole('button', { name: 'I have saved it — dismiss' }));
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Revoke reader-key' }));
expect(fetchMock.mock.calls.some(([path]) => path.endsWith('/reader-key'))).toBe(false);
const row = screen.getByRole('row', { name: /reader-key/ });
await user.click(within(row).getByRole('button', { name: 'Confirm revoke' }));
await screen.findByText('Revoked reader-key.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials/reader-key', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
}));
});
it('reports rejected connections without exposing management controls', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: 'Administrator role is required' }) })));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'viewer-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Administrator role is required');
expect(screen.queryByRole('heading', { name: 'Create a credential' })).toBeNull();
});
it('clears private panel state and permits reconnection after session expiry', async () => {
let expired = false;
vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (expired) return { ok: false, status: 401, json: async () => ({ detail: 'Browser session is invalid or expired' }) };
const result = path === '/v1/session' ? { csrf_token: 'csrf-fixture' }
: path.startsWith('/v1/admin/credentials?') ? [key] : [];
return { ok: true, json: async () => result };
}));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
expired = true;
await user.click(screen.getByRole('button', { name: 'Refresh' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByText('reader-key')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Your session ended.');
});
it('restores the admin session after remount and stops restoring after sign-out', async () => {
let active = true;
const storage = vi.spyOn(Storage.prototype, 'setItem');
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') {
return active ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) }
: { ok: false, status: 401, json: async () => ({}) };
}
if (path === '/v1/session' && init?.method === 'DELETE') active = false;
const result = path.startsWith('/v1/admin/credentials?') ? [key]
: path === '/v1/me' ? { principal_id: 'user:admin' } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
const first = render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(screen.queryByLabelText('Username')).toBeNull();
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Projects', 'Users', 'Credentials', 'Logs', 'Admin Settings']);
expect(screen.getAllByRole('tabpanel')).toHaveLength(1);
expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull();
await user.type(screen.getByLabelText('Project name'), 'Unfinished research');
const projectsTab = screen.getByRole('tab', { name: 'Projects' });
projectsTab.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Users' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'User accounts' })).toBeVisible();
await user.keyboard('{End}');
expect(screen.getByRole('tab', { name: 'Admin Settings' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'Change your password' })).toBeVisible();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
await user.keyboard('{Home}');
expect(projectsTab).toHaveFocus();
expect(screen.getByLabelText('Project name')).toHaveValue('Unfinished research');
first.unmount();
render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(fetchMock.mock.calls.filter(([path, init]) => path === '/v1/session' && init?.method === 'GET')).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'X-GEWU-Session': 'restore' },
}));
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'restored-csrf' }),
}));
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, 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: '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'];
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 refreshAdminData = useCallback(() => load(csrf), [load, csrf]);
const loadAdminPage = useCallback((page: number) => load(csrf, page), [load, csrf]);
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-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;
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef, type ReactNode } from 'react';
export function AdminDialog({ open, title, onClose, children, returnFocusRef }: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
returnFocusRef?: { current: HTMLElement | null };
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return <dialog ref={dialogRef} className="admin-dialog" aria-label={title}
onCancel={event => { event.preventDefault(); onClose(); }}
onClose={() => { onClose(); returnFocusRef?.current?.focus(); }}>
<div className="admin-heading"><h2>{title}</h2><button className="text-button" type="button" onClick={onClose}>Close</button></div>
{children}
</dialog>;
}
export function AdminList({ children, label }: { children: ReactNode; label: string }) {
return <ul className="admin-record-list" aria-label={label}>{children}</ul>;
}
export function AdminListItem({ title, summary, actions, children }: {
title: string;
summary?: string;
actions: ReactNode;
children?: ReactNode;
}) {
return <li className="admin-record"><div className="admin-record__top"><div><strong>{title}</strong>{summary && <p className="muted-copy">{summary}</p>}</div><div className="admin-record__actions">{actions}</div></div>{children}</li>;
}
+555
View File
@@ -0,0 +1,555 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation, useNavigate } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from './App';
const node = {
node_id: 'dome:season-visitors',
project_id: 'dome-performance',
title: 'Visitor count during the season',
summary: 'The installation hosted 400 visitors.',
node_type: 'finding',
tags: ['real-time', 'installation'],
audience: ['developer'],
use_for: ['planning'],
domain: ['interactive systems'],
assumptions: [{
text: 'The cited seasonal count is useful when comparing visitor patterns.',
evidence_type: 'reported',
assessment: {
status: 'supported',
method: 'paired source claim check',
rationale: 'The claim is recorded with a direct source reference.',
assessor_id: 'agent-pilot',
},
}],
limitations: [{
text: 'The source does not identify unique visitors.',
evidence_type: 'reported',
assessment: {
status: 'supported',
method: 'source completeness review',
rationale: 'The cited material does not provide unique visitor counts.',
assessor_id: 'agent-pilot',
},
}],
lifecycle: 'active',
as_of: '2026-10-13T00:00:00Z',
claims: [{
text: 'The installation hosted 400 visitors during the season.',
evidence_type: 'reported',
assessment: {
status: 'supported',
method: 'paired source claim check',
rationale: 'The cited sentence states the count.',
assessor_id: 'agent-pilot',
},
}],
};
const citation = {
source_revision_id: 'dome:pilot-report:r1',
extraction_id: 'a'.repeat(64),
quote: 'The installation hosted 400 visitors during the season.',
locator: { kind: 'text_span', block_id: 'block-1', start: 0, end: 54 },
};
function mockFetch(
searchMode: 'normal' | 'empty' | 'error' = 'normal',
resultNode = node,
resultCitation = citation,
restoreSession = false,
) {
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
const [input] = args;
const path = String(input);
let data: unknown;
if (path === '/v1/session' && args[1]?.method === 'GET') return restoreSession ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } as Response : { ok: false, status: 401, json: async () => ({}) } as Response;
if (path === '/v1/session') data = { csrf_token: 'gwc_test-token', expires_at: '2026-10-13T08:00:00Z' };
else if (path === '/v1/projects') data = ['dome-performance'];
else if (path === '/v1/search' && searchMode === 'error') {
return { ok: false, status: 503, json: async () => ({ detail: 'Search index unavailable.' }) } as Response;
}
else if (path === '/v1/search' && searchMode === 'empty') data = {
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/search') data = {
data: [{ node: resultNode, retrieval_score: -1.2 }],
citations: [resultCitation],
warnings: [],
omissions: [],
canonical_revision: '1'.repeat(40),
index_revision: '1'.repeat(40),
next_cursor: null,
};
else if (path.includes('/nodes/') && path.includes(`?revision=${'2'.repeat(40)}`)) data = {
data: [{ ...resultNode, title: 'Earlier canonical title' }], citations: [resultCitation], warnings: [], omissions: [],
canonical_revision: '2'.repeat(40), index_revision: null, next_cursor: null,
};
else if (path.includes('/nodes/') && !path.endsWith('/history') && !path.includes('?revision=')) data = {
data: [resultNode], citations: [resultCitation], warnings: [], omissions: [],
canonical_revision: '3'.repeat(40), index_revision: null, next_cursor: null,
};
else if (path.includes('/nodes/') && path.includes('?revision=')) data = {
data: [resultNode], citations: [resultCitation], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null,
};
else if (path === '/v1/neighbors') data = {
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/sources') data = {
data: [{
source: {
source_id: 'dome:pilot-report',
revision_id: 'dome:pilot-report:r1',
title: 'Pilot installation report',
source_type: 'internal',
publisher: 'Pilot team',
original_url: null,
captured_at: '2026-10-13T00:00:00Z',
archive_status: 'archived',
license: 'internal',
reliability_grade: 'medium',
ingested_for: ['dome-performance'],
},
}],
citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path.endsWith('/history')) data = {
data: [
{ revision: '1'.repeat(40), node },
{ revision: '2'.repeat(40), node: { ...node, title: 'Earlier canonical title' } },
], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null,
};
else if (path.includes('/extractions/')) data = {
source_revision_id: resultCitation.source_revision_id,
extraction_id: resultCitation.extraction_id,
parser_name: 'gewu-text', parser_version: '0.1.0',
block_id: resultCitation.locator.block_id, offset: resultCitation.locator.start,
normalized_text: resultCitation.quote, original_text: resultCitation.quote,
normalization_map: Array.from({ length: resultCitation.quote.length }, (_, index) => [index, index + 1]),
};
else if (path.includes('/sources/')) data = {
data: [{
revision_id: 'dome:pilot-report:r1', title: 'Pilot installation report',
source_type: 'internal', publisher: 'Pilot team', original_url: null,
captured_at: '2026-10-13T00:00:00Z', archive_status: 'archived', license: 'internal',
}], citations: [], warnings: [], omissions: [], canonical_revision: '1'.repeat(40),
index_revision: null, next_cursor: null,
};
else data = {
data: [resultNode], citations: [resultCitation], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null,
};
return { ok: true, json: async () => data } as Response;
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function CurrentRoute() {
const location = useLocation();
return <output aria-label="Current search URL">{location.search}</output>;
}
function HistoryControls() {
const navigate = useNavigate();
return (
<div>
<button type="button" onClick={() => navigate(-1)}>Back in history</button>
<button type="button" onClick={() => navigate(1)}>Forward in history</button>
</div>
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('GEWU research reader', () => {
it('connects, searches, reads exact evidence, and opens authorized source metadata', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance');
await user.click(screen.getByRole('link', { name: /stale knowledge/i }));
expect(screen.getByLabelText('Past freshness date')).toBeChecked();
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('As of from'), '2024-01-01');
await user.type(screen.getByLabelText('As of to'), '2024-12-31');
await user.click(screen.getByLabelText('medium'));
await user.click(screen.getByRole('button', { name: 'Search' }));
const result = await screen.findByRole('button', { name: /visitor count during the season/i });
expect(result).toBeVisible();
await user.click(result);
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors');
expect((await screen.findAllByText(citation.quote, { exact: false })).length).toBeGreaterThan(0);
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Assumptions and limitations' })).toBeVisible();
expect(screen.getByText(/The source does not identify unique visitors\./)).toBeVisible();
expect(screen.getByText('The cited sentence states the count.')).toBeVisible();
expect(screen.getByText(/not a truth probability/)).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Relationships' })).toBeVisible();
expect(screen.getByRole('region', { name: 'Knowledge facets' })).toHaveTextContent('real-time · installation');
expect(screen.getByText(/No accepted relationships are visible/)).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Find related suggestions' }));
expect(await screen.findByText(/Related suggestions loaded/)).toBeVisible();
await user.click(screen.getByRole('button', { name: /open source record/i }));
expect((await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length)
.toBeGreaterThan(0);
expect(
screen.getAllByRole('link', { name: 'Download archived revision' }).some(
(link) =>
link.getAttribute('href') ===
'/v1/projects/dome-performance/sources/dome%3Apilot-report%3Ar1/artifact',
),
).toBe(true);
await user.click(screen.getByRole('button', { name: /open exact source location/i }));
expect(await screen.findByRole('region', { name: 'Exact source location' })).toBeVisible();
expect(screen.getByText('Original captured text')).toBeVisible();
expect(screen.getAllByText(citation.quote, { selector: 'pre' })).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/extractions/'),
expect.objectContaining({ credentials: 'same-origin' }),
);
expect(fetchMock).toHaveBeenCalledWith(
'/v1/session',
expect.objectContaining({ headers: { Authorization: 'Bearer viewer-secret' } }),
);
expect(fetchMock).toHaveBeenCalledWith(
'/v1/search',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'gwc_test-token' }),
}),
);
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
stale_only: true,
confidences: ['medium'],
as_of_from: '2024-01-01',
as_of_to: '2024-12-31',
});
expect(localStorage.length).toBe(0);
expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('link', { name: /sources/i }));
await user.click(screen.getByRole('button', { name: 'Load source records' }));
expect(await screen.findByRole('heading', { name: 'Sources' })).toBeVisible();
expect(
(await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length,
).toBeGreaterThan(0);
expect(
screen.getAllByRole('link', { name: 'Download archived revision' }).some(
(link) =>
link.getAttribute('href') ===
'/v1/projects/dome-performance/sources/dome%3Apilot-report%3Ar1/artifact',
),
).toBe(true);
});
it('shows exact cue times for transcript citations', async () => {
const user = userEvent.setup();
const transcriptCitation = {
...citation,
locator: {
kind: 'transcript_timestamp_span',
block_id: 'cue-1',
start: 48,
end: 102,
time_start_ms: 872000,
time_end_ms: 876500,
},
};
mockFetch('normal', node, transcriptCitation);
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
expect(await screen.findByText(/Transcript · 00:14:32\.000–00:14:36\.500 · cue-1 · 48–102/)).toBeVisible();
});
it('shows a distinct initial reading state and no knowledge mutation controls', async () => {
mockFetch();
render(<MemoryRouter><App /></MemoryRouter>);
expect(screen.getByRole('heading', { level: 1, name: /find the evidence behind an idea/i })).toBeVisible();
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument();
});
it.each([
['empty', 'No matching accepted notes'],
['error', 'Search could not be completed'],
] as const)('distinguishes %s search outcomes from the initial state', async (mode, heading) => {
const user = userEvent.setup();
mockFetch(mode);
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'unmatched');
await user.click(screen.getByRole('button', { name: 'Search' }));
expect(await screen.findByRole('heading', { name: heading })).toBeVisible();
if (mode === 'error') expect(screen.getByText('Search index unavailable.')).toBeVisible();
});
it('routes Problems and Topics through the typed search facets', async () => {
const user = userEvent.setup();
mockFetch();
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(screen.getByRole('link', { name: /problems/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('problem');
await user.click(screen.getByRole('link', { name: /topics/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('concept');
});
it('applies exact tag, audience, use and domain facets and stores them in the URL', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('Tag'), 'installation');
await user.type(screen.getByLabelText('Audience'), 'developer');
await user.type(screen.getByLabelText('Use'), 'planning');
await user.type(screen.getByLabelText('Domain'), 'interactive systems');
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search')).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Search' }));
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
tags: ['installation'],
audience: ['developer'],
use_for: ['planning'],
domain: ['interactive systems'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent(
'tag=installation&audience=developer&use=planning&domain=interactive+systems',
);
});
it('restores a shared search URL after connecting with its query and exact facets', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(
<MemoryRouter initialEntries={['/?project=dome-performance&q=visitors&tag=installation&audience=developer&confidence=medium&as_of_from=2024-01-01&as_of_to=2024-12-31']}>
<App />
<CurrentRoute />
</MemoryRouter>,
);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search');
expect(searchCalls).toHaveLength(1);
expect(JSON.parse(String(searchCalls[0]?.[1]?.body))).toMatchObject({
query: 'visitors',
scope: { projects: ['dome-performance'] },
tags: ['installation'],
audience: ['developer'],
confidences: ['medium'],
as_of_from: '2024-01-01',
as_of_to: '2024-12-31',
});
expect(screen.getByLabelText('medium')).toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('2024-12-31');
});
it('restores prior searches with one Back step and reruns them on browser navigation', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(
<MemoryRouter initialEntries={['/?project=dome-performance']}>
<App />
<CurrentRoute />
<HistoryControls />
</MemoryRouter>,
);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
const queryBox = screen.getByRole('textbox', { name: /search claims/i });
await user.type(queryBox, 'visitors');
await user.click(screen.getByLabelText('medium'));
await user.type(screen.getByLabelText('As of from'), '2024-01-01');
await user.click(screen.getByRole('button', { name: 'Search' }));
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium');
await user.clear(queryBox);
await user.type(queryBox, 'methods');
await user.click(screen.getByLabelText('medium'));
await user.type(screen.getByLabelText('As of to'), '2026-12-31');
await user.click(screen.getByRole('button', { name: 'Search' }));
await screen.findByText('Search complete. 1 result.');
await user.click(screen.getByRole('button', { name: 'Back in history' }));
await screen.findByText('Search complete. 1 result.');
expect(queryBox).toHaveValue('visitors');
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium');
expect(screen.getByLabelText('medium')).toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('');
let searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors']);
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
await screen.findByText('Search complete. 1 result.');
expect(queryBox).toHaveValue('methods');
expect(screen.getByLabelText('medium')).not.toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('2026-12-31');
searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors', 'methods']);
});
it('labels the problem reading view and its missing accepted approaches', async () => {
const user = userEvent.setup();
mockFetch('normal', { ...node, node_type: 'problem' });
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitor problem');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Approaches and related knowledge' })).toBeVisible();
expect(screen.getByText(/No accepted approaches or other relationships are visible/)).toBeVisible();
});
it('browses problem records within the selected scope without requiring a text query', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch('normal', { ...node, node_type: 'problem' });
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('button', { name: 'Browse problems' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
query: '',
scope: { projects: ['dome-performance'] },
node_types: ['problem'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('type=problem');
});
it('restores a linked canonical record after connecting with a shared node URL', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(
<MemoryRouter initialEntries={['/?project=dome-performance&node=dome%3Aseason-visitors&node_project=dome-performance&revision=' + '2'.repeat(40)]}>
<App />
<CurrentRoute />
</MemoryRouter>,
);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith(
`/v1/projects/dome-performance/nodes/dome%3Aseason-visitors?revision=${'2'.repeat(40)}`,
expect.objectContaining({ credentials: 'same-origin' }),
);
});
it('clears and restores the reader selection with browser back and forward', async () => {
const user = userEvent.setup();
mockFetch();
render(
<MemoryRouter initialEntries={['/?project=dome-performance']}>
<App />
<CurrentRoute />
<HistoryControls />
</MemoryRouter>,
);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Back in history' }));
expect(await screen.findByText('Select a result to read its accepted summary, exact citations, source metadata, and revision history.')).toBeVisible();
expect(screen.getByLabelText('Current search URL')).not.toHaveTextContent('node=');
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors');
});
it('loads and shares a selected historical node revision', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(<MemoryRouter><App /><CurrentRoute /><HistoryControls /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByText('Revision history (2)'));
const neighborCallsBeforeHistoricalRead = fetchMock.mock.calls.filter(
([url]) => String(url) === '/v1/neighbors',
).length;
await user.click(screen.getByRole('button', { name: /earlier canonical title/i }));
expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible();
expect(screen.getByText(/Revision-pinned view\. Relationship links are omitted/)).toBeVisible();
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('revision=2222222222');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('?revision=2222222222'),
expect.objectContaining({ credentials: 'same-origin' }),
);
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/neighbors')).toHaveLength(
neighborCallsBeforeHistoricalRead,
);
await user.click(screen.getByRole('button', { name: 'Back in history' }));
expect(await screen.findByRole('heading', { name: 'Visitor count during the season' })).toBeVisible();
expect(screen.getByLabelText('Current search URL')).not.toHaveTextContent('revision=');
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible();
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('revision=2222222222');
});
});
it('restores library access from the existing browser session without entering a password', async () => {
const fetchMock = mockFetch('normal', node, citation, true);
render(<MemoryRouter initialEntries={['/?project=dome-performance']}><App /></MemoryRouter>);
expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance');
expect(screen.queryByLabelText('Username')).toBeNull();
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'GET', credentials: 'same-origin', headers: { 'X-GEWU-Session': 'restore' },
}));
});
File diff suppressed because it is too large. Load diff
+82
View File
@@ -0,0 +1,82 @@
import { memo, useRef, useState, type FormEvent } from 'react';
import type { components } from '../generated/api';
import { accountRequest } from './account-api';
import { AdminDialog, AdminList, AdminListItem } from './AdminPrimitives';
type Credential = components['schemas']['CredentialSummary'];
type Project = { project_id: string; title: string };
type Issued = { credential_id: string; token: string };
function CredentialsSection({ csrf, personal, credentials, projects, offset, onPageChange, onRefresh, onIssued, onError, onMessage }: {
csrf: string; personal: boolean; credentials: Credential[]; projects: Project[]; offset: number;
onPageChange: (page: number) => Promise<void>; onRefresh: () => Promise<void>; onIssued: (issued: Issued) => void;
onError: (error: string) => void; onMessage: (message: string) => void;
}) {
const [addOpen, setAddOpen] = useState(false);
const addButtonRef = useRef<HTMLButtonElement>(null);
const [credentialId, setCredentialId] = useState('');
const [principalId, setPrincipalId] = useState('');
const [role, setRole] = useState(personal ? 'agent' : 'viewer');
const [grants, setGrants] = useState<string[]>([]);
const [editId, setEditId] = useState<string | null>(null);
const [editRole, setEditRole] = useState('viewer');
const [editGrants, setEditGrants] = useState<string[]>([]);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function perform(action: () => Promise<void>) {
setBusy(true); onError('');
try { await action(); }
catch (error) { onError(error instanceof Error ? error.message : 'Credential operation failed.'); }
finally { setBusy(false); }
}
async function create(event: FormEvent) {
event.preventDefault();
await perform(async () => {
const result = await accountRequest<Issued>(personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials', csrf, {
method: 'POST', body: JSON.stringify(personal ? { name: credentialId, project_ids: grants } : { credential_id: credentialId, principal_id: principalId, role, project_ids: grants }),
});
onIssued(result); setAddOpen(false); setCredentialId(''); setPrincipalId(''); setGrants([]);
onMessage('Credential created. Save its secret before dismissing it.'); await onRefresh();
});
}
function startEdit(key: Credential) { setEditId(key.credential_id); setEditRole(key.roles[0] ?? 'viewer'); setEditGrants(key.project_ids); setRevokeId(null); }
async function saveEdit(event: FormEvent) {
event.preventDefault(); if (!editId) return;
await perform(async () => {
await accountRequest(`/v1/admin/credentials/${encodeURIComponent(editId)}`, csrf, { method: 'PATCH', body: JSON.stringify({ role: editRole, project_ids: editRole === 'admin' ? [] : editGrants }) });
onMessage(`Updated ${editId}.`); setEditId(null); await onRefresh();
});
}
return <section className="admin-card" aria-labelledby="credentials-title">
<div className="admin-heading"><div><h2 id="credentials-title">Credentials</h2><p className="muted-copy">Manage service keys and project grants.</p></div>
<button ref={addButtonRef} className="quiet-button" type="button" onClick={() => setAddOpen(true)}>{personal ? 'Add agent key' : 'Add credential'}</button>
<button className="text-button" disabled={busy} onClick={() => void perform(onRefresh)}>Refresh</button></div>
<AdminList label="Credentials">{credentials.map(key => <AdminListItem key={key.credential_id} title={key.credential_id}
summary={`${key.principal_id} · ${key.roles.join(', ')} · ${key.roles.includes('admin') ? 'All projects' : key.project_ids.join(', ') || 'No projects'} · ${key.revoked ? 'Revoked' : 'Active'}`}
actions={!key.revoked && <>{!personal && <button className="text-button" type="button" onClick={() => startEdit(key)}>Edit</button>}<button className="text-button" type="button" onClick={() => { setRevokeId(key.credential_id); setEditId(null); }}>Revoke</button></>}>
{editId === key.credential_id && <form className="admin-form connect-form record-edit-form" onSubmit={saveEdit}>
<label>Role<select value={editRole} onChange={event => { setEditRole(event.target.value); setEditGrants([]); }}><option value="viewer">Viewer</option><option value="editor">Editor</option><option value="agent">Agent</option><option value="admin">Administrator</option></select></label>
{editRole !== 'admin' && <fieldset><legend>Project access</legend>{projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={editGrants.includes(project.project_id)} onChange={event => setEditGrants(event.target.checked ? [...editGrants, project.project_id] : editGrants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>}
<div className="admin-wide"><button disabled={busy}>Save changes</button> <button className="text-button" type="button" onClick={() => setEditId(null)}>Cancel</button></div>
</form>}
{revokeId === key.credential_id && <div className="record-confirm"><p>Revoke this key? Its sessions and agent access will stop.</p><button className="quiet-button" disabled={busy} onClick={() => void perform(async () => {
await accountRequest(`${personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials'}/${encodeURIComponent(key.credential_id)}`, csrf, { method: 'DELETE' });
setRevokeId(null); onMessage(`Revoked ${key.credential_id}.`); await onRefresh();
})}>Confirm revoke</button> <button className="text-button" type="button" onClick={() => setRevokeId(null)}>Cancel</button></div>}
</AdminListItem>)}</AdminList>
{!credentials.length && <p>No credentials on this page.</p>}
<div className="admin-heading"><button className="quiet-button" disabled={busy || offset === 0} onClick={() => void perform(() => onPageChange(Math.max(0, offset - 50)))}>Previous</button><span>Page {Math.floor(offset / 50) + 1}</span><button className="quiet-button" disabled={busy || credentials.length < 50} onClick={() => void perform(() => onPageChange(offset + 50))}>Next</button></div>
<AdminDialog open={addOpen} title={personal ? 'Add agent key' : 'Add credential'} onClose={() => setAddOpen(false)} returnFocusRef={addButtonRef}>
<form className="admin-form connect-form" onSubmit={create}>
<label>{personal ? 'Agent name' : 'Credential ID'}<input value={credentialId} onChange={event => setCredentialId(event.target.value)} required maxLength={personal ? 64 : 128} pattern={personal ? '[A-Za-z0-9][A-Za-z0-9._\\-]*' : '[A-Za-z0-9][A-Za-z0-9._:\\-]*'} placeholder="research-agent-laptop" /></label>
{!personal && <label>Principal ID<input value={principalId} onChange={event => setPrincipalId(event.target.value)} required maxLength={128} pattern="[A-Za-z0-9][A-Za-z0-9._:\\-]*" placeholder="researcher" /></label>}
{!personal && <label>Role<select value={role} onChange={event => { setRole(event.target.value); setGrants([]); }}><option value="viewer">Viewer</option><option value="editor">Editor</option><option value="agent">Agent</option><option value="admin">Administrator</option></select></label>}
<fieldset><legend>Project access</legend>{role === 'admin' ? <p>All projects</p> : projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={grants.includes(project.project_id)} onChange={event => setGrants(event.target.checked ? [...grants, project.project_id] : grants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>
<button disabled={busy || (role !== 'admin' && !grants.length)}>{personal ? 'Create agent key' : 'Create credential'}</button>
</form>
</AdminDialog>
</section>;
}
export default memo(CredentialsSection);
+60
View File
@@ -0,0 +1,60 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a project from the admin form and makes it available for account and agent grants', async () => {
let created = false;
let conflict = true;
const project = {
schema_version: 1, project_id: 'my-research', title: 'My research', status: 'active',
north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'],
owners: ['user:admin'], autonomy_policy_id: 'my-research-policy',
};
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'csrf-fixture' };
if (path === '/v1/me') result = { principal_id: 'user:admin' };
if (path === '/v1/admin/projects') {
if (init?.method === 'POST') {
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: 'Project ID is already registered' }) };
created = true; result = project;
} else result = created ? [project] : [];
}
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.type(await screen.findByLabelText('Username'), 'admin');
await user.type(screen.getByLabelText('Password'), 'password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
const projects = await screen.findByRole('region', { name: 'Projects' });
expect(within(projects).getByText('No projects yet. Create your first project below.')).toBeVisible();
expect(within(projects).getByLabelText(/Owner ID/)).toHaveValue('user:admin');
await user.type(within(projects).getByLabelText('Project name'), 'My research');
expect(within(projects).getByLabelText(/Project ID/)).toHaveValue('my-research');
await user.type(within(projects).getByLabelText('Project purpose'), 'Investigate useful techniques.');
await user.type(within(projects).getByLabelText('Key research questions'), 'What works?\nUnder what conditions?');
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Project ID is already registered');
expect(within(projects).getByLabelText('Project name')).toHaveValue('My research');
conflict = false;
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
await screen.findByText('Created My research. It is now available for user access and agent keys.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/projects', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
body: JSON.stringify({
project: { project_id: 'my-research', title: 'My research', status: 'active', owners: ['user:admin'], north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], autonomy_policy_id: 'my-research-policy', default_includes: [] },
policy: { policy_id: 'my-research-policy', policy_version: 1, project_id: 'my-research', automatic_source_registration: true, automatic_node_types: ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'], require_supported_assessment: true },
}),
}));
expect(within(projects).getByLabelText('Project name')).toHaveValue('');
await user.click(screen.getByRole('tab', { name: 'Users' }));
expect(within(screen.getByRole('region', { name: 'User accounts' })).getByLabelText('My research')).toBeVisible();
await user.click(screen.getByRole('tab', { name: 'Credentials' }));
expect(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('My research')).toBeVisible();
});
+195
View File
@@ -0,0 +1,195 @@
import { memo, useRef, useState, type FormEvent } from 'react';
import type { ReactNode } from 'react';
import type { components } from '../generated/api';
import { AdminDialog } from './AdminPrimitives';
import { accountRequest } from './account-api';
type Project = components['schemas']['ProjectRecord'];
const researchTypes = ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'] as const;
function inlineMarkdown(text: string): ReactNode[] {
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))/g;
const nodes: ReactNode[] = [];
let cursor = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > cursor) nodes.push(text.slice(cursor, index));
const token = match[0];
if (token.startsWith('`')) nodes.push(<code key={index}>{token.slice(1, -1)}</code>);
else if (token.startsWith('**')) nodes.push(<strong key={index}>{token.slice(2, -2)}</strong>);
else if (token.startsWith('*')) nodes.push(<em key={index}>{token.slice(1, -1)}</em>);
else nodes.push(<a key={index} href={match[3]} target="_blank" rel="noreferrer">{match[2]}</a>);
cursor = index + token.length;
}
if (cursor < text.length) nodes.push(text.slice(cursor));
return nodes;
}
function ProjectMarkdown({ source }: { source: string }) {
const lines = source.replace(/\r\n?/g, '\n').split('\n');
const blocks: ReactNode[] = [];
let paragraph: string[] = [];
let list: { ordered: boolean; items: string[] } | null = null;
const flushParagraph = () => {
if (paragraph.length) blocks.push(<p key={`p-${blocks.length}`}>{inlineMarkdown(paragraph.join(' '))}</p>);
paragraph = [];
};
const flushList = () => {
if (!list) return;
const Tag = list.ordered ? 'ol' : 'ul';
blocks.push(<Tag key={`list-${blocks.length}`}>{list.items.map((item, index) => <li key={index}>{inlineMarkdown(item)}</li>)}</Tag>);
list = null;
};
for (const line of lines) {
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
const bullet = /^\s*[-*+]\s+(.+)$/.exec(line);
const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line);
if (!line.trim()) { flushParagraph(); flushList(); continue; }
if (heading) {
flushParagraph(); flushList();
const level = heading[1].length;
const content = inlineMarkdown(heading[2]);
if (level === 1) blocks.push(<h2 key={`h-${blocks.length}`}>{content}</h2>);
else if (level === 2) blocks.push(<h3 key={`h-${blocks.length}`}>{content}</h3>);
else if (level === 3) blocks.push(<h4 key={`h-${blocks.length}`}>{content}</h4>);
else blocks.push(<h5 key={`h-${blocks.length}`}>{content}</h5>);
continue;
}
if (bullet || numbered) {
flushParagraph();
const ordered = !!numbered;
if (list && list.ordered !== ordered) flushList();
list ??= { ordered, items: [] };
list.items.push((bullet ?? numbered)![1]);
continue;
}
flushList(); paragraph.push(line.trim());
}
flushParagraph(); flushList();
return <div className="project-markdown">{blocks}</div>;
}
function ProjectsSection({ csrf, projects, currentOwner, onCreated, onError, onMessage }: {
csrf: string;
projects: Project[];
currentOwner: string;
onCreated: () => Promise<void>;
onError: (message: string) => void;
onMessage: (message: string) => void;
}) {
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [purpose, setPurpose] = useState('');
const [questions, setQuestions] = useState('');
const [owner, setOwner] = useState(currentOwner);
const [automatic, setAutomatic] = useState(true);
const [busy, setBusy] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const createButtonRef = useRef<HTMLButtonElement>(null);
const viewButtonRef = useRef<HTMLButtonElement>(null);
const [viewing, setViewing] = useState<Project | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [editDescription, setEditDescription] = useState('');
const [editPurpose, setEditPurpose] = useState('');
const [editQuestions, setEditQuestions] = useState('');
async function create(event: FormEvent) {
event.preventDefault(); onError('');
const keyQuestions = questions.split('\n').map(q => q.trim()).filter(Boolean);
if (!title.trim() || !purpose.trim() || !keyQuestions.length || keyQuestions.length > 30) {
onError('Enter a project name, purpose, and between 1 and 30 research questions.'); return;
}
setBusy(true);
try {
await accountRequest('/v1/admin/projects', csrf, {
method: 'POST',
body: JSON.stringify({
project: {
project_id: slug, title: title.trim(), status: 'active', owners: [owner.trim()],
north_star: purpose.trim(), key_questions: keyQuestions,
autonomy_policy_id: `${slug}-policy`, default_includes: [],
},
policy: {
policy_id: `${slug}-policy`, policy_version: 1, project_id: slug,
automatic_source_registration: automatic,
automatic_node_types: automatic ? [...researchTypes] : [],
require_supported_assessment: true,
},
}),
});
setTitle(''); setSlug(''); setSlugEdited(false); setPurpose(''); setQuestions('');
onMessage(`Created ${title.trim()}. It is now available for user access and agent keys.`);
setCreateOpen(false);
await onCreated();
} catch (error) { onError(error instanceof Error ? error.message : 'Could not create project.'); }
finally { setBusy(false); }
}
function beginEdit(project: Project) {
setEditing(project.project_id);
setEditDescription(project.description ?? '');
setEditPurpose(project.north_star);
setEditQuestions(project.key_questions.join('\n'));
}
async function saveEdit(event: FormEvent) {
event.preventDefault();
if (!editing) return;
const keyQuestions = editQuestions.split('\n').map(question => question.trim()).filter(Boolean);
if (!editPurpose.trim() || !keyQuestions.length || keyQuestions.length > 30) {
onError('Enter a project purpose and between 1 and 30 research questions.'); return;
}
setBusy(true); onError('');
try {
await accountRequest(`/v1/admin/projects/${encodeURIComponent(editing)}`, csrf, {
method: 'PATCH', body: JSON.stringify({ description: editDescription.trim(), north_star: editPurpose.trim(), key_questions: keyQuestions }),
});
onMessage(`Updated ${editing}.`); setEditing(null); await onCreated();
} catch (error) { onError(error instanceof Error ? error.message : 'Could not update project.'); }
finally { setBusy(false); }
}
return <section id="projects" className="admin-card" aria-labelledby="projects-title">
<div className="admin-heading"><div><h2 id="projects-title">Projects</h2><p className="muted-copy">Manage research scopes, descriptions, and questions.</p></div>
<button ref={createButtonRef} className="quiet-button" type="button" onClick={() => setCreateOpen(true)}>Create project</button>
</div>
{projects.length ? <ul className="admin-projects">{projects.map(project => <li key={project.project_id}>
<div className="admin-heading"><strong>{project.title}</strong><div className="project-actions">
<button className="text-button" type="button" onClick={event => { viewButtonRef.current = event.currentTarget; setViewing(project); }}>View</button>
<button className="text-button" type="button" onClick={() => beginEdit(project)}>Edit</button>
</div></div>
{editing === project.project_id && <form className="admin-form connect-form project-edit-form" onSubmit={saveEdit}>
<label className="admin-wide">Description<textarea maxLength={4000} rows={3} value={editDescription} onChange={event => setEditDescription(event.target.value)} /></label>
<label className="admin-wide">Project purpose<textarea required maxLength={4000} rows={3} value={editPurpose} onChange={event => setEditPurpose(event.target.value)} /></label>
<label className="admin-wide">Key research questions<textarea required maxLength={16000} rows={4} value={editQuestions} onChange={event => setEditQuestions(event.target.value)} placeholder="One question per line" /></label>
<div className="admin-wide"><button disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button> <button className="text-button" type="button" disabled={busy} onClick={() => setEditing(null)}>Cancel</button></div>
</form>}
</li>)}</ul> : <p>No projects yet.</p>}
<AdminDialog open={Boolean(viewing)} title={viewing?.title ?? 'Project details'} onClose={() => setViewing(null)} returnFocusRef={viewButtonRef}>
{viewing && <>
<p className="muted-copy">{viewing.project_id} · {viewing.status}</p>
{viewing.description && <><h3>Description</h3><ProjectMarkdown source={viewing.description} /></>}
<h3>Project purpose</h3><ProjectMarkdown source={viewing.north_star} />
<h3>Key research questions</h3><ul>{viewing.key_questions.map((question, index) => <li key={`${index}-${question}`}><ProjectMarkdown source={question} /></li>)}</ul>
</>}
</AdminDialog>
<AdminDialog open={createOpen} title="Create a project" onClose={() => setCreateOpen(false)} returnFocusRef={createButtonRef}>
<form className="admin-form connect-form" onSubmit={create}>
<label>Project name<input required maxLength={200} value={title} placeholder="My research" onChange={event => {
setTitle(event.target.value);
if (!slugEdited) setSlug(event.target.value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 96).replace(/-$/g, ''));
}} /></label>
<label>Project ID<input required maxLength={96} pattern="[a-z0-9]+(-[a-z0-9]+)*" value={slug} onChange={event => { setSlug(event.target.value); setSlugEdited(true); }} placeholder="my-research" /><span className="muted-copy">A stable name using lowercase letters, numbers and hyphens.</span></label>
<label>Owner ID<input required maxLength={128} pattern="[A-Za-z0-9][A-Za-z0-9._:\\-]*" value={owner} onChange={event => setOwner(event.target.value)} /><span className="muted-copy">Defaults to your signed-in identity.</span></label>
<label className="admin-wide">Project purpose<textarea required maxLength={4000} rows={3} value={purpose} onChange={event => setPurpose(event.target.value)} placeholder="What should this project investigate or achieve?" /></label>
<label className="admin-wide">Key research questions<textarea required maxLength={16000} rows={3} value={questions} onChange={event => setQuestions(event.target.value)} placeholder="One question per line" /></label>
<fieldset><legend>Agent contributions</legend><label className="admin-check"><input type="checkbox" checked={automatic} onChange={event => setAutomatic(event.target.checked)} />Accept eligible research contributions automatically</label><p className="muted-copy">Allows source registration and evidence-backed research records that pass service validation. Deletions, merges and changes to project intent or policy still require owner authorization. Uncheck to require review for contributions; owner review authentication is configured separately.</p></fieldset>
<div className="admin-wide"><button disabled={busy}>{busy ? 'Creating project…' : 'Create project'}</button> <button className="text-button" type="button" disabled={busy} onClick={() => setCreateOpen(false)}>Cancel</button></div>
</form>
</AdminDialog>
</section>;
}
export default memo(ProjectsSection);
+16
View File
@@ -0,0 +1,16 @@
export class AccountRequestError extends Error {
status: number;
constructor(message: string, status: number) { super(message); this.status = status; }
}
export async function accountRequest<T>(path: string, csrf = '', init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init, credentials: 'same-origin', cache: 'no-store',
headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-GEWU-CSRF': csrf } : {}), ...init.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new AccountRequestError(typeof body.detail === 'string' ? body.detail : `Request failed (${response.status}). Check the supplied fields.`, response.status);
}
return response.json() as Promise<T>;
}
+302
View File
@@ -0,0 +1,302 @@
:root {
font-family: "Source Sans 3", "Segoe UI", sans-serif;
color: var(--text-primary);
background: var(--surface-canvas);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--surface-canvas: #f4f1ea;
--surface-paper: #fffdf8;
--surface-subtle: #eae5da;
--surface-rail: #ece8df;
--surface-inset: #f5f2eb;
--surface-warning: #f7edda;
--surface-selected: #f7f5ef;
--surface-input: #fff;
--surface-preview: #fffefa;
--text-primary: #242722;
--text-secondary: #454a44;
--text-muted: #596057;
--text-nav: #3e443e;
--text-nav-muted: #62675f;
--text-warning: #67410c;
--border-subtle: #d6d0c3;
--border-control: #777c71;
--accent-link: #315d50;
--accent-strong: #25493f;
--status-caution: #85520b;
--status-error: #a1342b;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
}
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
button, input, select { font: inherit; }
button, select { cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.65; }
a { color: var(--accent-link); text-underline-offset: 3px; }
a:hover { color: var(--accent-strong); }
:focus-visible { outline: 3px solid var(--accent-link); outline-offset: 3px; }
.skip-link {
position: fixed;
z-index: 10;
top: var(--space-3);
left: var(--space-3);
padding: var(--space-3) var(--space-4);
transform: translateY(-160%);
background: var(--surface-paper);
border: 1px solid var(--border-control);
}
.skip-link:focus { transform: translateY(0); }
.app-shell { min-height: 100vh; }
.rail {
position: fixed;
inset: 0 auto 0 0;
display: flex;
flex-direction: column;
width: 224px;
padding: 28px 18px 20px;
background: var(--surface-rail);
border-right: 1px solid var(--border-subtle);
}
.brand { display: flex; align-items: center; gap: 12px; color: inherit; font-size: 1.1rem; font-weight: 750; letter-spacing: 0.03em; text-decoration: none; }
.brand__mark { display: grid; width: 38px; height: 38px; place-items: center; color: var(--surface-paper); background: var(--accent-link); font-family: "Source Serif 4", Georgia, serif; font-size: 1.55rem; font-weight: 500; }
.brand__sub { display: block; margin-top: 3px; color: var(--text-muted); font-size: 0.63rem; letter-spacing: 0.12em; }
.rail-nav { display: grid; gap: 6px; margin-top: 64px; }
.rail-nav__item { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 9px 11px; color: var(--text-nav); font-size: 0.98rem; text-decoration: none; border-radius: 3px; }
.rail-nav__item > span:first-child { width: 20px; font-size: 1.2rem; text-align: center; }
.rail-nav__item--active { color: var(--surface-paper); background: var(--accent-link); }
.rail-nav__item--active:hover { color: white; background: var(--accent-strong); }
.rail-nav__item--muted { color: var(--text-nav-muted); }
.rail__footer { margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
.rail__footer > p:nth-child(2) { overflow-wrap: anywhere; margin: 8px 0 4px; font-size: 0.95rem; font-weight: 650; }
.scope-note, .muted-copy { color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
.workspace { max-width: 1580px; min-height: 100vh; margin-left: 224px; padding: 42px clamp(24px, 4vw, 64px) 24px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 26px; border-bottom: 1px solid var(--border-subtle); }
.eyebrow { margin: 0; color: var(--accent-link); font-size: 0.73rem; font-weight: 750; letter-spacing: 0.1em; }
.topbar h1 { margin: 10px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: clamp(2rem, 3.2vw, 3.15rem); font-weight: 500; letter-spacing: -0.025em; line-height: 1.08; }
.quiet-button, .text-button, .more-button { min-height: 44px; border: 0; background: transparent; color: var(--accent-link); font-weight: 650; }
.quiet-button { padding: 8px 13px; border: 1px solid var(--border-control); border-radius: 3px; }
.quiet-button:hover, .more-button:hover { background: var(--surface-subtle); }
.connect-card { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 390px); gap: 48px; max-width: 940px; margin: 72px auto; padding: clamp(24px, 5vw, 56px); background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.connect-card h2 { max-width: 14ch; margin: 10px 0 14px; font-family: "Source Serif 4", Georgia, serif; font-size: 2.2rem; font-weight: 500; line-height: 1.1; }
.connect-card p:not(.eyebrow) { max-width: 47ch; color: var(--text-muted); line-height: 1.55; }
.connect-form { display: grid; align-content: center; gap: 10px; }
.connect-form label, .scope-field > span { color: var(--text-muted); font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; }
.connect-form input, .search-field input, .scope-field select { min-width: 0; min-height: 48px; padding: 10px 12px; color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); border-radius: 2px; }
.connect-form button, .search-form > button { min-height: 48px; padding: 10px 20px; color: white; background: var(--accent-link); border: 1px solid var(--accent-link); border-radius: 2px; font-weight: 700; }
.connect-form button:hover:not(:disabled), .search-form > button:hover:not(:disabled) { background: var(--accent-strong); }
.search-panel { margin: 27px 0 31px; padding: 18px 20px 14px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.search-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(150px, 200px) minmax(150px, 200px) auto auto; gap: 12px; align-items: end; }
.search-field { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 48px; padding-left: 13px; color: var(--text-muted); background: var(--surface-input); border: 1px solid var(--border-control); }
.search-field > span { font-size: 1.35rem; }
.search-field input { width: 100%; border: 0; outline: 0; }
.scope-field { display: grid; gap: 5px; }
.scope-field select { width: 100%; padding-block: 7px; }
.stale-filter { display: flex; min-height: 48px; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 0.9rem; }
.stale-filter input { width: 20px; height: 20px; accent-color: var(--accent-link); }
.facet-filters { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 12px; border: 1px solid var(--border-subtle); }
.facet-filters legend { padding-inline: 5px; color: var(--accent-link); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.1em; }
.facet-filters label { display: grid; gap: 5px; min-width: 0; color: var(--text-muted); font-size: 0.8rem; font-weight: 650; }
.facet-filters input { width: 100%; min-width: 0; min-height: 40px; padding: 8px 10px; color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); border-radius: 2px; }
.confidence-options { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; margin: 0; padding: 7px 9px; border: 1px solid var(--border-control); }
.confidence-options legend { padding-inline: 3px; color: var(--text-muted); font-size: 0.8rem; font-weight: 650; }
.facet-filters .confidence-options label { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; color: inherit; }
.facet-filters .confidence-options input[type="checkbox"] { width: 18px; min-height: 18px; margin: 0; accent-color: var(--accent-link); }
.facet-filters__hint { grid-column: 1 / -1; margin: 0; color: var(--text-muted); font-size: 0.8rem; }
.search-panel > .scope-note { margin: 10px 0 0; }
.reading-grid { display: grid; grid-template-columns: minmax(300px, 0.85fr) minmax(380px, 1.15fr); align-items: start; gap: 30px; }
.results, .reader { min-width: 0; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.section-heading h2 { margin: 6px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.75rem; font-weight: 500; }
.section-heading--compact { align-items: center; margin-bottom: 10px; }
.section-heading--compact h3 { margin: 4px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.28rem; font-weight: 500; }
.result-count { flex: 0 0 auto; color: var(--text-muted); font-size: 0.85rem; }
.warning-list { display: grid; gap: 6px; margin: 0 0 12px; padding: 0; list-style: none; }
.warning-list li { display: grid; gap: 3px; padding: 10px 12px; color: var(--text-warning); background: var(--surface-warning); border-left: 2px solid var(--status-caution); font-size: 0.86rem; line-height: 1.4; }
.warning-list strong { text-transform: capitalize; }
.state-card, .reader-empty { padding: 24px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.state-card h3 { margin: 0 0 6px; font-family: "Source Serif 4", Georgia, serif; font-size: 1.32rem; font-weight: 500; }
.state-card p, .reader-empty p { margin: 0; color: var(--text-muted); line-height: 1.55; }
.result-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.result-row { display: grid; width: 100%; gap: 8px; padding: 16px 17px; color: inherit; text-align: left; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-left: 3px solid transparent; }
.result-row:hover { border-color: var(--border-control); }
.result-row--selected { border-left-color: var(--accent-link); background: var(--surface-selected); }
.result-row__meta, .result-row__foot { color: var(--text-muted); font-size: 0.82rem; }
.result-row__meta { text-transform: capitalize; }
.result-row__meta span, .result-row__foot span { padding-inline: 4px; }
.result-row strong { font-family: "Source Serif 4", Georgia, serif; font-size: 1.22rem; font-weight: 500; line-height: 1.2; }
.result-row__summary { display: -webkit-box; overflow: hidden; color: var(--text-secondary); line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.result-row__foot { margin-top: 1px; }
.stale-label { color: var(--text-warning); font-size: 0.82rem; font-weight: 650; }
.more-button { width: 100%; margin-top: 12px; border: 1px solid var(--border-subtle); }
.reader { padding: 22px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.reader__header { padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.reader__header h2 { margin: 8px 0; font-family: "Source Serif 4", Georgia, serif; font-size: clamp(1.7rem, 2.5vw, 2.2rem); font-weight: 500; line-height: 1.13; overflow-wrap: anywhere; }
.reader__header > p:not(.eyebrow) { margin: 0; color: var(--text-secondary); font-size: 1.05rem; line-height: 1.55; }
.reader__header > p.muted-copy { overflow-wrap: anywhere; }
.reader__header .freshness { margin-top: 12px !important; padding: 9px 11px; color: var(--text-warning) !important; background: var(--surface-warning); font-size: 0.9rem !important; }
.evidence-section { padding-top: 18px; }
.evidence-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.evidence-card { padding: 15px; background: var(--surface-inset); border-left: 2px solid var(--accent-link); }
.evidence-card blockquote { margin: 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.13rem; line-height: 1.55; overflow-wrap: anywhere; }
.citation-location { margin: 9px 0 2px; color: var(--text-muted); font-size: 0.83rem; overflow-wrap: anywhere; }
.text-button { min-height: 36px; padding: 4px 0; text-align: left; text-decoration: underline; text-underline-offset: 3px; }
.source-card { margin-top: 18px; padding: 15px; border: 1px solid var(--border-subtle); }
.source-browser { margin-top: 32px; padding: 20px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.source-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.source-list__item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 16px; background: var(--surface-inset); border-left: 2px solid var(--accent-link); }
.source-list__item h3 { margin: 6px 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.2rem; font-weight: 500; overflow-wrap: anywhere; }
.source-list__item p { margin: 4px 0; line-height: 1.45; overflow-wrap: anywhere; }
.source-list__revision { color: var(--text-muted); font-size: 0.85rem; }
.source-list__actions { display: grid; align-content: center; justify-items: start; gap: 8px; }
.source-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 12px 0; }
.source-card dl > div { min-width: 0; }
.source-card dt { color: var(--text-muted); font-size: 0.78rem; }
.source-card dd { margin: 2px 0 0; font-size: 0.9rem; overflow-wrap: anywhere; }
.source-card a { display: inline-block; margin-top: 5px; }
.location-preview { margin-top: 12px; padding: 12px; background: var(--surface-preview); border: 1px solid var(--border-subtle); }
.location-preview__label { margin: 10px 0 4px; color: var(--text-muted); font-size: 0.82rem; font-weight: 650; }
.location-preview pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: 1.55; }
.history-details { margin-top: 17px; padding-top: 14px; border-top: 1px solid var(--border-subtle); }
.history-details summary { min-height: 32px; color: var(--accent-link); cursor: pointer; font-weight: 650; }
.history-details ol { display: grid; gap: 8px; padding-left: 22px; color: var(--text-secondary); font-size: 0.88rem; line-height: 1.45; overflow-wrap: anywhere; }
.relationship-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.relationship-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.relationship-list li { padding: 12px 14px; background: var(--surface-inset); border-left: 2px solid var(--border-control); }
.relationship-list li p { margin: 4px 0 0; line-height: 1.45; overflow-wrap: anywhere; }
.relationship-list__verb { color: var(--text-muted); font-size: 0.83rem; text-transform: capitalize; }
.relationship-list__target { font-family: "Source Serif 4", Georgia, serif; font-size: 1.08rem; }
.suggestion-panel { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border-control); }
.assessment-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.assessment-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.assessment-list li { padding: 14px; background: var(--surface-inset); }
.assessment-list blockquote { margin: 0 0 10px; font-family: "Source Serif 4", Georgia, serif; font-size: 1.06rem; line-height: 1.5; }
.assessment-list p { margin: 5px 0 0; line-height: 1.45; overflow-wrap: anywhere; }
.reader-empty { display: grid; min-height: 310px; align-content: center; justify-items: start; gap: 12px; border: 0; }
.reader-empty__glyph { color: var(--accent-link); font-family: "Source Serif 4", Georgia, serif; font-size: 5rem; line-height: 0.8; }
.reader-empty h2 { max-width: 12ch; margin: 0; font-family: "Source Serif 4", Georgia, serif; font-size: 2rem; font-weight: 500; line-height: 1.1; }
.reader-empty p { max-width: 38ch; }
.status-line { min-height: 24px; margin: 22px 0 10px; color: var(--text-secondary); font-size: 0.88rem; }
.workspace-footer { display: flex; justify-content: space-between; gap: 20px; padding-top: 12px; color: var(--text-muted); border-top: 1px solid var(--border-subtle); font-size: 0.8rem; }
.workspace-footer span:last-child { text-align: right; }
@media (max-width: 1120px) {
.rail { width: 188px; }
.workspace { margin-left: 188px; padding-inline: 28px; }
.reading-grid { grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr); gap: 18px; }
.search-form { grid-template-columns: minmax(0, 1fr) minmax(150px, 1fr); }
.search-field { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.rail { position: static; width: auto; height: auto; padding: 12px 18px; flex-direction: row; align-items: center; justify-content: space-between; border-right: 0; border-bottom: 1px solid var(--border-subtle); }
.rail-nav { display: flex; gap: 4px; margin: 0; }
.rail-nav__item { min-height: 40px; padding: 8px; font-size: 0.86rem; }
.rail-nav__item > span:first-child { display: none; }
.rail__footer { display: none; }
.workspace { margin-left: 0; padding: 26px 22px 20px; }
.search-form { grid-template-columns: minmax(0, 1fr); }
.search-field { grid-column: auto; }
.facet-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.reading-grid { grid-template-columns: minmax(0, 1fr); }
.reader { grid-row: 1; }
.reader-empty { min-height: 180px; }
}
@media (max-width: 560px) {
.rail { flex-wrap: wrap; padding-inline: 12px; }
.rail-nav { width: 100%; max-width: 100%; overflow-x: auto; }
.rail-nav__item { flex: 0 0 auto; }
.brand { gap: 8px; font-size: 0.98rem; }
.brand__mark { width: 32px; height: 32px; font-size: 1.3rem; }
.brand__sub { font-size: 0.55rem; }
.rail-nav__item { font-size: 0.78rem; }
.workspace { padding: 22px 14px 18px; }
.topbar { gap: 10px; padding-bottom: 18px; }
.topbar h1 { font-size: 2rem; }
.quiet-button { min-height: 40px; padding-inline: 9px; font-size: 0.83rem; }
.connect-card { grid-template-columns: 1fr; gap: 22px; margin: 30px auto; padding: 22px; }
.connect-card h2 { font-size: 1.8rem; }
.search-panel { margin-block: 18px 22px; padding: 14px; }
.search-form { grid-template-columns: minmax(0, 1fr); gap: 10px; }
.search-form > button { width: 100%; }
.facet-filters { grid-template-columns: minmax(0, 1fr); }
.reader { padding: 16px; }
.source-card dl { grid-template-columns: minmax(0, 1fr); }
.source-list__item { grid-template-columns: minmax(0, 1fr); }
.workspace-footer { flex-direction: column; gap: 5px; }
.workspace-footer span:last-child { text-align: left; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
.admin-card { margin-block: var(--space-5); padding: var(--space-5); background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.admin-card h2 { margin-top: 0; font-family: "Source Serif 4", Georgia, serif; font-weight: 500; }
.admin-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-block: var(--space-4); }
.admin-heading h2 { margin: 0; }
.admin-form { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: var(--space-4); }
.admin-form > label { display: grid; align-content: start; gap: var(--space-2); }
.admin-form select { min-height: 48px; padding: var(--space-3); color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); }
.admin-form fieldset { grid-column: 1 / -1; min-width: 0; border: 1px solid var(--border-subtle); }
.admin-form .admin-check { display: inline-flex; align-items: center; min-height: 44px; gap: var(--space-2); margin-right: var(--space-5); }
.admin-check input { min-height: auto; width: 20px; height: 20px; }
.admin-secret input { display: block; width: 100%; margin-block: var(--space-3); padding: var(--space-3); border: 1px solid var(--border-control); background: var(--surface-input); }
.admin-table { overflow-x: auto; }
.admin-table table { width: 100%; border-collapse: collapse; text-align: left; }
.admin-table th, .admin-table td { padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); vertical-align: top; overflow-wrap: anywhere; min-width: 110px; }
.admin-table td:first-child { min-width: 180px; }
.admin-error { color: var(--status-error); }
.admin-activity { list-style: none; padding: 0; }
.admin-activity li { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
.admin-activity span { color: var(--text-muted); }
@media (max-width: 700px) { .admin-form { grid-template-columns: minmax(0, 1fr); } .admin-card { padding: var(--space-4); } }
.admin-form .admin-wide { grid-column: 1 / -1; }
.admin-form textarea { width: 100%; padding: var(--space-3); font: inherit; color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); resize: vertical; }
.admin-projects { list-style: none; padding: 0; }
.admin-projects li { padding-block: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
.admin-projects span { display: block; margin-top: var(--space-2); color: var(--text-muted); }
.admin-tabs { display: flex; gap: var(--space-2); overflow-x: auto; padding: 4px 4px 0; border-bottom: 1px solid var(--border-subtle); }
.admin-tabs [role="tab"] { flex: 0 0 auto; min-height: 48px; padding: 12px 18px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--text-secondary); font: inherit; font-weight: 650; cursor: pointer; white-space: nowrap; }
.admin-tabs [role="tab"]:hover { color: var(--accent-link); background: var(--surface-inset); }
.admin-tabs [aria-selected="true"] { color: var(--accent-link); border-bottom-color: var(--accent-link); background: var(--surface-selected); }
.admin-dialog { width: min(760px, calc(100vw - 32px)); max-height: min(90vh, 900px); overflow-y: auto; padding: var(--space-5); color: var(--text-primary); background: var(--surface-paper); border: 1px solid var(--border-subtle); box-shadow: 0 16px 48px #24272233; }
.admin-dialog::backdrop { background: #24272288; }
.admin-dialog .admin-heading { margin-top: 0; }
.project-edit-form { margin-top: var(--space-4); padding: var(--space-4); background: var(--surface-inset); }
@media (max-width: 700px) { .admin-dialog { padding: var(--space-4); } }
.project-actions { display: flex; align-items: center; gap: var(--space-3); }
.admin-record-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.admin-record { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
.admin-record__top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.admin-record__top > div:first-child { min-width: 0; overflow-wrap: anywhere; }
.admin-record__top .muted-copy { margin: var(--space-1) 0 0; }
.admin-record__actions { display: flex; align-items: center; gap: var(--space-3); flex: 0 0 auto; }
.record-edit-form { margin-top: var(--space-4); padding: var(--space-4); background: var(--surface-inset); }
.record-confirm { margin-top: var(--space-3); padding: var(--space-3); background: var(--surface-warning); }
.admin-filter-bar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4); background: var(--surface-inset); }
.admin-filter-bar label { display: grid; gap: var(--space-1); min-width: 140px; }
.admin-filter-bar input, .admin-filter-bar select { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border-control); background: var(--surface-input); color: var(--text-primary); }
@media (max-width: 700px) { .admin-dialog { padding: var(--space-4); } .admin-record__top { align-items: flex-start; flex-direction: column; } .admin-record__actions { min-height: 44px; } }
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';