/** * WebAuthn credentials management page. * * Lists registered passkeys with name, transports, and sign count. * Provides "Add passkey" and "Remove" actions. */ import { html, definePage, reactive, apiFetch, toast, openModal, closeModal, formModal, refreshModals, PageHeader, Empty, Table, esc, ActionCell, Badge, startRegistration, webauthnSupported, isModalProcessing, setModalProcessing, } from '/static/hoover/index.js?v=12'; const state = reactive({ credentials: [], loading: true, refreshing: false, error: null }); async function loadCredentials(abortController) { if (abortController?.signal?.aborted) return; if (state.credentials.length) state.refreshing = true; else state.loading = true; state.error = null; try { const res = await apiFetch('/api/auth/webauthn/credentials', { signal: abortController?.signal, }); if (abortController?.signal?.aborted) return; if (res.ok) { state.credentials = res.data || []; } else { state.error = res.error || 'Failed to load credentials'; } } catch (e) { if (!abortController?.signal?.aborted) { state.error = e.message || 'Failed to load credentials'; } } finally { if (!abortController?.signal?.aborted) { state.loading = false; state.refreshing = false; } } } function addCredentialModal() { if (!webauthnSupported()) { toast('WebAuthn is not supported in this browser', 'error'); return; } openModal((inner) => { formModal( inner, 'Add passkey', [ { label: 'Passkey name', id: 'cred-name', type: 'text', placeholder: 'My laptop key', }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(), }, { label: 'Register', cls: 'btn-primary', action: 'r', processing: true, handler: async () => { if (isModalProcessing()) return; setModalProcessing(true); refreshModals(); const user = JSON.parse(localStorage.getItem('vw:user') || 'null'); const username = user?.username || ''; if (!username) { toast('Username not available', 'error'); setModalProcessing(false); refreshModals(); return; } try { // Step 1: Get registration options const beginRes = await apiFetch('/api/auth/webauthn/register-begin', { method: 'POST', body: { username }, }); if (!beginRes.ok) { throw beginRes.error || 'Registration failed'; } const options = beginRes.data; // Step 2: Call browser authenticator const credentialName = document.getElementById('cred-name')?.value?.trim() || ''; const credentialResponse = await startRegistration(options); // Step 3: Verify with server const finishRes = await apiFetch('/api/auth/webauthn/register-finish', { method: 'POST', body: { username, credential_response: credentialResponse, registration_options: options, name: credentialName, }, }); if (!finishRes.ok) { throw finishRes.error || 'Registration verification failed'; } toast('Passkey registered', 'success'); closeModal(); loadCredentials(); } catch (e) { if (!e.message.toLowerCase().includes('cancelled')) { toast(e.message || 'Registration failed', 'error'); } } finally { setModalProcessing(false); refreshModals(); } }, }, ], ); }); } function confirmRemove(credentialId, credentialName) { openModal((inner) => { formModal( inner, 'Remove passkey', [], [ html`

Remove "${esc(credentialName || credentialId.slice(0, 12))}"?

`, { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(), }, { label: 'Remove', cls: 'btn-primary btn-danger', action: 'r', processing: true, handler: async () => { if (isModalProcessing()) return; setModalProcessing(true); refreshModals(); try { const res = await apiFetch('/api/auth/webauthn/creds/' + encodeURIComponent(credentialId), { method: 'DELETE', }); if (!res.ok) { throw res.error || 'Removal failed'; } toast('PassKey removed', 'success'); closeModal(); loadCredentials(); } catch (e) { toast(e.message || 'Removal failed', 'error'); } finally { setModalProcessing(false); refreshModals(); } }, }, ], ); }); } function CredentialsPage() { if (state.loading && !state.credentials.length) { return [ PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }), html`
Loading...
`, ]; } if (state.error) { return [ PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }), html`
${esc(state.error)}
`, ]; } if (!state.credentials.length) { return [ PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication', actions: html``, }), html` `, ]; } const cols = [ { key: 'name', label: 'Name' }, { key: 'transports', label: 'Transports' }, { key: 'signCount', label: 'Uses' }, { key: 'id', label: 'ID' }, { key: '_action', label: '' }, ]; const rows = state.credentials.map(c => ({ name: esc(c.name || 'Unnamed'), transports: (c.transports || ['internal']).map(t => html`${esc(t)}` ), signCount: c.sign_count ?? 0, id: esc(c.id.slice(0, 12) + '...'), _action: ActionCell({ actions: [ { label: 'Remove', cls: 'btn-danger', icon: 'Delete', onClick: () => confirmRemove(c.id, c.name), }, ], }), })); const actions = webauthnSupported() ? html`` : html`WebAuthn not supported in this browser`; return [ PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication', actions: actions, }), Table({ columns: cols, rows }), ]; } const Page = definePage({ init() { document.title = 'Passkeys — Vacuum Wall'; return state; }, async load(s, abortController) { await loadCredentials(abortController); }, render() { return CredentialsPage(); }, }); export default Page;