/** * 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'; 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(sessionStorage.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`