This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const citation = {
|
||||
source_revision_id: 'dome:pilot-report:r1',
|
||||
extraction_id: 'a'.repeat(64),
|
||||
assertion_path: 'claims/0',
|
||||
assertion_digest: 'b'.repeat(64),
|
||||
quote: 'The installation hosted 400 visitors during the season.',
|
||||
locator: { schema_version: 1, kind: 'text_span', block_id: 'block-1', start: 0, end: 54 },
|
||||
normalization_profile: 'unicode-nfc-lf-v1',
|
||||
schema_version: 1,
|
||||
};
|
||||
|
||||
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',
|
||||
lifecycle: 'active',
|
||||
as_of: '2026-10-13T00:00:00Z',
|
||||
tags: ['installation'],
|
||||
audience: ['developer'],
|
||||
use_for: ['planning'],
|
||||
domain: ['interactive systems'],
|
||||
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',
|
||||
},
|
||||
}],
|
||||
assumptions: [],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
const envelope = (data: unknown[]) => ({
|
||||
api_version: 'v1',
|
||||
request_id: 'browser-test-request',
|
||||
resolved_scope: {
|
||||
projects: ['dome-performance'],
|
||||
include_shared: false,
|
||||
all_projects: false,
|
||||
schema_version: 1,
|
||||
},
|
||||
retrieval_mode: 'keyword',
|
||||
budget_profile: { tokenizer: 'none', encoding: 'raw-record-v1', schema_version: 1 },
|
||||
tokens_used: 150,
|
||||
data,
|
||||
citations: [citation],
|
||||
warnings: [],
|
||||
omissions: [],
|
||||
canonical_revision: '1'.repeat(40),
|
||||
index_revision: '1'.repeat(40),
|
||||
next_cursor: null,
|
||||
schema_version: 1,
|
||||
});
|
||||
|
||||
test('restores a shared search URL and reads its exact citation', async ({ page }) => {
|
||||
const searchBodies: Array<Record<string, unknown>> = [];
|
||||
await page.route('**/v1/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/v1/session' && request.method() === 'GET') {
|
||||
await route.fulfill({ status: 401, json: { detail: 'No browser session' } });
|
||||
} else if (url.pathname === '/v1/session') {
|
||||
await route.fulfill({ json: { csrf_token: 'browser-test-csrf' } });
|
||||
} else if (url.pathname === '/v1/projects') {
|
||||
await route.fulfill({ json: ['dome-performance'] });
|
||||
} else if (url.pathname === '/v1/search') {
|
||||
searchBodies.push(request.postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({ json: envelope([{ node, retrieval_score: -1.2 }]) });
|
||||
} else if (url.pathname.endsWith('/history')) {
|
||||
await route.fulfill({ json: envelope([{ revision: '1'.repeat(40), node }]) });
|
||||
} else if (url.pathname === '/v1/neighbors') {
|
||||
await route.fulfill({ json: envelope([]) });
|
||||
} else if (url.pathname === '/v1/projects/dome-performance/nodes/dome%3Aseason-visitors') {
|
||||
await route.fulfill({ json: envelope([node]) });
|
||||
} else {
|
||||
await route.fulfill({ status: 404, json: { detail: 'Unexpected test API request.' } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/?project=dome-performance&q=visitors&tag=installation&audience=developer&confidence=medium&as_of_from=2024-01-01&as_of_to=2024-12-31');
|
||||
await page.getByRole('button', { name: 'Use an existing credential' }).click();
|
||||
await page.getByLabel('GEWU viewer credential').fill('synthetic-viewer-token');
|
||||
await page.getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Visitor count during the season/ })).toBeVisible();
|
||||
await expect.poll(() => searchBodies).toHaveLength(1);
|
||||
expect(searchBodies[0]).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',
|
||||
});
|
||||
|
||||
const result = page.getByRole('button', { name: /Visitor count during the season/ });
|
||||
await result.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Claim assessments' })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Evidence' }).getByText(citation.quote)).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
const documentWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
expect(documentWidth).toBe(320);
|
||||
});
|
||||
|
||||
test('query editing remains passive until the reader submits Search', async ({ page }) => {
|
||||
let searchCount = 0;
|
||||
await page.route('**/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/v1/session' && route.request().method() === 'GET') {
|
||||
await route.fulfill({ status: 401, json: { detail: 'No browser session' } });
|
||||
} else if (pathname === '/v1/session') {
|
||||
await route.fulfill({ json: { csrf_token: 'browser-test-csrf' } });
|
||||
} else if (pathname === '/v1/projects') {
|
||||
await route.fulfill({ json: ['dome-performance'] });
|
||||
} else if (pathname === '/v1/search') {
|
||||
searchCount += 1;
|
||||
await route.fulfill({ json: envelope([{ node, retrieval_score: -1.2 }]) });
|
||||
} else {
|
||||
await route.fulfill({ json: envelope([]) });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/?project=dome-performance');
|
||||
await page.getByRole('button', { name: 'Use an existing credential' }).click();
|
||||
await page.getByLabel('GEWU viewer credential').fill('synthetic-viewer-token');
|
||||
await page.getByRole('button', { name: 'Connect' }).click();
|
||||
await page.getByRole('textbox', { name: /Search claims/ }).fill('visitors');
|
||||
await expect.poll(() => searchCount).toBe(0);
|
||||
await page.getByRole('textbox', { name: /Search claims/ }).press('Enter');
|
||||
await expect.poll(() => searchCount).toBe(1);
|
||||
await expect(page.getByRole('button', { name: /Visitor count during the season/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4f1ea" />
|
||||
<title>GEWU — Research Library</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4771
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "gewu-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"check": "tsc -b && eslint . && vitest run",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"generate:api-types": "openapi-typescript ../schema/v1/openapi.json --output src/generated/api.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@testing-library/jest-dom": "^6.9.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"globals": "^16.4.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"vite": "^7.1.10",
|
||||
"vitest": "^5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173 --strictPort',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>,
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['msi.tail0b014a.ts.net'],
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
// Keep the browser-facing host so the API can validate Origin behind TLS proxies.
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user