mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 20:17:42 +00:00
Account page: an "Observer MQTT login" section below Companions (only with userManagement.observerLogins) with three states: request, login (username, created, bound observers, "Mail my credentials again") and revoked. #/account?section=observers scrolls to it. No password is rendered; no API returns one. Admin: an "Observer logins" tab (#/admin?tab=observer-logins) with the logins (Revoke / Reissue behind an inline confirm, no browser dialog) and every observer from one GET /api/observers joined client-side with the bindings: owner, "not migrated" or "orphaned", and a select to reassign or unbind. roles.js now copies observerLogins from /api/config/client into MC_USER_MGMT; without it neither the new UI nor the existing "My observers go offline" notification option could appear. The user-management E2E server (deploy.yml and the test header) runs with observerLogins enabled; the E2E covers request, mail with obs_, bind via the authz hook, resend, admin tab, revoke and reissue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
4.8 KiB
JavaScript
107 lines
4.8 KiB
JavaScript
/* #/admin: the admin area of optional user management
|
|
* (docs/specs/2026-10-07-admin-dashboard-design.md). A tab shell; each tab
|
|
* is a module with mount(container) and unmount():
|
|
* overview window.CSAdminOverview (admin-overview.js)
|
|
* users window.CSAdminUsers (admin-users.js)
|
|
* audit window.CSAdminAudit (admin-audit.js)
|
|
* proposals window.CSAdminProposals (admin-proposals.js), only with channel proposals on
|
|
* observer-logins window.CSAdminObserverLogins (admin-observer-logins.js), only with observer logins on
|
|
* Deep link: #/admin?tab=<tab>&<tab filters>. The old #/admin/users?... is
|
|
* rewritten to #/admin?tab=users&... with replaceState. Access relies on
|
|
* the server's withAdmin check and CSAuth.isAdmin(); the shell adds no
|
|
* access mechanism of its own. */
|
|
(function () {
|
|
'use strict';
|
|
var TABS = [
|
|
{ id: 'overview', label: 'Overview', mod: 'CSAdminOverview' },
|
|
{ id: 'users', label: 'Users', mod: 'CSAdminUsers' },
|
|
{ id: 'audit', label: 'Audit', mod: 'CSAdminAudit' },
|
|
{ id: 'proposals', label: 'Proposals', mod: 'CSAdminProposals', when: proposalsOn },
|
|
{ id: 'observer-logins', label: 'Observer logins', mod: 'CSAdminObserverLogins', when: observerLoginsOn }
|
|
];
|
|
function proposalsOn() { return !!(window.MC_USER_MGMT && window.MC_USER_MGMT.channelProposals); }
|
|
function observerLoginsOn() { return !!(window.MC_USER_MGMT && window.MC_USER_MGMT.observerLogins); }
|
|
function visibleTabs() { return TABS.filter(function (t) { return !t.when || t.when(); }); }
|
|
// The page on screen, whether it was rendered for an admin, and the
|
|
// mounted tab, so an auth change can redirect or re-render.
|
|
var mounted = { app: null, admin: false, tab: null };
|
|
// Bumped by every init and destroy, so an init whose CSAuth.ready()
|
|
// resolves after the page was left (or re-rendered) mounts nothing.
|
|
var renderSeq = 0;
|
|
|
|
function readTab(hash) {
|
|
var t = new URLSearchParams(String(hash || '').split('?')[1] || '').get('tab');
|
|
var tabs = visibleTabs();
|
|
for (var i = 0; i < tabs.length; i++) if (tabs[i].id === t) return tabs[i];
|
|
return tabs[0];
|
|
}
|
|
|
|
// #/admin/users?x=1 becomes #/admin?tab=users&x=1; any other hash: null.
|
|
function legacyRewrite(hash) {
|
|
var m = /^#\/admin\/users(?:\?(.*))?$/.exec(String(hash || ''));
|
|
if (!m) return null;
|
|
return '#/admin?tab=users' + (m[1] ? '&' + m[1] : '');
|
|
}
|
|
|
|
function tabsHtml(active) {
|
|
var html = '<nav class="admin-tabs" aria-label="Admin sections">';
|
|
visibleTabs().forEach(function (t) {
|
|
var on = t.id === active;
|
|
html += '<a class="tab-btn' + (on ? ' active' : '') + '" href="#/admin?tab=' + t.id + '"' +
|
|
(on ? ' aria-current="page"' : '') + '>' + t.label + '</a>';
|
|
});
|
|
return html + '</nav>';
|
|
}
|
|
|
|
function page(inner) { return '<div class="um-page admin-page">' + inner + '</div>'; }
|
|
|
|
function unmountTab() {
|
|
if (mounted.tab) window[mounted.tab.mod].unmount();
|
|
mounted.tab = null;
|
|
}
|
|
|
|
function init(app, routeParam) {
|
|
unmountTab();
|
|
var seq = ++renderSeq;
|
|
var legacy = routeParam === 'users' ? legacyRewrite(location.hash) : null;
|
|
if (legacy) {
|
|
history.replaceState(null, '', legacy);
|
|
routeParam = null;
|
|
}
|
|
app.innerHTML = page('<p>Loading…</p>');
|
|
var ready = window.CSAuth ? CSAuth.ready() : Promise.resolve();
|
|
return ready.then(function () {
|
|
if (seq !== renderSeq) return;
|
|
if (routeParam || !window.CSAuth || !CSAuth.isEnabled()) {
|
|
app.innerHTML = page('<h2>Not found</h2>');
|
|
return;
|
|
}
|
|
mounted.app = app;
|
|
mounted.admin = CSAuth.isAdmin();
|
|
if (!mounted.admin) {
|
|
app.innerHTML = page('<h2>Admin</h2><p>Admins only. <a href="#/account/login">Log in</a></p>');
|
|
return;
|
|
}
|
|
var tab = readTab(location.hash);
|
|
app.innerHTML = page('<h2>Admin</h2>' + tabsHtml(tab.id) + '<div id="adminTab"></div>');
|
|
mounted.tab = tab;
|
|
window[tab.mod].mount(document.getElementById('adminTab'));
|
|
}).catch(function () {
|
|
if (seq !== renderSeq) return;
|
|
app.innerHTML = page('<h2>Admin</h2><p>Network error, reload the page.</p>');
|
|
});
|
|
}
|
|
|
|
// Logout (header, account page or a 401) goes to the login view; a login
|
|
// or role change that flips admin access re-renders the page.
|
|
window.addEventListener('cs-auth-changed', function (e) {
|
|
// A logout that already moved elsewhere (the header goes home) wins.
|
|
if (!mounted.app || location.hash.split('?')[0] !== '#/admin') return;
|
|
if (!e.detail) { location.hash = '#/account/login'; return; }
|
|
if (CSAuth.isAdmin() !== mounted.admin) init(mounted.app, null);
|
|
});
|
|
|
|
registerPage('admin', { init: init, destroy: function () { renderSeq++; unmountTab(); mounted.app = null; } });
|
|
window.CSAdmin = { _test: { readTab: readTab, legacyRewrite: legacyRewrite, tabsHtml: tabsHtml } };
|
|
})();
|