auth: add Users + Audit admin view in SPA

This commit is contained in:
Ivo Oskamp 2026-05-28 16:12:11 +02:00
parent 4c0f8bd06f
commit c93a611898
3 changed files with 192 additions and 0 deletions

View File

@ -59,6 +59,7 @@ function renderUserBadge(me) {
'scan-mailbox': 'New Mailbox Scan',
'scan-entra': 'New Entra Group Scan',
'tenants': 'Tenants',
'users': 'Users',
'settings': 'Settings',
};
@ -1599,6 +1600,181 @@ function renderUserBadge(me) {
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
// -------------------------------------------------------------------------
// Users + Audit admin view
// -------------------------------------------------------------------------
async function renderUsersView() {
const root = document.getElementById('usersViewRoot');
if (!root) return;
root.innerHTML = '<section class="users-view"><h2>Users</h2>' +
'<div class="users-toolbar"><button id="newUserBtn" class="btn btn-primary">New user</button></div>' +
'<div id="usersTable"></div>' +
'<h3 style="margin-top:24px">Audit log</h3>' +
'<div id="auditTable"></div></section>';
document.getElementById('newUserBtn').addEventListener('click', function () { openUserModal(null); });
await reloadUsersTable();
await reloadAuditTable();
}
async function reloadUsersTable() {
const target = document.getElementById('usersTable');
if (!target) return;
let rows;
try {
rows = await requestJson('/api/users');
} catch (e) {
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
return;
}
target.innerHTML = '';
const tbl = document.createElement('table');
tbl.innerHTML = '<thead><tr><th>Username</th><th>Role</th><th>Active</th><th>Created</th><th></th></tr></thead>';
const tb = document.createElement('tbody');
rows.forEach(function (u) {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + escHtml(u.username) + '</td>' +
'<td>' + escHtml(u.role) + '</td>' +
'<td>' + (u.is_active ? 'yes' : 'no') + '</td>' +
'<td>' + escHtml(new Date(u.created_at).toLocaleString()) + '</td>';
const actions = document.createElement('td');
const edit = Object.assign(document.createElement('button'), { textContent: 'Edit', className: 'btn btn-outline btn-small' });
edit.addEventListener('click', function () { openUserModal(u); });
const reset = Object.assign(document.createElement('button'), { textContent: 'Reset pw', className: 'btn btn-outline btn-small' });
reset.addEventListener('click', function () { openResetModal(u); });
const del = Object.assign(document.createElement('button'), { textContent: 'Delete', className: 'btn btn-outline btn-small' });
del.addEventListener('click', async function () {
if (!confirm('Delete ' + u.username + '?')) return;
const r = await fetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) { alert('Delete failed'); return; }
await reloadUsersTable();
await reloadAuditTable();
});
actions.append(edit, ' ', reset, ' ', del);
tr.append(actions);
tb.append(tr);
});
tbl.append(tb);
target.append(tbl);
}
function openUserModal(existing) {
const isNew = !existing;
const usernameSafe = existing ? escHtml(existing.username) : '';
const html = '<div class="modal-back"><div class="modal">' +
'<h3>' + (isNew ? 'New user' : 'Edit ' + usernameSafe) + '</h3>' +
'<label>Username<input id="muUsername" ' + (isNew ? '' : 'disabled') + ' value="' + usernameSafe + '" /></label>' +
(isNew ? '<label>Password<input id="muPassword" type="password" /></label>' : '') +
'<label>Role<select id="muRole">' +
'<option value="user"' + (existing && existing.role === 'user' ? ' selected' : '') + '>user</option>' +
'<option value="admin"' + (existing && existing.role === 'admin' ? ' selected' : '') + '>admin</option>' +
'</select></label>' +
(isNew ? '' : '<label><input id="muActive" type="checkbox"' + (existing.is_active ? ' checked' : '') + ' /> Active</label>') +
'<p id="muError" class="auth-error" hidden></p>' +
'<div class="modal-actions">' +
'<button id="muCancel" class="btn btn-outline">Cancel</button>' +
'<button id="muSave" class="btn btn-primary">' + (isNew ? 'Create' : 'Save') + '</button>' +
'</div></div></div>';
const wrap = document.createElement('div');
wrap.innerHTML = html;
document.body.append(wrap);
const close = function () { wrap.remove(); };
document.getElementById('muCancel').addEventListener('click', close);
document.getElementById('muSave').addEventListener('click', async function () {
const err = document.getElementById('muError');
err.hidden = true;
try {
if (isNew) {
await requestJson('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('muUsername').value,
password: document.getElementById('muPassword').value,
role: document.getElementById('muRole').value,
}),
});
} else {
await requestJson('/api/users/' + encodeURIComponent(existing.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: document.getElementById('muRole').value,
is_active: document.getElementById('muActive').checked,
}),
});
}
close();
await reloadUsersTable();
await reloadAuditTable();
} catch (e) {
err.textContent = (e && e.message) || 'Save failed';
err.hidden = false;
}
});
}
function openResetModal(u) {
const wrap = document.createElement('div');
wrap.innerHTML = '<div class="modal-back"><div class="modal">' +
'<h3>Reset password for ' + escHtml(u.username) + '</h3>' +
'<label>New password<input id="rpPw" type="password" /></label>' +
'<p id="rpErr" class="auth-error" hidden></p>' +
'<div class="modal-actions">' +
'<button id="rpCancel" class="btn btn-outline">Cancel</button>' +
'<button id="rpSave" class="btn btn-primary">Reset</button>' +
'</div></div></div>';
document.body.append(wrap);
const close = function () { wrap.remove(); };
document.getElementById('rpCancel').addEventListener('click', close);
document.getElementById('rpSave').addEventListener('click', async function () {
const err = document.getElementById('rpErr');
err.hidden = true;
try {
await requestJson('/api/users/' + encodeURIComponent(u.id) + '/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('rpPw').value }),
});
close();
await reloadAuditTable();
} catch (e) {
err.textContent = (e && e.message) || 'Reset failed';
err.hidden = false;
}
});
}
async function reloadAuditTable() {
const target = document.getElementById('auditTable');
if (!target) return;
let rows;
try {
rows = await requestJson('/api/audit?limit=100');
} catch (e) {
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
return;
}
target.innerHTML = '';
const tbl = document.createElement('table');
tbl.innerHTML = '<thead><tr><th>When</th><th>Event</th><th>User</th><th>IP</th><th>Detail</th></tr></thead>';
const tb = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
const detailText = r.detail ? JSON.stringify(r.detail) : '';
tr.innerHTML =
'<td>' + escHtml(new Date(r.ts).toLocaleString()) + '</td>' +
'<td>' + escHtml(r.event) + '</td>' +
'<td>' + escHtml(r.user_id == null ? '' : String(r.user_id)) + '</td>' +
'<td>' + escHtml(r.ip == null ? '' : String(r.ip)) + '</td>' +
'<td>' + escHtml(detailText) + '</td>';
tb.append(tr);
});
tbl.append(tb);
target.append(tbl);
}
// -------------------------------------------------------------------------
// Hash router
// -------------------------------------------------------------------------
@ -1639,6 +1815,9 @@ function renderUserBadge(me) {
els.contentTitle.textContent = ROUTE_TITLES[route];
}
document.title = 'Clearview | ' + ROUTE_TITLES[route];
if (route === 'users') {
renderUsersView().catch(function (err) { console.error('Users view failed', err); });
}
// On user navigation, move focus to the new page's first heading so
// screen-reader and keyboard users land in the freshly shown content.
if (moveFocus && activePage) {

View File

@ -572,6 +572,10 @@
<!-- =================================================================== -->
<!-- Route: Settings (placeholder) -->
<!-- =================================================================== -->
<section class="route-page" data-route-page="users" hidden>
<div id="usersViewRoot"></div>
</section>
<section class="route-page" data-route-page="settings" hidden>
<div class="panel">
<div class="panel-header split">

View File

@ -768,3 +768,12 @@ strong {
.user-badge { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border: 1px solid #2a2f3a; border-radius: 999px; font-size: 12px; }
.user-badge button { background: transparent; border: 0; color: #93c5fd; cursor: pointer; padding: 0; }
.user-badge button:hover { text-decoration: underline; }
/* Users / audit admin view */
.modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: #1a1d24; color: #e6e8ee; padding: 20px; border-radius: 10px; min-width: 320px; display: flex; flex-direction: column; gap: 10px; }
.modal label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
.users-view table { width: 100%; border-collapse: collapse; }
.users-view th, .users-view td { padding: 6px 8px; border-bottom: 1px solid #2a2f3a; text-align: left; }
.users-toolbar { margin: 12px 0; }