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>
75 lines
4.3 KiB
React
75 lines
4.3 KiB
React
// 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>
|
||
);
|
||
}
|