feat: add auth subsystem with WebAuthn passkeys support
New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password, lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps, install script, server.py, app.js, and websocket/api clients
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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,
|
||||
} from '/static/hoover/index.js?v=12';
|
||||
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
|
||||
|
||||
const state = reactive({ credentials: [], loading: true, refreshing: false, error: null });
|
||||
|
||||
async function loadCredentials() {
|
||||
if (state.credentials.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/webauthn/credentials');
|
||||
if (res.ok) {
|
||||
state.credentials = res.data || [];
|
||||
} else {
|
||||
state.error = res.error || 'Failed to load credentials';
|
||||
}
|
||||
} catch (e) {
|
||||
state.error = e.message || 'Failed to load credentials';
|
||||
}
|
||||
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(localStorage.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();
|
||||
},
|
||||
|
||||
render() {
|
||||
return CredentialsPage();
|
||||
},
|
||||
});
|
||||
|
||||
export default Page;
|
||||
Reference in New Issue
Block a user