Files
vacuum-wall/webui/static/pages/passkeys.js
T
mteehan 8ae60ab8cf fix: harden auth and fix frontend issues
- Add builtin admin user with full access, immutable permissions (lib/db.py, lib/auth_users.py, webui/static/pages/users.js)
- Fix passkeys TypeError on string throws (webui/static/pages/passkeys.js)
- Add zero-permission warning in create user modal (webui/static/pages/users.js)
- Restore readonly on proxy paths textarea (webui/static/pages/proxy.js)
- Mask credential ownership errors to prevent enumeration (lib/webauthn.py, tests/test_auth.py)
2026-07-28 18:52:03 +00:00

285 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,
Badge,
esc,
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 new Error(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 new Error(finishRes.error || 'Registration verification failed');
}
toast('Passkey registered', 'success');
closeModal();
loadCredentials();
} catch (e) {
const msg = typeof e === 'string' ? e : (e.message || 'Registration failed');
if (!msg.toLowerCase().includes('cancelled')) {
toast(msg, '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 new Error(res.error || 'Removal failed');
}
toast('Passkey removed', 'success');
closeModal();
loadCredentials();
} catch (e) {
const msg = typeof e === 'string' ? e : (e.message || 'Removal failed');
toast(msg, '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: webauthnSupported()
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
: html`<span class="text-sm text-muted">WebAuthn not supported</span>`,
}),
html`<div class="card" key="empty">
<div class="text-muted text-sm">No passkeys registered</div>
${webauthnSupported()
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
: ''}
</div>`,
];
}
const rows = state.credentials.map(c =>
html`<tr key=${c.id}>
<td><strong>${esc(c.name || 'Unnamed')}</strong></td>
<td>${(c.transports || ['internal']).map(t => html`<${Badge} text=${esc(t)} />`)}</td>
<td>${c.sign_count ?? 0}</td>
<td class="text-sm">${esc(c.id.slice(0, 12) + '...')}</td>
<td><button class="btn btn-sm btn-outline" onClick=${() => confirmRemove(c.id, c.name)}>Remove</button></td>
</tr>`
);
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</span>`;
return [
PageHeader({
title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication',
actions,
}),
Table({
columns: ['Name', 'Transports', 'Uses', 'ID', 'Actions'],
rows,
emptyText: 'No passkeys found',
}),
];
}
const Page = definePage({
init() {
document.title = 'Passkeys — Vacuum Wall';
return state;
},
async load(s, abortController) {
await loadCredentials(abortController);
},
render() {
return CredentialsPage();
},
});
export default Page;