Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s

- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 12:52:49 -04:00
1 parent 207bfcb919
commit bdb04cdedc
35 files changed
+1860 -22

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
// Admin → Health: is the server in good shape? Disk use, snapshots (make, download, restore) and recent failures.
import { useCallback, useEffect, useState } from 'react';
import { adminBackupNow, adminGetHealth, adminRestoreSnapshot, adminSnapshotUrl } from '../syncService.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4';
export const formatBytes = (n) => {
if (n == null) return 'unknown';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024; let i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
};
export const formatUptime = (s) => {
if (s < 90) return `${s} seconds`;
if (s < 5400) return `${Math.round(s / 60)} minutes`;
if (s < 129600) return `${Math.round(s / 3600)} hours`;
return `${Math.round(s / 86400)} days`;
};
const ago = (at) => {
const m = Math.round((Date.now() - at) / 60000);
if (m < 2) return 'just now';
if (m < 120) return `${m} minutes ago`;
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
return `${Math.round(m / 1440)} days ago`;
};
function Log({ title, empty, rows }) {
return (
<section className={card}>
<h3 className="text-sm font-medium">{title}</h3>
{rows.length === 0 ? <p className="mt-2 text-xs text-[var(--r-sub)]">{empty}</p> : (
<ul className="mt-2 divide-y divide-[var(--r-line)] text-xs">
{rows.map((e, i) => (
<li key={`${e.at}-${i}`} className="flex flex-wrap justify-between gap-x-3 py-1.5">
<span className="min-w-0 break-words">{e.detail}{e.email && e.kind === 'notify' ? ` (${e.email})` : ''}</span>
<span className="shrink-0 text-[var(--r-sub)]">{ago(e.at)}</span>
</li>
))}
</ul>
)}
</section>
);
}
export default function AdminHealth() {
const [state, setState] = useState({ loading: true, error: '', data: null });
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [restoring, setRestoring] = useState(null); // snapshot name awaiting typed confirmation
const [typed, setTyped] = useState('');
const load = useCallback(async () => {
const r = await adminGetHealth();
setState(r.ok ? { loading: false, error: '', data: r.data } : { loading: false, error: r.error ?? 'Could not read server health.', data: null });
}, []);
useEffect(() => { load(); }, [load]);
const backupNow = async () => {
setBusy(true); setMessage('');
const r = await adminBackupNow();
setBusy(false);
setMessage(r.ok ? 'Snapshot saved.' : r.error ?? 'The snapshot could not be made.');
load();
};
const restore = async () => {
setBusy(true); setMessage('');
const r = await adminRestoreSnapshot(restoring);
setBusy(false);
if (r.ok) { setRestoring(null); setTyped(''); setMessage(`Restored ${r.data.restored}. What was there before is saved as ${r.data.safetyCopy}. You may need to sign in again.`); load(); }
else setMessage(r.error ?? 'The snapshot could not be restored.');
};
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (state.error) return <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>;
const { data } = state;
const { backups, disk } = data;
const stale = backups.enabled && (!backups.latest || Date.now() - backups.latest.at > 36 * 3600 * 1000);
const usedPct = disk.total ? Math.round(((disk.total - disk.free) / disk.total) * 100) : null;
return (
<div className="space-y-4">
<section className={card}>
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div><dt className="text-xs text-[var(--r-sub)]">Running for</dt><dd>{formatUptime(data.uptimeSeconds)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Database</dt><dd>{formatBytes(disk.db)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Snapshots</dt><dd>{formatBytes(disk.backups)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Disk free</dt><dd>{formatBytes(disk.free)}{usedPct != null ? ` (${usedPct}% used)` : ''}</dd></div>
</dl>
<p className="mt-3 text-xs text-[var(--r-sub)]">Node {data.node} · IQ Bible {data.iqConfigured ? 'connected' : 'not set up'} · data in {data.dataDir}</p>
</section>
<section className={card}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">Snapshots</h3>
<button type="button" onClick={backupNow} disabled={busy} className={btn}>{busy && !restoring ? 'Working…' : 'Back up now'}</button>
</div>
{!backups.enabled && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Automatic snapshots are turned off (BACKUP_DISABLED).</p>}
{stale && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{backups.latest ? `The last snapshot is ${ago(backups.latest.at)}.` : 'There are no snapshots yet.'} One should be made every day.</p>}
{data.backupFailures.length > 0 && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Last failure: {data.backupFailures[0].detail} ({ago(data.backupFailures[0].at)})</p>}
{backups.snapshots.length > 0 && (
<ul className="mt-2 divide-y divide-[var(--r-line)] text-sm">
{backups.snapshots.map((s) => (
<li key={s.name} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="min-w-0"><span className="break-all">{s.name}</span> <span className="text-xs text-[var(--r-sub)]">· {formatBytes(s.bytes)} · {ago(s.at)}</span></span>
<span className="flex gap-2">
<a href={adminSnapshotUrl(s.name)} download className={btn}>Download</a>
<button type="button" onClick={() => { setRestoring(s.name); setTyped(''); setMessage(''); }} className={btn}>Restore…</button>
</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-[var(--r-sub)]">Keeps the newest {backups.keep}. A snapshot holds every account’s data.</p>
{restoring && (
<div role="group" aria-label="Confirm restore" className="mt-3 space-y-2 rounded-xl border border-[var(--r-danger,#b42318)] p-3 text-sm">
<p>This replaces <strong>everyone’s</strong> data with what was saved in <span className="break-all">{restoring}</span>. Anything changed since then is lost, though what’s there now is saved as a new snapshot first. Type the snapshot’s name to go ahead.</p>
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Snapshot name" placeholder={restoring}
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs" />
<div className="flex gap-2">
<button type="button" onClick={restore} disabled={busy || typed !== restoring} className="rounded-lg bg-[var(--r-danger,#b42318)] px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40">{busy ? 'Restoring…' : 'Restore this snapshot'}</button>
<button type="button" onClick={() => setRestoring(null)} className={btn}>Cancel</button>
</div>
</div>
)}
{message && <p role="status" className="mt-2 text-xs text-[var(--r-sub)]">{message}</p>}
</section>
<Log title="Failed sign-ins" empty="None since the server last started." rows={data.signinFailures} />
<Log title="Failed notifications" empty="None since the server last started." rows={data.notifyFailures} />
<p className="text-xs text-[var(--r-sub)]">The failure lists are kept in memory, so they start over when the server restarts.</p>
<button type="button" onClick={load} className={btn}>Refresh</button>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, expect, test, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AdminHealth, { formatBytes, formatUptime } from './AdminHealth.jsx';
const health = (over = {}) => ({
uptimeSeconds: 7200, node: 'v22', dataDir: '/data', iqConfigured: false,
disk: { db: 2048, backups: 4096, free: 5e9, total: 1e10 },
backups: { enabled: true, keep: 7, latest: { name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }, snapshots: [{ name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }] },
signinFailures: [{ at: Date.now() - 60000, kind: 'signin', detail: 'Wrong password for a@x.test from 1.2.3.4' }],
notifyFailures: [], backupFailures: [], ...over,
});
const mockFetch = (routes) => vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
const r = routes[key] ?? routes.default;
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
}));
afterEach(() => vi.unstubAllGlobals());
test('formats sizes and times plainly', () => {
expect(formatBytes(2048)).toBe('2.0 KB');
expect(formatBytes(5e9)).toBe('4.7 GB');
expect(formatBytes(null)).toBe('unknown');
expect(formatUptime(7200)).toBe('2 hours');
});
test('shows disk use, snapshots and sign-in failures', async () => {
mockFetch({ 'GET /admin/health': { body: health() } });
render(<AdminHealth />);
expect(await screen.findByText('2 hours')).toBeInTheDocument();
expect(screen.getByText(/Wrong password for a@x.test/)).toBeInTheDocument();
expect(screen.getByText('None since the server last started.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute('href', expect.stringContaining('/admin/backups/projects-2026-10-05-03-00.db'));
});
test('warns when the newest snapshot is old', async () => {
const old = Date.now() - 4 * 86400000;
mockFetch({ 'GET /admin/health': { body: health({ backups: { enabled: true, keep: 7, latest: { name: 'x', bytes: 1, at: old }, snapshots: [] } }) } });
render(<AdminHealth />);
expect(await screen.findByRole('alert')).toHaveTextContent(/last snapshot is 4 days ago/);
});
test('restoring needs the snapshot name typed first', async () => {
const name = 'projects-2026-10-05-03-00.db';
mockFetch({ 'GET /admin/health': { body: health() }, [`POST /admin/backups/${name}/restore`]: { body: { restored: name, safetyCopy: 'projects-new.db' } } });
const user = userEvent.setup();
render(<AdminHealth />);
await user.click(await screen.findByRole('button', { name: 'Restore…' }));
const go = screen.getByRole('button', { name: 'Restore this snapshot' });
expect(go).toBeDisabled();
await user.type(screen.getByLabelText('Snapshot name'), name);
expect(go).toBeEnabled();
await user.click(go);
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Restored projects-2026-10-05-03-00.db'));
expect(fetch.mock.calls.some(([u, o]) => o?.method === 'POST' && JSON.parse(o.body).confirm === name)).toBe(true);
});
+2 -2
View File
@@ -12,7 +12,7 @@ import { loadProjectById } from '../utils/projectStorage.js';
export default function CommandPalette({ onClose }) {
const {
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans, openPrayer,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
@@ -40,7 +40,7 @@ export default function CommandPalette({ onClose }) {
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Prayer journal', 'Requests, answers and the verses behind them', openPrayer], ['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Back up my data', 'Download everything as a file', openBackup],
];
const lower = q.toLowerCase();
+1
View File
@@ -15,6 +15,7 @@ const BY_PAGE = {
['G', 'Pick a book and chapter'],
['B', 'Bookmark the selected verse'],
['C', 'Compare translations'],
['M', 'Map this chapter’s cross-references (needs IQ Bible)'],
['H', 'Your highlights'],
[',', 'Reading settings'],
['F', 'Hide or show the controls'],
+89
View File
@@ -0,0 +1,89 @@
// The cross-reference map for a chapter (from IQ Bible's reference data): the chapter in the middle, the chapters
// it is linked to around it, in Bible order. Heavier lines mean more verse-to-verse links. Tap a chapter to see how
// they connect, open it, or move the map's centre there. A list below says the same in words.
import { useEffect, useMemo, useState } from 'react';
import Sheet from './Sheet.jsx';
import { useIq } from '../../hooks/useIq.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { chapterId, layoutMap, linkedChapters, parseGraph } from '../../utils/xrefMap.js';
const SIZE = 700;
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
export default function CrossRefMap({ open, onClose, book, chapter, onOpenChapter }) {
const [center, setCenter] = useState({ book, chapter });
const [depth, setDepth] = useState(1);
const [picked, setPicked] = useState(null);
useEffect(() => { if (open) { setCenter({ book, chapter }); setPicked(null); } }, [open, book, chapter]);
const { status, data, error } = useIq(open ? 'cross-references/graph' : null, { book: center.book, chapter: center.chapter, depth });
const centerId = chapterId(center.book, center.chapter);
const graph = useMemo(() => parseGraph(data), [data]);
const map = useMemo(() => layoutMap(graph, centerId, SIZE), [graph, centerId]);
const linked = useMemo(() => linkedChapters(graph, centerId), [graph, centerId]);
const byId = new Map(map.nodes.map((n) => [n.id, n]));
const sel = picked ? byId.get(picked) : null;
const selLinks = sel ? graph.edges.filter((e) => (e.source === centerId && e.target === sel.id) || (e.target === centerId && e.source === sel.id)) : [];
const label = (n) => `${bookName(n.book)} ${n.chapter}`;
const hot = new Set([...map.nodes].filter((n) => n.id !== centerId).sort((a, b) => b.weight - a.weight).slice(0, 14).map((n) => n.id)); // labelled on the picture
return (
<Sheet open={open} onClose={onClose} title={`Cross-reference map · ${bookName(center.book)} ${center.chapter}`} label="Cross-reference map">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-[var(--r-sub)]">
<span role="group" aria-label="How far to follow the links" className="flex overflow-hidden rounded-full border border-[var(--r-line)]">
{[[1, 'Directly linked'], [2, 'And the ones they link to']].map(([d, name]) => (
<button key={d} type="button" aria-pressed={depth === d} onClick={() => { setDepth(d); setPicked(null); }}
className={`px-3 py-1 ${depth === d ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'hover:bg-black/5'}`}>{name}</button>
))}
</span>
{(center.book !== book || center.chapter !== chapter) && (
<button type="button" onClick={() => { setCenter({ book, chapter }); setPicked(null); }} className="text-[var(--r-accent)] hover:underline">Back to {bookName(book)} {chapter}</button>
)}
<span>Lines are heavier where there are more verse links.</span>
</div>
{status === 'loading' && <p className="text-sm text-[var(--r-sub)]">Drawing the map…</p>}
{status === 'error' && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{status === 'empty' && <p className="text-sm text-[var(--r-sub)]">No cross-references are recorded for this chapter.</p>}
{status === 'ready' && map.nodes.length > 1 && (
<>
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={`Map of ${linked.length} chapters linked to ${bookName(center.book)} ${center.chapter}`} className="mx-auto block w-full max-w-[34rem]">
{map.edges.map((e) => <path key={`${e.source}-${e.target}`} d={e.path} fill="none" stroke="currentColor" strokeWidth={e.width} opacity={picked && (e.source === picked || e.target === picked) ? 0.9 : e.opacity} className="text-[var(--r-text)]" />)}
{map.nodes.map((n) => (
<g key={n.id} onClick={() => setPicked(n.id === picked ? null : n.id)} className="cursor-pointer" role="button" tabIndex={0} aria-label={`${label(n)}, ${n.weight} links`}
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setPicked(n.id === picked ? null : n.id); } }}>
<circle cx={n.x} cy={n.y} r={n.r + (n.id === picked ? 3 : 0)} fill={n.color} stroke={n.id === picked || n.id === centerId ? 'currentColor' : 'none'} strokeWidth="2" className="text-[var(--r-text)]" />
{(n.id === centerId || n.id === picked || hot.has(n.id)) && (
<text x={n.x} y={n.y + (n.y < SIZE / 2 ? -n.r - 5 : n.r + 14)} textAnchor="middle" fontSize={n.id === centerId ? 18 : 13} fontWeight={n.id === centerId ? 700 : 500} fill="currentColor" className="text-[var(--r-text)]">{label(n)}</text>
)}
</g>
))}
</svg>
{graph.truncated && <p className="mt-1 text-center text-[11px] text-[var(--r-sub)]">A large map: only the first part is shown.</p>}
{sel && (
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3 text-sm" role="status">
<p className="font-medium">{label(sel)}</p>
<p className="text-xs text-[var(--r-sub)]">{selLinks.length ? `${selLinks.reduce((n, e) => n + e.weight, 0)} verse links with ${bookName(center.book)} ${center.chapter}` : `${sel.ring > 1 ? 'Linked through another chapter' : 'Linked'}`}</p>
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { onOpenChapter(sel.book, sel.chapter); }} className="font-medium text-[var(--r-accent)] hover:underline">Read {label(sel)}</button>
{sel.id !== centerId && <button type="button" onClick={() => { setCenter({ book: sel.book, chapter: sel.chapter }); setPicked(null); }} className="hover:underline">Put it in the middle</button>}
</div>
</div>
)}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Most linked to {bookName(center.book)} {center.chapter}</h3>
<ol className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{linked.slice(0, 20).map((c) => (
<li key={c.id} className="flex items-baseline justify-between gap-2">
<button type="button" onClick={() => setPicked(c.id)} className="truncate text-left hover:underline">{bookName(c.book)} {c.chapter}</button>
<span className="shrink-0 text-xs text-[var(--r-sub)]">{c.weight}</span>
</li>
))}
</ol>
</>
)}
<p className="mt-4 text-[10px] text-[var(--r-sub)]">Cross-references via IQ Bible.</p>
</Sheet>
);
}
@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import CrossRefMap from './CrossRefMap.jsx';
import { resetIq } from '../../utils/iqbible.js';
let routes;
let asked;
beforeEach(() => {
resetIq();
asked = [];
routes = {};
vi.stubGlobal('fetch', vi.fn((url) => {
asked.push(String(url));
const hit = routes[String(url)];
return Promise.resolve(hit ? { ok: true, status: 200, json: async () => hit } : { ok: false, status: 404, json: async () => ({ error: 'nf' }) });
}));
});
afterEach(() => vi.unstubAllGlobals());
const graph = {
nodes: [
{ id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }, { id: 'PSA.104', book: 'PSA', name_en: 'Psalms', chapter: 104 },
{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'COL.1', book: 'COL', name_en: 'Colossians', chapter: 1 },
],
edges: [{ source: 'GEN.1', target: 'PSA.104', weight: 4 }, { source: 'GEN.1', target: 'JHN.1', weight: 9 }, { source: 'GEN.1', target: 'COL.1', weight: 3 }],
};
const url = (book, chapter, depth = 1) => `/api/iq/cross-references/graph?book=${book}&chapter=${chapter}&depth=${depth}`;
test('nothing is fetched until the map is opened; then the linked chapters are drawn and listed strongest first', async () => {
const { rerender } = render(<CrossRefMap open={false} onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
expect(asked).toEqual([]);
routes[url('GEN', 1)] = graph;
rerender(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
expect(await screen.findByRole('img', { name: /Map of 3 chapters linked to Genesis 1/ })).toBeInTheDocument();
const items = screen.getAllByRole('listitem').map((li) => li.textContent);
expect(items[0]).toBe('John 19'); // strongest link first
expect(items[1]).toBe('Psalms 1044');
expect(screen.getByRole('button', { name: 'John 1, 9 links' })).toBeInTheDocument();
});
test('picking a chapter shows how strongly it links, and can open it or put it in the middle', async () => {
routes[url('GEN', 1)] = graph;
routes[url('JHN', 1)] = { nodes: [{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }], edges: [{ source: 'JHN.1', target: 'GEN.1', weight: 9 }] };
const onOpenChapter = vi.fn();
const user = userEvent.setup();
render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={onOpenChapter} />);
await user.click(await screen.findByRole('button', { name: 'John 1, 9 links' }));
expect(screen.getByRole('status')).toHaveTextContent('9 verse links with Genesis 1');
await user.click(screen.getByRole('button', { name: /read john 1/i }));
expect(onOpenChapter).toHaveBeenCalledWith('JHN', 1);
await user.click(screen.getByRole('button', { name: /put it in the middle/i }));
expect(await screen.findByText(/Cross-reference map · John 1/)).toBeInTheDocument();
expect(asked).toContain(url('JHN', 1));
await user.click(screen.getByRole('button', { name: /back to genesis 1/i }));
expect(await screen.findByText(/Cross-reference map · Genesis 1/)).toBeInTheDocument();
});
test('following the links further asks for more, and a chapter with no links says so', async () => {
routes[url('GEN', 1)] = graph;
const user = userEvent.setup();
const { unmount } = render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
await screen.findByRole('img');
await user.click(screen.getByRole('button', { name: 'And the ones they link to' }));
expect(asked).toContain(url('GEN', 1, 2));
unmount();
render(<CrossRefMap open onClose={() => {}} book="OBA" chapter={1} onOpenChapter={() => {}} />);
expect(await screen.findByText(/No cross-references are recorded/)).toBeInTheDocument();
});
+2 -1
View File
@@ -156,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onOpenMap, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) {
const iqOn = useIqAvailable();
@@ -291,6 +291,7 @@ export default function VerseSheet({
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && onOpenMap && <button type="button" onClick={onOpenMap} className="mb-3 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Map this chapter’s links (M)</button>}
{tab === 'refs' && (
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
@@ -0,0 +1,77 @@
// Settings → Security: where you're signed in, with a way to sign the others out, and an optional alert for new sign-ins.
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { listDevices, setLoginAlerts, signOutDevice, signOutOtherDevices } from '../../syncService.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
const METHODS = { password: 'password', passkey: 'passkey', mfa: 'password and code', register: 'new account' };
export function whenText(at, now = Date.now()) {
const m = Math.round((now - at) / 60000);
if (m < 2) return 'just now';
if (m < 120) return `${m} minutes ago`;
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
return `${Math.round(m / 1440)} days ago`;
}
export default function DevicesSection() {
const { authUser, setAuthUser } = useApp();
const [devices, setDevices] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = async () => { const r = await listDevices(); if (r.ok) setDevices(r.data); else setError(r.error ?? 'Could not load your devices.'); };
useEffect(() => { load(); }, []);
const signOut = async (d) => {
if (!window.confirm(`Sign out ${d.label}?`)) return;
const r = await signOutDevice(d.id);
if (!r.ok && r.status !== 404) setError(r.error ?? 'Could not sign it out.');
load();
};
const signOutOthers = async () => {
if (!window.confirm('Sign out every other device? They’ll need to sign in again.')) return;
setBusy(true);
const r = await signOutOtherDevices();
setBusy(false);
if (!r.ok) setError(r.error ?? 'Could not sign them out.');
load();
};
const toggleAlerts = async (e) => {
setError('');
const r = await setLoginAlerts(e.target.checked);
if (r.ok) setAuthUser({ ...authUser, loginAlerts: r.data.loginAlerts }); else setError(r.error ?? 'Could not change that.');
};
const others = (devices ?? []).filter((d) => !d.current);
return (
<section aria-label="Devices" className="space-y-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Where you’re signed in</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">If you don’t recognise one, sign it out and change your password.</p>
</div>
{devices === null && !error && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{devices && (
<ul className="divide-y divide-[var(--r-line)] text-sm">
{devices.map((d) => (
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="min-w-0">
<span className="font-medium">{d.label}</span>{d.current && <span className="ml-2 rounded-full bg-black/10 px-2 py-0.5 text-[11px]">This device</span>}
<span className="block text-xs text-[var(--r-sub)]">
{d.current ? 'Active now' : `Last active ${whenText(d.lastSeen)}`}{d.method ? ` · signed in with ${METHODS[d.method] ?? d.method}` : ''}{d.ip ? ` · ${d.ip}` : ''}
</span>
</span>
{!d.current && <button type="button" onClick={() => signOut(d)} className={btn}>Sign out</button>}
</li>
))}
</ul>
)}
{others.length > 0 && <button type="button" onClick={signOutOthers} disabled={busy} className={btn}>{busy ? 'Signing out…' : `Sign out the other ${others.length === 1 ? 'device' : `${others.length} devices`}`}</button>}
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<label className="flex items-start gap-2 border-t border-[var(--r-line)] pt-3 text-sm">
<input type="checkbox" checked={!!authUser?.loginAlerts} onChange={toggleAlerts} className="mt-1" />
<span>Tell me when someone signs in<span className="block text-xs text-[var(--r-sub)]">Sends a message to your notification topic (set up under Notifications) each time a device signs in.</span></span>
</label>
</section>
);
}
@@ -0,0 +1,74 @@
// Settings → Security: sign in with Face ID, a fingerprint or a security key instead of typing a password.
import { useEffect, useState } from 'react';
import { listPasskeys, passkeyRemove, passkeyRename } from '../../syncService.js';
import { addPasskeyHere, passkeysSupported } from '../../utils/passkeys.js';
import { useToast } from '../Toasts.jsx';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
const day = (t) => new Date(t).toLocaleDateString();
export default function PasskeysSection() {
const { toast } = useToast();
const [list, setList] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [editing, setEditing] = useState(null); // { id, name }
const supported = passkeysSupported();
useEffect(() => { listPasskeys().then((r) => setList(r.ok ? r.data : [])); }, []);
const add = async () => {
setBusy(true); setError('');
const r = await addPasskeyHere();
setBusy(false);
if (r.ok) { setList(r.data); toast('Passkey added. You can now sign in with it.'); } else setError(r.error ?? 'Could not add a passkey.');
};
const remove = async (p) => {
if (!window.confirm(`Remove the passkey “${p.name}”? You can still sign in with your password.`)) return;
const r = await passkeyRemove(p.id);
if (r.ok) setList(r.data); else setError(r.error ?? 'Could not remove it.');
};
const rename = async (e) => {
e.preventDefault();
const r = await passkeyRename(editing.id, editing.name);
if (r.ok) { setList(r.data); setEditing(null); } else setError(r.error ?? 'Could not rename it.');
};
return (
<section aria-label="Passkeys" className="space-y-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Passkeys</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Sign in with Face ID, a fingerprint or a security key, with nothing to type. A passkey is checked by your device, so it replaces the authenticator code too.</p>
</div>
{!supported && <p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">This browser or address can’t use passkeys. They need a recent browser and an https:// address.</p>}
{list?.length > 0 && (
<ul className="divide-y divide-[var(--r-line)] text-sm">
{list.map((p) => (
<li key={p.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
{editing?.id === p.id ? (
<form onSubmit={rename} className="flex flex-1 gap-2">
<input aria-label="Passkey name" autoFocus value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} maxLength={60}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm" />
<button type="submit" className={btn}>Save</button>
<button type="button" onClick={() => setEditing(null)} className={btn}>Cancel</button>
</form>
) : (
<>
<span className="min-w-0"><span className="font-medium">{p.name}</span>
<span className="block text-xs text-[var(--r-sub)]">Added {day(p.createdAt)} · {p.lastUsedAt ? `last used ${day(p.lastUsedAt)}` : 'not used yet'}</span></span>
<span className="flex gap-2">
<button type="button" onClick={() => setEditing({ id: p.id, name: p.name })} className={btn}>Rename</button>
<button type="button" onClick={() => remove(p)} className={btn}>Remove</button>
</span>
</>
)}
</li>
))}
</ul>
)}
{list && list.length === 0 && supported && <p className="text-sm text-[var(--r-sub)]">No passkeys yet.</p>}
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{supported && <button type="button" onClick={add} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Waiting for your device…' : 'Add a passkey on this device'}</button>}
</section>
);
}
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const app = { authUser: { id: 'u1', email: 'a@x.test', loginAlerts: false }, setAuthUser: vi.fn() };
vi.mock('../../context/AppContext.js', () => ({ useApp: () => app }));
const webauthn = vi.hoisted(() => ({ browserSupportsWebAuthn: vi.fn(() => true), startRegistration: vi.fn(), startAuthentication: vi.fn() }));
vi.mock('@simplewebauthn/browser', () => webauthn);
import PasskeysSection from './PasskeysSection.jsx';
import DevicesSection, { whenText } from './DevicesSection.jsx';
import { signInWithPasskey } from '../../utils/passkeys.js';
const routes = {};
beforeEach(() => {
Object.keys(routes).forEach((k) => delete routes[k]);
app.setAuthUser.mockClear(); webauthn.browserSupportsWebAuthn.mockReturnValue(true);
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
const r = routes[key] ?? { status: 404, body: { error: `no route ${key}` } };
return { ok: (r.status ?? 200) < 400, status: r.status ?? 200, json: async () => r.body };
}));
});
afterEach(() => vi.unstubAllGlobals());
const passkey = { id: 'k1', name: 'My phone', createdAt: 1_700_000_000_000, lastUsedAt: null };
test('lists passkeys and adds one through the device prompt', async () => {
routes['GET /passkeys'] = { body: [passkey] };
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
routes['POST /passkeys'] = { body: [passkey, { ...passkey, id: 'k2', name: 'Laptop' }] };
webauthn.startRegistration.mockResolvedValue({ id: 'k2' });
const user = userEvent.setup();
render(<PasskeysSection />);
expect(await screen.findByText('My phone')).toBeInTheDocument();
expect(screen.getByText(/not used yet/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Add a passkey on this device/ }));
expect(await screen.findByText('Laptop')).toBeInTheDocument();
expect(webauthn.startRegistration).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } });
});
test('says so when the device prompt is cancelled', async () => {
routes['GET /passkeys'] = { body: [] };
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
webauthn.startRegistration.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
const user = userEvent.setup();
render(<PasskeysSection />);
await user.click(await screen.findByRole('button', { name: /Add a passkey/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('cancelled or timed out');
});
test('renames and removes a passkey', async () => {
routes['GET /passkeys'] = { body: [passkey] };
routes['PATCH /passkeys/k1'] = { body: [{ ...passkey, name: 'Pixel' }] };
routes['DELETE /passkeys/k1'] = { body: [] };
vi.spyOn(window, 'confirm').mockReturnValue(true);
const user = userEvent.setup();
render(<PasskeysSection />);
await user.click(await screen.findByRole('button', { name: 'Rename' }));
const input = screen.getByLabelText('Passkey name');
await user.clear(input); await user.type(input, 'Pixel'); await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Pixel')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(screen.queryByText('Pixel')).not.toBeInTheDocument());
});
test('hides the button where passkeys cannot work', async () => {
webauthn.browserSupportsWebAuthn.mockReturnValue(false);
routes['GET /passkeys'] = { body: [] };
render(<PasskeysSection />);
expect(await screen.findByText(/can’t use passkeys/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add a passkey/ })).not.toBeInTheDocument();
});
test('sign-in with a passkey asks the server, then the device, then the server again', async () => {
routes['POST /auth/passkey/options'] = { body: { challenge: 'c' } };
routes['POST /auth/passkey/verify'] = { body: { id: 'u1', email: 'a@x.test' } };
webauthn.startAuthentication.mockResolvedValue({ id: 'k1' });
expect(await signInWithPasskey()).toMatchObject({ ok: true, data: { email: 'a@x.test' } });
webauthn.startAuthentication.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
expect(await signInWithPasskey()).toMatchObject({ ok: false, cancelled: true });
});
test('shows devices, signs one out, and signs out the rest', async () => {
const devices = [
{ id: 'd1', current: true, label: 'Safari on macOS', method: 'password', ip: '1.1.1.1', lastSeen: Date.now() },
{ id: 'd2', current: false, label: 'Chrome on Windows', method: 'passkey', ip: '2.2.2.2', lastSeen: Date.now() - 3 * 86400000 },
];
routes['GET /sessions'] = { body: devices };
routes['DELETE /sessions/d2'] = { body: { ok: true } };
vi.spyOn(window, 'confirm').mockReturnValue(true);
const user = userEvent.setup();
render(<DevicesSection />);
expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument();
expect(screen.getByText('This device')).toBeInTheDocument();
expect(screen.getByText(/signed in with passkey/)).toBeInTheDocument();
routes['GET /sessions'] = { body: [devices[0]] };
await user.click(screen.getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(screen.queryByText('Chrome on Windows')).not.toBeInTheDocument());
expect(screen.queryByRole('button', { name: /Sign out the other/ })).not.toBeInTheDocument();
});
test('the sign-in alert switch reports a server refusal', async () => {
routes['GET /sessions'] = { body: [] };
routes['PUT /auth/login-alerts'] = { status: 400, body: { error: 'Set up a notification topic first (under Notifications).' } };
const user = userEvent.setup();
render(<DevicesSection />);
await user.click(await screen.findByRole('checkbox'));
expect(await screen.findByRole('alert')).toHaveTextContent('notification topic');
expect(app.setAuthUser).not.toHaveBeenCalled();
routes['PUT /auth/login-alerts'] = { body: { loginAlerts: true } };
await user.click(screen.getByRole('checkbox'));
await waitFor(() => expect(app.setAuthUser).toHaveBeenCalledWith(expect.objectContaining({ loginAlerts: true })));
});
test('times read plainly', () => {
const now = 1_000_000_000_000;
expect(whenText(now - 30_000, now)).toBe('just now');
expect(whenText(now - 5 * 3600_000, now)).toBe('5 hours ago');
expect(whenText(now - 3 * 86400_000, now)).toBe('3 days ago');
});