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,207 @@
|
||||
/**
|
||||
* Login page.
|
||||
*
|
||||
* Username + password form, plus "Sign in with passkey" button.
|
||||
* On success: stores tokens and navigates to dashboard.
|
||||
*/
|
||||
|
||||
import { h, definePage } from '/static/hoover/index.js?v=11';
|
||||
import { apiFetch, toast, setAuthToken, getAuthToken } from '/static/hoover/api.js?v=12';
|
||||
import {
|
||||
handleLoginSuccess,
|
||||
webauthnSupported,
|
||||
startAuthentication,
|
||||
} from '/static/hoover/components/auth.js';
|
||||
|
||||
import { html } from '/static/hoover/html.js?v=9';
|
||||
|
||||
function LoginPage() {
|
||||
const hasWebAuthn = webauthnSupported();
|
||||
|
||||
return html`
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">Vacuum Wall</h2>
|
||||
<p class="login-subtitle">Sign in to continue</p>
|
||||
<form id="loginForm" class="login-form">
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
id="loginUsername"
|
||||
autocomplete="username"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div id="loginPasswordGroup" class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
id="loginPassword"
|
||||
autocomplete="current-password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
<button type="submit" class="btn btn-primary btn-login" id="loginBtn">Sign in</button>
|
||||
</form>
|
||||
${hasWebAuthn ? html`
|
||||
<div class="login-divider">or</div>
|
||||
<button type="button" class="btn btn-outline btn-passkey" id="passkeyBtn">
|
||||
Sign in with passkey
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
const form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await doPasswordLogin();
|
||||
});
|
||||
}
|
||||
|
||||
async function doPasswordLogin() {
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const errEl = document.getElementById('loginError');
|
||||
if (!username || !password) {
|
||||
errEl.textContent = 'Username and password are required';
|
||||
return;
|
||||
}
|
||||
errEl.textContent = '';
|
||||
|
||||
const res = await apiFetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
handleLoginSuccess(res.data);
|
||||
toast('Welcome, ' + username, 'success');
|
||||
} else {
|
||||
errEl.textContent = res.error || 'Login failed';
|
||||
}
|
||||
}
|
||||
|
||||
function setupPasskeyButton() {
|
||||
const passkeyBtn = document.getElementById('passkeyBtn');
|
||||
if (!passkeyBtn) return;
|
||||
|
||||
const usernameInput = document.getElementById('loginUsername');
|
||||
const passwordGroup = document.getElementById('loginPasswordGroup');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const errEl = document.getElementById('loginError');
|
||||
|
||||
passkeyBtn.addEventListener('click', async () => {
|
||||
errEl.textContent = '';
|
||||
const username = usernameInput.value.trim();
|
||||
if (!username) {
|
||||
errEl.textContent = 'Enter your username first';
|
||||
usernameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
passkeyBtn.disabled = true;
|
||||
passkeyBtn.textContent = 'Checking...';
|
||||
|
||||
try {
|
||||
const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', {
|
||||
method: 'POST',
|
||||
body: { username },
|
||||
});
|
||||
|
||||
if (!beginRes.ok) {
|
||||
errEl.textContent = beginRes.error || 'Failed to start authentication';
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.textContent = 'Sign in with passkey';
|
||||
return;
|
||||
}
|
||||
|
||||
if (beginRes.data && beginRes.data.no_webauthn) {
|
||||
errEl.textContent = 'No passkey registered for this account';
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.textContent = 'Sign in with passkey';
|
||||
return;
|
||||
}
|
||||
|
||||
const authOptions = beginRes.data;
|
||||
passkeyBtn.textContent = 'Waiting for authenticator...';
|
||||
|
||||
const assertionResponse = await startAuthentication(authOptions);
|
||||
|
||||
passkeyBtn.textContent = 'Verifying...';
|
||||
|
||||
const finishRes = await apiFetch('/api/auth/webauthn/authenticate-finish', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username,
|
||||
assertion_response: assertionResponse,
|
||||
auth_options: authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
if (finishRes.ok) {
|
||||
handleLoginSuccess(finishRes.data);
|
||||
toast('Welcome, ' + username, 'success');
|
||||
} else {
|
||||
errEl.textContent = finishRes.error || 'Passkey authentication failed';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message && err.message.toLowerCase().includes('user cancelled')) {
|
||||
errEl.textContent = 'Authentication cancelled';
|
||||
} else {
|
||||
errEl.textContent = err.message || 'Passkey authentication failed';
|
||||
}
|
||||
} finally {
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.textContent = 'Sign in with passkey';
|
||||
}
|
||||
});
|
||||
|
||||
passkeyBtn.addEventListener('mouseenter', () => {
|
||||
if (passwordGroup) {
|
||||
passwordGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
passkeyBtn.addEventListener('mouseleave', () => {
|
||||
if (passwordGroup) {
|
||||
passwordGroup.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const Page = definePage({
|
||||
init() {
|
||||
document.title = 'Login — Vacuum Wall';
|
||||
},
|
||||
|
||||
async load(state, abortController) {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/session');
|
||||
if (res.ok) {
|
||||
window.location.hash = '/dashboard';
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// auth check failed, show login
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
return h('div', null, LoginPage());
|
||||
},
|
||||
});
|
||||
|
||||
handleLogin();
|
||||
setupPasskeyButton();
|
||||
|
||||
export default Page;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Users management page.
|
||||
*
|
||||
* Multi-user admin: list, create, edit permissions, delete users.
|
||||
* Requires auth: rw permission.
|
||||
*/
|
||||
|
||||
import { h, definePage, reactive, requestUpdate } from '/static/hoover/index.js?v=11';
|
||||
import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=11';
|
||||
|
||||
const SUBSYSTEMS = [
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
{ key: 'network', label: 'Network' },
|
||||
{ key: 'dhcp', label: 'DHCP' },
|
||||
{ key: 'proxy', label: 'Proxy' },
|
||||
{ key: 'certs', label: 'Certs' },
|
||||
{ key: 'wireguard', label: 'WireGuard' },
|
||||
{ key: 'logs', label: 'Logs' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'auth', label: 'Auth' },
|
||||
];
|
||||
|
||||
function currentUser() {
|
||||
const u = JSON.parse(localStorage.getItem('vw:user') || 'null');
|
||||
return u ? u.username : '';
|
||||
}
|
||||
|
||||
function hasAuthAdmin() {
|
||||
const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null');
|
||||
return perms && perms.auth === 'rw';
|
||||
}
|
||||
|
||||
const state = reactive({ users: [], loading: true, refreshing: false, error: null });
|
||||
|
||||
async function loadUsers(abortController) {
|
||||
if (abortController?.signal?.aborted) return;
|
||||
if (state.users.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const [usersRes, countsRes] = await Promise.all([
|
||||
apiFetch('/api/auth/users'),
|
||||
apiFetch('/api/auth/webauthn/credential-counts'),
|
||||
]);
|
||||
if (abortController?.signal?.aborted) return;
|
||||
|
||||
if (usersRes.ok) {
|
||||
const credCounts = countsRes.ok ? (countsRes.data || {}) : {};
|
||||
state.users = (usersRes.data || []).map(u => ({
|
||||
...u,
|
||||
credCount: credCounts[u.username] || 0,
|
||||
}));
|
||||
} else {
|
||||
state.error = usersRes.error || 'Failed to load users';
|
||||
}
|
||||
} catch (e) {
|
||||
state.error = 'Failed to load users';
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
}
|
||||
|
||||
function permissionLevel(perms, subsystem) {
|
||||
return perms[subsystem] || '—';
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
openModal((inner) => {
|
||||
const fields = [
|
||||
{ label: 'Username', id: 'new-username', placeholder: '3-32 chars: letters, digits, dash, underscore' },
|
||||
{ label: 'Password', id: 'new-password', type: 'password', placeholder: 'At least 8 characters' },
|
||||
];
|
||||
|
||||
// Add subsystem permission selects
|
||||
for (const sub of SUBSYSTEMS) {
|
||||
fields.push({
|
||||
label: sub.label,
|
||||
id: 'perm-' + sub.key,
|
||||
tag: 'select',
|
||||
options: ['—', 'read', 'rw'],
|
||||
});
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
{
|
||||
label: 'Create',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
processing: true,
|
||||
handler: async () => {
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const password = document.getElementById('new-password').value;
|
||||
|
||||
if (!username) { toast('Username is required', 'error'); return; }
|
||||
if (!password || password.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
|
||||
|
||||
const perms = {};
|
||||
for (const sub of SUBSYSTEMS) {
|
||||
const level = document.getElementById('perm-' + sub.key).value;
|
||||
if (level && level !== '—') {
|
||||
perms[sub.key] = level;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
body: { username, password, permissions: perms },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast('User ' + username + ' created', 'success');
|
||||
closeModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
toast(res.error || 'Failed to create user', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
formModal(inner, 'Create User', fields, actions);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditPermissionsModal(user) {
|
||||
openModal((inner) => {
|
||||
const perms = user.permissions || {};
|
||||
const fields = [
|
||||
{ label: 'Username', id: 'edit-username', value: user.username, type: 'text' },
|
||||
];
|
||||
|
||||
for (const sub of SUBSYSTEMS) {
|
||||
fields.push({
|
||||
label: sub.label,
|
||||
id: 'edit-perm-' + sub.key,
|
||||
tag: 'select',
|
||||
options: [['', '—'], ['read', 'read'], ['rw', 'rw']],
|
||||
});
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
{
|
||||
label: 'Save',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
processing: true,
|
||||
handler: async () => {
|
||||
const perms = {};
|
||||
for (const sub of SUBSYSTEMS) {
|
||||
const level = document.getElementById('edit-perm-' + sub.key).value;
|
||||
if (level && level !== '—') {
|
||||
perms[sub.key] = level;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/auth/users/' + encodeURIComponent(user.username), {
|
||||
method: 'POST',
|
||||
body: { permissions: perms },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast('Permissions updated', 'success');
|
||||
closeModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
toast(res.error || 'Failed to update permissions', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
formModal(inner, 'Edit Permissions — ' + esc(user.username), fields, actions);
|
||||
|
||||
// Pre-select permission values
|
||||
for (const sub of SUBSYSTEMS) {
|
||||
const el = document.getElementById('edit-perm-' + sub.key);
|
||||
if (el) {
|
||||
el.value = perms[sub.key] || '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
if (state.loading) {
|
||||
return html`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
|
||||
<div class="card"><div class="card-body"><p class="text-muted">Loading...</p></div></div>`;
|
||||
}
|
||||
|
||||
if (state.error && !state.users.length) {
|
||||
return html`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
|
||||
<div class="card"><div class="card-body"><p class="text-danger">${esc(state.error)}</p></div></div>`;
|
||||
}
|
||||
|
||||
const myUser = currentUser();
|
||||
const rows = state.users.map(u => {
|
||||
const isMe = u.username === myUser;
|
||||
const permBadges = SUBSYSTEMS.map(sub => {
|
||||
const level = permissionLevel(u.permissions, sub.key);
|
||||
const variant = level === 'rw' ? 'info' : level === 'read' ? 'secondary' : 'light';
|
||||
if (level === '—') return null;
|
||||
return html`<span key=${sub.key}><${Badge} text=${level} variant=${variant} /> ${sub.label} </span>`;
|
||||
}).filter(Boolean);
|
||||
|
||||
return html`<tr key=${u.username}>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td class="text-sm">${u.credCount || 0}</td>
|
||||
<td class="text-sm text-muted">${permBadges.length ? permBadges.join(' ') : '—'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onClick=${() => openEditPermissionsModal(u)}>Edit</button>
|
||||
${isMe ? html`<span class="text-muted text-sm">(you)</span>` :
|
||||
html`<${ConfirmDelete}
|
||||
url=${'/api/auth/users/' + encodeURIComponent(u.username)}
|
||||
deleteKey=${u.username}
|
||||
message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'}
|
||||
success=${'User ' + esc(u.username) + ' deleted'}
|
||||
onRefresh=${() => loadUsers()} />`}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Users',
|
||||
subtitle: 'Manage users and permissions',
|
||||
actions: html`<button class="btn btn-primary" onClick=${() => openCreateUserModal()}>Add User</button>`,
|
||||
}),
|
||||
html`<div class="card">
|
||||
<div class="card-body">
|
||||
<${Table}
|
||||
columns=${['Username', 'Passkeys', 'Permissions', 'Actions']}
|
||||
rows=${rows}
|
||||
emptyText="No users found" />
|
||||
</div>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return state;
|
||||
},
|
||||
|
||||
async load(s, abortController) {
|
||||
if (!hasAuthAdmin()) {
|
||||
s.error = 'Admin access required';
|
||||
s.loading = false;
|
||||
return;
|
||||
}
|
||||
await loadUsers(abortController);
|
||||
},
|
||||
|
||||
render(s) {
|
||||
return h('div', null, UsersPage());
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user