Files
n30nex fb101e46e0 fix(nav): fit primary links around late account controls (#2286)
Red commit: `e5589b4c` ([failing
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40191348162)).

Fixes #2216. Navigation measures the available flex strip instead of
guessing component widths. Optional statistics and spacing yield when
primary/active links would overlap More. Account insertion triggers a
layout event immediately, including while authentication is pending or
fails; normal user-change events retain their semantics.

- Regression CI reproduced link overlap across narrow widths and late
account controls. Review added a second RED test for the actual delayed
authentication bootstrap before the three-line correction.
- Validation on `f0ccbf4c`: full frontend suite; 89 user-management
checks; 56 navigation checks; existing priority and clipping suites.
Parent independently reran auth and browser checks.
- Browser verified: http://127.0.0.1:18854; real bootstrap overlap of
14.66px becomes 21.38px clearance. Resizing, account changes, keyboard
More and touch targets remain covered.
- E2E assertion added: `tests/e2e/test-nav-fluid-1055-e2e.js:89`.
- Work is bounded by the fixed navigation link set; events share one
animation frame. No polling, observers, new requests, dependencies or
settings.

Privacy, whitespace, CSS-variable, XSS and ESLint checks passed.
External home-directory preflight bundle unavailable; applicable checks
were performed locally. Independent review verified the bootstrap
correction with no outstanding findings. [Final
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40192737545)
passed on `f0ccbf4c`, including all three browser shards and both
architecture builds.
2026-10-26 15:43:43 +01:00

203 lines
7.9 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();
// Header layout changes even if the session request stalls or fails.
window.dispatchEvent(new CustomEvent('cs-auth-control-added'));
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 }
};
})();