mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 18:17:15 +00:00
Part A of #2128: optional, off-by-default user accounts. With the feature off nothing changes; with it on, visitors can register and log in, and admins manage users and use the operator actions without the API key. PR #2130 (settings sync) builds on this one. The two are meant to be merged together. ## The situation - Operator actions (geofilter save and prune, backup, perf reset) need the shared `apiKey`. There is no per-person right. - Nothing in CoreScope knows who a visitor is, so the requests in #2128 that need that (#1835, #2092, #1508, #730) have nothing to build on. ## What this PR adds **Two new Go modules** - `internal/users`: a separate `users.db` (SQLite through `modernc.org/sqlite`) with users, sessions, single-use tokens, an audit log and a mail log. Passwords use argon2id. - `internal/mailer`: a `Mailer` interface with a Brevo client (send, delivery events, webhook parsing) and an in-memory fake for tests. **Server (`cmd/server`)**, active only with `userManagement.enabled` - 24 routes, all documented in OpenAPI under the `users` tag ([`auth_routes.go`](https://github.com/efiten/CoreScope/blob/feat/user-management/cmd/server/auth_routes.go)): - auth: register, activate, login, logout, me, forgot, reset; - account: profile, password, email change with confirmation, sessions, self-delete; - admin: list, detail, disable, enable, delete, role, resend activation, manual activation, mail status refresh; - a Brevo webhook, registered only when `mail.webhookSecret` is set. - `requireAdmin` replaces `requireAPIKey` at the 7 operator call sites: the API key **or** an admin session. With the feature off it is the old API-key gate (`TestRequireAdminWithoutUserManagementIsAPIKeyGate`). - `/api/config/client` gets `userManagement: {enabled: true}` only when the service started; with the feature off the response is byte-identical. **Frontend** - `auth.js` (header account control, request helper that adds the CSRF header), `account.js` (login, register, activate, forgot, reset, confirm email, my account), `admin-users.js` (`#/admin/users`, deep-linked filters), `account.css` (theme tokens only). - On phones the top-bar control is hidden, so a conditional entry goes into the bottom-nav "More" sheet and the nav drawer. - The customizer geofilter tab and the Perf "Reset stats" button use the admin session when there is one. **Config.** A `userManagement` block (`config.example.json`, [`docs/user-guide/accounts.md`](https://github.com/efiten/CoreScope/blob/feat/user-management/docs/user-guide/accounts.md)). The Brevo key can come from `CORESCOPE_BREVO_API_KEY`. The server refuses to start when the block is enabled but incomplete. ## Security choices - Session cookie `cs_session`: HttpOnly, SameSite=Lax, Secure when `publicBaseUrl` is https. Every cookie-authenticated state change needs the `X-CS-CSRF` header and a matching Origin. - Activation needs the token **and** the account password. Without the password, an attacker who keeps re-registering a known address could get the owner to activate an account that carries the attacker's password. - Register, forgot and email change answer identically for known and unknown addresses. A password reset ends all sessions, a password change ends all other sessions, and both end outstanding email-change links. - Rate limits: login 10 per 15 minutes, register and forgot 5 per hour, per IP and per address. The bucket count is capped. `trustedProxies` makes the per-IP limits see real client IPs behind a proxy. - Server logs carry `#<user id>`, never addresses, tokens or passwords; mail-provider error texts are redacted before logging. ## Performance No change to an existing hot path with the feature off. With it on: - One `users.db` lookup per authenticated request (session by token hash). - The admin user table rebuilds its `tbody` on each filter change. `users.List` caps the result at 1000 rows (`internal/users/users.go`), which bounds the rebuild. - `map[string]interface{}` in `openapi.go`: 79 before, 78 after. ## Verification - `internal/users`, `internal/mailer` and `cmd/server`: `go vet` and `go test -race` pass locally. 121 new Go tests. - `cmd/server` with `-tags e2etest`: vet and the e2e hook tests pass. - `sh test-all.sh` exits 0. `tests/unit/test-user-management-ui.js`: 67 passing (vm, real modules). - `tests/e2e/test-user-management-e2e.js` (6 steps) passed locally against an `e2etest` build with the fake mailer and against a feature-off build. CI builds the `e2etest` binary and runs the suite on a second server (`deploy.yml`). - On a staging instance with a real Brevo key: register, activation mail delivered, activate, admin table, "Refresh status" showing sent, deferred, delivered, opened and clicked. ## Not in this PR - Settings sync (#2130), the admin dashboard, approval flows and notifications (parts B to E of #2128). - A `requireReadAuth` mode (#1835). Sessions from this PR are what such a mode would accept. - Binary size and build time with `modernc.org/sqlite` linked next to `mattn/go-sqlite3` were not measured. Their driver names do not collide. #1992 discusses the driver choice. - No Brevo webhook was configured on staging; delivery status there came from "Refresh status". --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
/* Issue #1061 — Bottom navigation for narrow viewports.
|
|
* Issue #1174 — Add 6th "More" tab + bottom-anchored sheet for long-tail routes.
|
|
*
|
|
* Renders 6 tabs anchored to the bottom on viewports ≤768px:
|
|
* 1. Home — primary
|
|
* 2. Packets — primary
|
|
* 3. Live — primary
|
|
* 4. Map — primary
|
|
* 5. Channels — primary
|
|
* 6. More — toggles a bottom-anchored sheet listing the long-tail
|
|
* routes (Nodes, Tools, Observers, Analytics, Perf, Audio Lab).
|
|
* Replaces the hamburger at ≤768px (#1174 design call).
|
|
*
|
|
* Tabs are <a href="#/..."> so they reuse the existing hashchange-driven
|
|
* router in app.js (no full reload, no reimplementation of routing logic).
|
|
* The "More" tab is a <button> (not <a>) since it toggles UI rather than
|
|
* navigating to a hash.
|
|
*
|
|
* Stable selectors for tests / future automation:
|
|
* [data-bottom-nav] — the <nav> container
|
|
* [data-bottom-nav-tab="<route>"] — each tab including "more"
|
|
* [data-bottom-nav-sheet] — the popover sheet
|
|
* [data-bottom-nav-more-route="<route>"] — each long-tail route in the sheet
|
|
*
|
|
* Active-tab highlight is a class toggle ("active") set on hashchange.
|
|
* Visual treatment lives in bottom-nav.css and respects
|
|
* prefers-reduced-motion (transitions disabled).
|
|
*
|
|
* Sheet behavior:
|
|
* - tap More → sheet opens, aria-expanded="true"
|
|
* - tap More while open → sheet closes (toggle, not push)
|
|
* - tap any route inside → in-app router navigates AND sheet closes
|
|
* - tap outside (anywhere not the sheet or the More tab) → sheet closes
|
|
* - sheet has role="menu" for a11y
|
|
*
|
|
* The sheet DOM is built lazily on first open — it's only used at ≤768px
|
|
* and there's no point sitting in the DOM at desktop widths.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (typeof document === 'undefined') return;
|
|
|
|
// Phosphor sprite ref helper (#1648 M1).
|
|
// Returns an inline SVG that uses the bundled sprite at /icons/phosphor-sprite.svg.
|
|
// Inherits color via currentColor and size via the surrounding font-size.
|
|
function phIconHTML(name) {
|
|
return '<svg class="ph-icon" aria-hidden="true" focusable="false">' +
|
|
'<use href="/icons/phosphor-sprite.svg#ph-' + name + '"></use></svg>';
|
|
}
|
|
|
|
// 5 primary tabs + the More toggle. Each entry: { route, hash, label, ph }.
|
|
// For More, hash is null (not a route). `ph` is the Phosphor icon id
|
|
// (no "ph-" prefix) — see public/icons/phosphor-sprite.svg.
|
|
var TABS = [
|
|
{ route: 'home', hash: '#/home', label: 'Home', ph: 'house' },
|
|
{ route: 'packets', hash: '#/packets', label: 'Packets', ph: 'package' },
|
|
{ route: 'live', hash: '#/live', label: 'Live', ph: 'broadcast' },
|
|
{ route: 'map', hash: '#/map', label: 'Map', ph: 'map-trifold' },
|
|
{ route: 'channels', hash: '#/channels', label: 'Channels', ph: 'chat-circle' },
|
|
{ route: 'more', hash: null, label: 'More', ph: 'list' },
|
|
];
|
|
|
|
// Long-tail routes surfaced in the More sheet. Mirrors data-route values
|
|
// from the existing top-nav (public/index.html). Order matches what
|
|
// operators expect from the desktop top-nav.
|
|
//
|
|
// !! MANUAL SYNC REQUIRED !!
|
|
// This list is intentionally hardcoded (not generated from
|
|
// `.top-nav .nav-link[data-route]`) because the top-nav HTML is in
|
|
// mid-rewrite and not a reliable single-source-of-truth. If you add a
|
|
// new top-nav route (e.g. a future "Lab" page), you MUST also append
|
|
// it here, or it will be unreachable on phones at <=768px (the
|
|
// hamburger is hidden at that breakpoint -- see bottom-nav.css).
|
|
var MORE_ROUTES = [
|
|
{ route: 'nodes', hash: '#/nodes', label: 'Nodes', ph: 'monitor' },
|
|
{ route: 'tools', hash: '#/tools', label: 'Tools', ph: 'wrench' },
|
|
{ route: 'observers', hash: '#/observers', label: 'Observers', ph: 'eye' },
|
|
{ route: 'analytics', hash: '#/analytics', label: 'Analytics', ph: 'chart-bar' },
|
|
{ route: 'scope-audit', hash: '#/scope-audit', label: 'Scope Audit', ph: 'clipboard-text' },
|
|
{ route: 'perf', hash: '#/perf', label: 'Perf', ph: 'lightning' },
|
|
{ route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', ph: 'music-note' },
|
|
];
|
|
|
|
// The Coverage route is opt-in (config `clientRxCoverage`). roles.js injects
|
|
// its link into the desktop top-nav only, so it must be added here too or it
|
|
// is unreachable on phones. Evaluated at lazy build time (first sheet open),
|
|
// by which point MeshConfigReady has resolved window.MC_CLIENT_RX_COVERAGE.
|
|
var COVERAGE_ROUTE = { route: 'rx-coverage', hash: '#/rx-coverage', label: 'Coverage', ph: 'broadcast' };
|
|
|
|
// Optional user management (config `userManagement.enabled`). The top-nav
|
|
// account control is hidden at <=768px, so the entry lives here. The label
|
|
// follows the login state; see the cs-auth-changed listener below.
|
|
// !! Keep in sync with public/nav-drawer.js accountRoute().
|
|
function accountRoute() {
|
|
return window.CS_USER
|
|
? { route: 'account', hash: '#/account', label: 'My account', ph: 'user-circle' }
|
|
: { route: 'account', hash: '#/account/login', label: 'Log in', ph: 'user-circle' };
|
|
}
|
|
|
|
function moreRoutes() {
|
|
var out = MORE_ROUTES.slice();
|
|
if (window.MC_CLIENT_RX_COVERAGE) {
|
|
var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1;
|
|
out.splice(after, 0, COVERAGE_ROUTE);
|
|
}
|
|
if (window.MC_USER_MGMT) out.push(accountRoute());
|
|
return out;
|
|
}
|
|
|
|
window.addEventListener('cs-auth-changed', function () {
|
|
var a = document.querySelector('#' + SHEET_ID + ' [data-bottom-nav-more-route="account"]');
|
|
if (!a) return;
|
|
var r = accountRoute();
|
|
a.setAttribute('href', escapeHtml(r.hash));
|
|
var lb = a.querySelector('.bottom-nav-sheet-label');
|
|
if (lb) lb.textContent = r.label;
|
|
});
|
|
|
|
var SHEET_ID = 'bottomNavMoreSheet';
|
|
|
|
function currentRoute() {
|
|
// Mirror app.js navigate(): strip "#/" and any trailing "?…" / "/…".
|
|
var h = (location.hash || '').replace(/^#\//, '');
|
|
if (!h) return 'packets'; // app.js default
|
|
var slash = h.indexOf('/');
|
|
if (slash >= 0) h = h.substring(0, slash);
|
|
var q = h.indexOf('?');
|
|
if (q >= 0) h = h.substring(0, q);
|
|
return h || 'packets';
|
|
}
|
|
|
|
function build() {
|
|
if (document.querySelector('[data-bottom-nav]')) return;
|
|
|
|
var nav = document.createElement('nav');
|
|
nav.className = 'bottom-nav';
|
|
nav.setAttribute('data-bottom-nav', '');
|
|
nav.setAttribute('role', 'navigation');
|
|
nav.setAttribute('aria-label', 'Bottom navigation');
|
|
|
|
TABS.forEach(function (t) {
|
|
var el;
|
|
if (t.route === 'more') {
|
|
// <button> for the toggle: it does not navigate.
|
|
el = document.createElement('button');
|
|
el.setAttribute('type', 'button');
|
|
el.setAttribute('aria-haspopup', 'menu');
|
|
el.setAttribute('aria-expanded', 'false');
|
|
el.setAttribute('aria-controls', SHEET_ID);
|
|
} else {
|
|
el = document.createElement('a');
|
|
el.setAttribute('href', t.hash);
|
|
}
|
|
el.className = 'bottom-nav-tab';
|
|
el.setAttribute('data-bottom-nav-tab', t.route);
|
|
el.setAttribute('data-route', t.route);
|
|
el.setAttribute('aria-label', t.label);
|
|
|
|
var ic = document.createElement('span');
|
|
ic.className = 'bottom-nav-icon';
|
|
ic.setAttribute('aria-hidden', 'true');
|
|
ic.innerHTML = phIconHTML(t.ph);
|
|
|
|
var lb = document.createElement('span');
|
|
lb.className = 'bottom-nav-label';
|
|
lb.textContent = t.label;
|
|
|
|
el.appendChild(ic);
|
|
el.appendChild(lb);
|
|
nav.appendChild(el);
|
|
});
|
|
|
|
// Insert after <main> so it's a sibling at the body level — keeps
|
|
// it out of the <main> scroll container. The CSS pins it bottom:0
|
|
// via position:fixed so DOM order beyond "after the nav" doesn't
|
|
// matter for layout, but document order matters for screen readers.
|
|
var main = document.getElementById('app') || document.querySelector('main');
|
|
if (main && main.parentNode) {
|
|
main.parentNode.insertBefore(nav, main.nextSibling);
|
|
} else {
|
|
document.body.appendChild(nav);
|
|
}
|
|
|
|
wireMoreSheet();
|
|
}
|
|
|
|
function syncActive() {
|
|
var route = currentRoute();
|
|
// #1174 mesh-op review: the More tab represents the long-tail
|
|
// routes; reflect that in the active-class so users on /tools,
|
|
// /analytics, etc. still see WHICH tab they're under. Without this
|
|
// every long-tail route lit up zero tabs.
|
|
var moreRouteSet = {};
|
|
for (var k = 0; k < MORE_ROUTES.length; k++) moreRouteSet[MORE_ROUTES[k].route] = 1;
|
|
var routeIsLongTail = !!moreRouteSet[route];
|
|
var tabs = document.querySelectorAll('[data-bottom-nav-tab]');
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
var t = tabs[i];
|
|
var tabRoute = t.getAttribute('data-bottom-nav-tab');
|
|
if (tabRoute === 'more') {
|
|
// The More tab IS active when the current route belongs to the
|
|
// long-tail set surfaced by the More sheet. We do NOT add
|
|
// aria-current here — the tab toggles a sheet, not a single
|
|
// page, so aria-current="page" would lie. The visual active
|
|
// class is the user-facing affordance; that's enough.
|
|
if (routeIsLongTail) t.classList.add('active');
|
|
else if (!isSheetOpen()) t.classList.remove('active');
|
|
// If the sheet is open we leave .active alone — openSheet()
|
|
// owns the class while open.
|
|
continue;
|
|
}
|
|
if (tabRoute === route) {
|
|
t.classList.add('active');
|
|
t.setAttribute('aria-current', 'page');
|
|
} else {
|
|
t.classList.remove('active');
|
|
t.removeAttribute('aria-current');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── More sheet ──
|
|
// Built lazily on first open; lives as a sibling of the <nav> so the
|
|
// bottom-nav's z-index/stacking is independent of the sheet. The sheet
|
|
// is anchored above the bottom-nav via CSS (bottom: <nav-height>).
|
|
function getOrBuildSheet() {
|
|
var existing = document.getElementById(SHEET_ID);
|
|
if (existing) return existing;
|
|
|
|
var sheet = document.createElement('div');
|
|
sheet.id = SHEET_ID;
|
|
sheet.className = 'bottom-nav-sheet';
|
|
sheet.setAttribute('data-bottom-nav-sheet', '');
|
|
sheet.setAttribute('role', 'menu');
|
|
sheet.setAttribute('aria-label', 'More navigation');
|
|
sheet.hidden = true;
|
|
|
|
moreRoutes().forEach(function (r) {
|
|
var a = document.createElement('a');
|
|
a.className = 'bottom-nav-sheet-item';
|
|
a.setAttribute('href', escapeHtml(r.hash));
|
|
a.setAttribute('role', 'menuitem');
|
|
a.setAttribute('data-bottom-nav-more-route', r.route);
|
|
a.setAttribute('data-route', r.route);
|
|
|
|
var ic = document.createElement('span');
|
|
ic.className = 'bottom-nav-sheet-icon';
|
|
ic.setAttribute('aria-hidden', 'true');
|
|
ic.innerHTML = phIconHTML(r.ph);
|
|
|
|
var lb = document.createElement('span');
|
|
lb.className = 'bottom-nav-sheet-label';
|
|
lb.textContent = r.label;
|
|
|
|
a.appendChild(ic);
|
|
a.appendChild(lb);
|
|
|
|
// Tap a route → close sheet (the <a href> handles navigation via
|
|
// the existing hashchange router in app.js).
|
|
a.addEventListener('click', function () { closeSheet(); });
|
|
|
|
sheet.appendChild(a);
|
|
});
|
|
|
|
// Dark mode toggle — mirrors #darkModeToggle in the hidden top-nav.
|
|
// Delegates the click to the real button so app.js owns all theme logic.
|
|
var sep = document.createElement('hr');
|
|
sep.className = 'bottom-nav-sheet-sep';
|
|
sheet.appendChild(sep);
|
|
|
|
var darkBtn = document.createElement('button');
|
|
darkBtn.type = 'button';
|
|
darkBtn.className = 'bottom-nav-sheet-item bottom-nav-sheet-darkbtn';
|
|
darkBtn.setAttribute('role', 'menuitem');
|
|
darkBtn.setAttribute('data-bottom-nav-dark-toggle', '');
|
|
|
|
var darkIc = document.createElement('span');
|
|
darkIc.className = 'bottom-nav-sheet-icon';
|
|
darkIc.setAttribute('aria-hidden', 'true');
|
|
|
|
var darkLb = document.createElement('span');
|
|
darkLb.className = 'bottom-nav-sheet-label';
|
|
|
|
darkBtn.appendChild(darkIc);
|
|
darkBtn.appendChild(darkLb);
|
|
sheet.appendChild(darkBtn);
|
|
|
|
darkBtn.addEventListener('click', function () {
|
|
var realToggle = document.getElementById('darkModeToggle');
|
|
if (realToggle) realToggle.click();
|
|
syncDarkModeBtn();
|
|
closeSheet();
|
|
});
|
|
|
|
// Sit the sheet next to the nav so they share a stacking context.
|
|
var nav = document.querySelector('[data-bottom-nav]');
|
|
if (nav && nav.parentNode) {
|
|
nav.parentNode.insertBefore(sheet, nav);
|
|
} else {
|
|
document.body.appendChild(sheet);
|
|
}
|
|
return sheet;
|
|
}
|
|
|
|
function syncDarkModeBtn() {
|
|
var btn = document.querySelector('[data-bottom-nav-dark-toggle]');
|
|
if (!btn) return;
|
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
btn.querySelector('.bottom-nav-sheet-icon').innerHTML = phIconHTML(isDark ? 'moon' : 'sun');
|
|
btn.querySelector('.bottom-nav-sheet-label').textContent = isDark ? 'Light mode' : 'Dark mode';
|
|
}
|
|
|
|
function isSheetOpen() {
|
|
var sheet = document.getElementById(SHEET_ID);
|
|
return !!(sheet && !sheet.hidden);
|
|
}
|
|
|
|
function openSheet() {
|
|
var sheet = getOrBuildSheet();
|
|
sheet.hidden = false;
|
|
sheet.classList.add('open');
|
|
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
if (moreTab) {
|
|
moreTab.setAttribute('aria-expanded', 'true');
|
|
moreTab.classList.add('active');
|
|
}
|
|
syncDarkModeBtn();
|
|
}
|
|
|
|
function closeSheet() {
|
|
var sheet = document.getElementById(SHEET_ID);
|
|
if (sheet) {
|
|
sheet.hidden = true;
|
|
sheet.classList.remove('open');
|
|
}
|
|
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
if (moreTab) {
|
|
moreTab.setAttribute('aria-expanded', 'false');
|
|
moreTab.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
function toggleSheet() {
|
|
if (isSheetOpen()) closeSheet();
|
|
else openSheet();
|
|
}
|
|
|
|
function wireMoreSheet() {
|
|
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
if (!moreTab) return;
|
|
// Toggle on tap. Use click — covers mouse and synthesized tap.
|
|
moreTab.addEventListener('click', function (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggleSheet();
|
|
});
|
|
|
|
// Outside-click closes the sheet. Listen at document level; ignore
|
|
// clicks on the sheet itself or on the More tab (handled above).
|
|
document.addEventListener('click', function (ev) {
|
|
if (!isSheetOpen()) return;
|
|
var t = ev.target;
|
|
var sheet = document.getElementById(SHEET_ID);
|
|
if (sheet && sheet.contains(t)) return;
|
|
if (moreTab.contains(t)) return;
|
|
closeSheet();
|
|
});
|
|
|
|
// Tapping any OTHER bottom-nav tab also closes the sheet.
|
|
var otherTabs = document.querySelectorAll('[data-bottom-nav-tab]');
|
|
for (var i = 0; i < otherTabs.length; i++) {
|
|
var t = otherTabs[i];
|
|
if (t.getAttribute('data-bottom-nav-tab') === 'more') continue;
|
|
t.addEventListener('click', function () { closeSheet(); });
|
|
}
|
|
|
|
// Esc closes the sheet (a11y).
|
|
document.addEventListener('keydown', function (ev) {
|
|
if (ev.key === 'Escape' && isSheetOpen()) closeSheet();
|
|
});
|
|
|
|
// Hashchange (any nav) also closes — covers programmatic navigation.
|
|
window.addEventListener('hashchange', function () { closeSheet(); });
|
|
}
|
|
|
|
function init() {
|
|
// Singleton guard: init() may be invoked twice if (a) DOMContentLoaded
|
|
// fires AND (b) something else re-imports the script later, or if a
|
|
// future SPA-like re-mount path is added. The internal `build()` is
|
|
// idempotent (early-returns on existing [data-bottom-nav]), but the
|
|
// `hashchange` listener and the document-level outside-click /
|
|
// keydown listeners in wireMoreSheet() would otherwise stack, leaking
|
|
// handlers exactly like PR #1180's MQL-leak class. Bail on second call.
|
|
if (window.__bottomNavInitDone) return;
|
|
window.__bottomNavInitDone = true;
|
|
build();
|
|
syncActive();
|
|
window.addEventListener('hashchange', syncActive);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|