mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 19:17:24 +00:00
Part C of #2128: an admin area with an overview, the user table and a global audit log. Off by default like the rest of user management: with `userManagement` off nothing changes, and non-admins get no new UI. This is the first of 4 stacked PRs (C, D, E, then a small export/backup follow-up). Each later one contains this branch; review them in order. ## The situation - An admin could manage users one by one in `#/admin/users`, but nothing showed whether the instance needs attention: accounts stuck in activation, bouncing mail, someone guessing a password, an MQTT source that dropped. - The audit log existed (`internal/users/audit.go`) but could only be read per user, and logins were not recorded. ## What this PR adds **Store (`internal/users`)** - Schema v3: an index on `audit_log(at)`. - `AuditList` with filters (action or group prefix like `user.login.*`, user as actor or target, period) and keyset pagination; `PruneAudit`; `Stats` for the user figures. **Server** - `GET /api/admin/stats` (typed struct): accounts by status, admins, registrations and active users over 7 and 30 days, logins and failed logins in 24 hours, mail by final status, and the attention items computed server-side. - `GET /api/admin/audit`: filtered, newest first, `next` cursor. - `GET /api/admin/users` gains `bouncing=1`. - Logins are audited as `user.login` and `user.login.failed` (reason `wrong_password`, `pending` or `disabled`). An unknown address writes no row. The writes are asynchronous, so the login response does not wait on `users.db`. Login rows are pruned after 90 days. **Frontend** - `#/admin?tab=overview|users|audit`: `admin.js` (tab shell), `admin-overview.js` ("Needs attention", Users card, System card from the existing health, MQTT and observer endpoints), `admin-audit.js` (filters in the URL, "Load more"). `admin-users.js` becomes the Users tab; the old `#/admin/users` link rewrites to it. - `/api/healthz` is read on open and on Refresh only, not on the 60-second timer, because it walks every packet under a read lock. Spec: [`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md). ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally, 26 new Go tests. One upstream test, `TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain `master` (file modes) and is unrelated. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E with the `e2etest` build: 13 of 13 steps locally. - Running on our staging and production instance since 7 October 2026. ## Not in this PR - Restricting existing pages (perf, MQTT status) to logged-in users or admins. The admin area adds no access mechanism of its own, so that stays a later router rule plus endpoint check. - Server-enforced customizer tab restrictions (#1508). - The 24-hour, 5-attempt and 10-minute attention thresholds are constants for now (AGENTS.md rule 8: customizer later). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.8 KiB
JavaScript
201 lines
7.8 KiB
JavaScript
/* Optional user management client (docs/specs/2026-10-06-user-management-design.md).
|
|
* Inert unless /api/config/client advertises userManagement.enabled (roles.js
|
|
* sets window.MC_USER_MGMT). Exposes window.CSAuth and window.CS_USER, and
|
|
* fires 'cs-auth-changed' on window whenever the user changes. */
|
|
(function () {
|
|
'use strict';
|
|
var state = { enabled: false, user: null, ready: null };
|
|
|
|
function request(method, path, body) {
|
|
var opts = { method: method, credentials: 'same-origin', headers: { 'Accept': 'application/json' } };
|
|
if (body !== undefined) {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
if (method !== 'GET' && state.user && state.user.csrfToken) {
|
|
opts.headers['X-CS-CSRF'] = state.user.csrfToken;
|
|
}
|
|
return fetch(path, opts).then(function (res) {
|
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
if (res.status === 401 && state.user && path !== '/api/auth/login' && path !== '/api/auth/activate') {
|
|
setUser(null);
|
|
notify('You were logged out.');
|
|
}
|
|
return { ok: res.ok, status: res.status, data: data || {} };
|
|
});
|
|
});
|
|
}
|
|
|
|
function notify(msg) {
|
|
var el = document.getElementById('csAuthToast');
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = 'csAuthToast';
|
|
el.className = 'cs-auth-toast';
|
|
el.setAttribute('role', 'status');
|
|
el.setAttribute('aria-live', 'polite');
|
|
document.body.appendChild(el);
|
|
}
|
|
el.textContent = msg;
|
|
el.classList.add('visible');
|
|
clearTimeout(notify._t);
|
|
notify._t = setTimeout(function () { el.classList.remove('visible'); }, 4000);
|
|
}
|
|
|
|
function setUser(u) {
|
|
state.user = u || null;
|
|
window.CS_USER = state.user;
|
|
renderControl();
|
|
window.dispatchEvent(new CustomEvent('cs-auth-changed', { detail: state.user }));
|
|
}
|
|
|
|
// say shows text in the message box id (account pages, settings sync).
|
|
function say(id, text, ok) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.classList.toggle('ok', !!ok);
|
|
el.classList.toggle('err', !ok);
|
|
}
|
|
function errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); }
|
|
|
|
// An optional handler (settings-sync.js) runs first: it may cancel the
|
|
// logout or hand back afterLogout, which runs once the server ended the
|
|
// session and before the user is cleared.
|
|
var logoutHandler = null;
|
|
function setLogoutHandler(fn) { logoutHandler = fn; }
|
|
|
|
// logout ends the session on the server. Only when that succeeded does it
|
|
// move to next and then clear the user, so pages listening for
|
|
// 'cs-auth-changed' already see the new view. A refusal keeps the user
|
|
// and is returned for the caller to show; a cancel returns cancelled. One
|
|
// logout runs at a time: another call while the dialog is open or the
|
|
// POST is out returns cancelled.
|
|
var loggingOut = null;
|
|
function logout(next) {
|
|
if (loggingOut) return Promise.resolve({ ok: false, cancelled: true, status: 0, data: {} });
|
|
var done = function () { loggingOut = null; };
|
|
loggingOut = runLogout(next);
|
|
loggingOut.then(done, done);
|
|
return loggingOut;
|
|
}
|
|
|
|
function runLogout(next) {
|
|
return Promise.resolve(logoutHandler ? logoutHandler() : null).then(function (h) {
|
|
h = h || {};
|
|
if (h.cancel) return { ok: false, cancelled: true, status: 0, data: {} };
|
|
return request('POST', '/api/auth/logout').then(function (r) {
|
|
if (r.ok) {
|
|
if (h.afterLogout) h.afterLogout();
|
|
location.hash = next;
|
|
setUser(null);
|
|
}
|
|
return r;
|
|
});
|
|
});
|
|
}
|
|
|
|
function refreshMe() {
|
|
return request('GET', '/api/auth/me').then(function (r) {
|
|
setUser(r.ok ? r.data : null);
|
|
return state.user;
|
|
});
|
|
}
|
|
|
|
function icon(id) {
|
|
return '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#' + id + '"></use></svg>';
|
|
}
|
|
|
|
function closeMenu() {
|
|
var m = document.getElementById('accountMenu');
|
|
var b = document.getElementById('accountToggle');
|
|
if (m && !m.hidden) { m.hidden = true; if (b) b.setAttribute('aria-expanded', 'false'); }
|
|
}
|
|
|
|
// The menu is position:fixed (.top-nav clips overflow); place it under the toggle, like the More menu (#1406).
|
|
function positionMenu() {
|
|
var m = document.getElementById('accountMenu');
|
|
var b = document.getElementById('accountToggle');
|
|
if (!m || !b) return;
|
|
var r = b.getBoundingClientRect();
|
|
m.style.top = (r.bottom + 4) + 'px';
|
|
m.style.right = (window.innerWidth - r.right) + 'px';
|
|
m.style.left = 'auto';
|
|
}
|
|
|
|
function renderControl() {
|
|
if (!state.enabled) return;
|
|
var right = document.querySelector('.top-nav .nav-right');
|
|
if (!right) return;
|
|
var wrap = document.getElementById('accountWrap');
|
|
if (!wrap) {
|
|
wrap = document.createElement('div');
|
|
wrap.id = 'accountWrap';
|
|
wrap.className = 'nav-account-wrap';
|
|
right.insertBefore(wrap, document.getElementById('hamburger'));
|
|
}
|
|
var u = state.user;
|
|
if (!u) {
|
|
wrap.innerHTML = '<a class="nav-account-btn" id="accountToggle" href="#/account/login">' +
|
|
icon('ph-user-circle') + '<span class="nav-account-label">Log in</span></a>';
|
|
return;
|
|
}
|
|
var html = '<button class="nav-account-btn" id="accountToggle" aria-haspopup="true" aria-expanded="false" aria-controls="accountMenu" title="Account">' +
|
|
icon('ph-user-circle') + '<span class="nav-account-label">' + escapeHtml(u.displayName) + '</span></button>' +
|
|
'<div class="nav-account-menu" id="accountMenu" role="menu" hidden>' +
|
|
'<a role="menuitem" href="#/account">My account</a>' +
|
|
(u.role === 'admin' ? '<a role="menuitem" href="#/admin">Admin</a>' : '') +
|
|
'<button type="button" role="menuitem" id="accountLogout">Log out</button></div>';
|
|
wrap.innerHTML = html;
|
|
var btn = document.getElementById('accountToggle');
|
|
var menu = document.getElementById('accountMenu');
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
var open = menu.hidden;
|
|
if (open) positionMenu();
|
|
menu.hidden = !open;
|
|
btn.setAttribute('aria-expanded', String(open));
|
|
});
|
|
menu.addEventListener('click', closeMenu);
|
|
document.getElementById('accountLogout').addEventListener('click', function () {
|
|
logout('#/home').then(function (r) {
|
|
if (!r.ok && !r.cancelled) notify((r.data && r.data.error) || ('Logout failed (HTTP ' + r.status + ')'));
|
|
}, function () { notify('Network error, try again.'); });
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', closeMenu);
|
|
window.addEventListener('resize', function () {
|
|
var m = document.getElementById('accountMenu');
|
|
if (m && !m.hidden) positionMenu();
|
|
});
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
|
|
|
state.ready = Promise.resolve(window.MeshConfigReady).then(function () {
|
|
if (!window.MC_USER_MGMT || !window.MC_USER_MGMT.enabled) return null;
|
|
state.enabled = true;
|
|
renderControl();
|
|
return refreshMe();
|
|
}).catch(function () { return null; });
|
|
|
|
window.CSAuth = {
|
|
request: request,
|
|
refreshMe: refreshMe,
|
|
logout: logout,
|
|
setLogoutHandler: setLogoutHandler,
|
|
say: say,
|
|
errText: errText,
|
|
setUser: setUser,
|
|
notify: notify,
|
|
ready: function () { return state.ready; },
|
|
isEnabled: function () { return state.enabled; },
|
|
user: function () { return state.user; },
|
|
isAdmin: function () { return !!(state.user && state.user.role === 'admin'); },
|
|
adminHeaders: function () {
|
|
return (state.user && state.user.role === 'admin') ? { 'X-CS-CSRF': state.user.csrfToken } : {};
|
|
},
|
|
// Unit-test hook (tests/unit/test-user-management-ui.js).
|
|
_test: { renderControl: renderControl, state: state }
|
|
};
|
|
})();
|