auth: add Users + Audit admin view in SPA
This commit is contained in:
parent
4c0f8bd06f
commit
c93a611898
@ -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) {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user