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

@@ -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>
);
}