Files
vacuum-wall/webui/static/pages/users.js
T
mteehan 8ae60ab8cf fix: harden auth and fix frontend issues
- Add builtin admin user with full access, immutable permissions (lib/db.py, lib/auth_users.py, webui/static/pages/users.js)
- Fix passkeys TypeError on string throws (webui/static/pages/passkeys.js)
- Add zero-permission warning in create user modal (webui/static/pages/users.js)
- Restore readonly on proxy paths textarea (webui/static/pages/proxy.js)
- Mask credential ownership errors to prevent enumeration (lib/webauthn.py, tests/test_auth.py)
2026-07-28 18:52:03 +00:00

273 lines
9.8 KiB
JavaScript

/**
* Users management page.
*
* Multi-user admin: list, create, edit permissions, delete users.
* Requires auth: rw permission.
*/
import { h, definePage, reactive } from '/static/hoover/index.js';
import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js';
const BUILTIN_ADMIN = 'admin';
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(sessionStorage.getItem('vw:user') || 'null');
return u ? u.username : '';
}
function hasAuthAdmin() {
const perms = JSON.parse(sessionStorage.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', { signal: abortController?.signal }),
apiFetch('/api/auth/webauthn/credential-counts', { signal: abortController?.signal }),
]);
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) {
if (!abortController?.signal?.aborted) {
state.error = 'Failed to load users';
}
} finally {
if (!abortController?.signal?.aborted) {
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;
}
}
if (user.username === BUILTIN_ADMIN) {
toast('Cannot modify permissions for builtin admin', 'error');
return;
}
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>
${u.username === BUILTIN_ADMIN ? html`<span class="text-muted text-sm">(builtin)</span>` :
html`<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'}
onComplete=${() => 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());
},
});