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-mailbox': 'New Mailbox Scan',
|
||||||
'scan-entra': 'New Entra Group Scan',
|
'scan-entra': 'New Entra Group Scan',
|
||||||
'tenants': 'Tenants',
|
'tenants': 'Tenants',
|
||||||
|
'users': 'Users',
|
||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1599,6 +1600,181 @@ function renderUserBadge(me) {
|
|||||||
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
|
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
|
||||||
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
|
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
|
// Hash router
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -1639,6 +1815,9 @@ function renderUserBadge(me) {
|
|||||||
els.contentTitle.textContent = ROUTE_TITLES[route];
|
els.contentTitle.textContent = ROUTE_TITLES[route];
|
||||||
}
|
}
|
||||||
document.title = 'Clearview | ' + 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
|
// On user navigation, move focus to the new page's first heading so
|
||||||
// screen-reader and keyboard users land in the freshly shown content.
|
// screen-reader and keyboard users land in the freshly shown content.
|
||||||
if (moveFocus && activePage) {
|
if (moveFocus && activePage) {
|
||||||
|
|||||||
@ -572,6 +572,10 @@
|
|||||||
<!-- =================================================================== -->
|
<!-- =================================================================== -->
|
||||||
<!-- Route: Settings (placeholder) -->
|
<!-- 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>
|
<section class="route-page" data-route-page="settings" hidden>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header split">
|
<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 { 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 { background: transparent; border: 0; color: #93c5fd; cursor: pointer; padding: 0; }
|
||||||
.user-badge button:hover { text-decoration: underline; }
|
.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