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;
|