272 lines
9.6 KiB
JavaScript
272 lines
9.6 KiB
JavaScript
/**
|
|
* Users management page.
|
|
*
|
|
* Multi-user admin: list, create, edit permissions, delete users.
|
|
* Requires auth: rw permission.
|
|
*/
|
|
|
|
import { h, definePage, reactive, html, PageHeader, Table, Badge, ConfirmDelete, openModal, closeModal, formModal, apiFetch, toast, esc, getAuthData } 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 = getAuthData()?.user;
|
|
return u ? u.username : '';
|
|
}
|
|
|
|
function hasAuthAdmin() {
|
|
const perms = getAuthData()?.permissions;
|
|
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());
|
|
},
|
|
});
|