Files
vacuum-wall/webui/static/pages/passkeys.js
T
mteehan 244576b8eb fix: render Add passkey button on empty state
The Empty component ignores children, so the button was silently dropped
when using <Empty> as an htm wrapper. Inline the card structure instead.
2026-07-28 16:45:34 +00:00

300 lines
9.8 KiB
JavaScript

/**
* 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`<p class="text-sm">Remove "<strong>${esc(credentialName || credentialId.slice(0, 12))}</strong>"?</p>`,
{
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`<div class="card" key="loading">
<div class="card-body loading">Loading...</div>
</div>`,
];
}
if (state.error) {
return [
PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }),
html`<div class="card" key="error">
<div class="card-body error-msg">${esc(state.error)}</div>
</div>`,
];
}
if (!state.credentials.length) {
return [
PageHeader({
title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication',
actions: html`<button class="btn btn-sm btn-primary" onClick=${() => webauthnSupported() && addCredentialModal()}>
Add passkey
</button>`,
}),
html`<div class="card" key="empty">
<div class="text-muted text-sm">No passkeys registered</div>
<button class="btn btn-sm btn-primary"
onClick=${() => webauthnSupported() && addCredentialModal()}>
Add passkey
</button>
</div>`,
];
}
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`<Badge>${esc(t)}</Badge>`
),
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`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>
Add passkey
</button>`
: html`<span class="text-sm text-muted">WebAuthn not supported in this browser</span>`;
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;