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
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user