Files
study-bible-project/src/components/settings/PasskeysSection.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- 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>
2026-10-06 12:52:49 -04:00

75 lines
4.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}