ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
264 lines
9.4 KiB
JavaScript
264 lines
9.4 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?v=13';
|
|
import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=13';
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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());
|
|
},
|
|
});
|