Files
meshcore-analyzer/public/auth.js
T
efitenandClaude Opus 5.5 18a634c115 feat: admin dashboard with overview and audit log (user management part C) (#2138)
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>
2026-10-08 21:38:30 +02:00

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 }
};
})();