/** * 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?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(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'), 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`

Loading...

`; } if (state.error && !state.users.length) { return html`

${esc(state.error)}

`; } 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`<${Badge} text=${level} variant=${variant} /> ${sub.label} `; }).filter(Boolean); return html` ${esc(u.username)} ${u.credCount || 0} ${permBadges.length ? permBadges.join(' ') : '—'} ${isMe ? html`(you)` : 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()} />`} `; }); return [ PageHeader({ title: 'Users', subtitle: 'Manage users and permissions', actions: html``, }), html`
<${Table} columns=${['Username', 'Passkeys', 'Permissions', 'Actions']} rows=${rows} emptyText="No users found" />
`, ]; } 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()); }, });