ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
299 lines
9.7 KiB
JavaScript
299 lines
9.7 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?v=14';
|
|
|
|
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`<Empty text="No passkeys registered">
|
|
<button class="btn btn-sm btn-primary"
|
|
onClick=${() => webauthnSupported() && addCredentialModal()}>
|
|
Add passkey
|
|
</button>
|
|
</Empty>`,
|
|
];
|
|
}
|
|
|
|
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;
|