275 lines
9.3 KiB
JavaScript
275 lines
9.3 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();
|
|
|
|
try {
|
|
// Step 1: Get registration options (username from JWT)
|
|
const beginRes = await apiFetch('/api/auth/webauthn/register-begin', {
|
|
method: 'POST',
|
|
body: {},
|
|
});
|
|
|
|
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 (username from JWT)
|
|
const finishRes = await apiFetch('/api/auth/webauthn/register-finish', {
|
|
method: 'POST',
|
|
body: {
|
|
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({
|
|
title: 'Passkeys - Vacuum Wall',
|
|
init() {
|
|
return state;
|
|
},
|
|
|
|
async load(s, abortController) {
|
|
await loadCredentials(abortController);
|
|
},
|
|
|
|
render() {
|
|
return CredentialsPage();
|
|
},
|
|
});
|
|
|
|
export default Page;
|