login.html/login.js: the two reset views are gone along with the reset-token handling, and the sign-in form now says which password to type - "your Windows password, the same one you use to sign in to your computer" - using the .hint class the page already had, so no new CSS and no new literal. "Forgot password?" is KEPT and points at https://primecontrols.okta.com/. An earlier draft of this task deleted the link and I proposed a plain "contact IT" sentence instead; Okta is the better answer, and with no app password and no break-glass it is the only recovery path that exists. Three details that would each have broken it: - The old click handler on #forgot-link called preventDefault() to swap views. Left in place it would have silently swallowed the navigation, so the link would look right and do nothing. There is now deliberately no handler, and login.js says why so nobody adds one back. - target="_blank" without rel="noopener noreferrer" hands the opened page a window.opener handle back to the login page. - Worth recording since it was checked rather than assumed: the CSP allows this. form-action 'self' governs form submission, not link navigation, and no navigate-to directive is set - so a plain <a href> off-origin is fine and the nginx config needs no change. login.js also handles 503 distinctly now. T10.2 made that mean "the directory is unreachable or misconfigured", which is our fault - showing "invalid password" would send people hunting for a password they no longer have while a deploy is broken. Also removed, because T10.3 deleted the endpoints behind them and leaving them would have produced visible 404s rather than dead-but-harmless markup: auth-guard.js the whole change-password dialog (POST /api/auth/password) wp-sidenav.js the "Password / Change your password" menu entry that opened it users.js the per-row "Reset password" action users.js the password field in the create-account form - NewUserIn no users.html longer accepts one, so the form was posting a rejected field The self-row placeholder button pointed at a top-bar Password link that no longer exists; it is now a plain "you" marker. Verified: node --check passes on all four touched JS files; the only password references left in html/ are the sign-in form and the SMTP config in admin.js, which is unrelated and stays. Logged BL-027 rather than acted on: the Okta URL is the first sign of an Okta tenant on this estate, which means an OIDC flow is available in principle and would remove the domain-lockout hazard that forced AUTH_MAX_ATTEMPTS to 2. D13 was decided and reaffirmed and T10.1-T10.4 are built, so swapping the mechanism mid-wave is the reordering CLAUDE.md forbids. Recording is not reopening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
13 KiB
JavaScript
278 lines
13 KiB
JavaScript
/* Global app navigation drawer for the Work Package Suite.
|
|
|
|
The suite grew page by page and the only way between them was the browser's back
|
|
button or the home page. This is the one place that lists everywhere you can go —
|
|
a ☰ button in the app bar opening an off-canvas drawer.
|
|
|
|
ROLE GATING: the drawer only offers what the signed-in account can actually reach.
|
|
The Admin Console is admins-only, so it appears for admins only; the User Directory
|
|
is readable by everyone (that's the point of a directory), so it always appears.
|
|
Every destination re-checks server-side — this is navigation, not a permission.
|
|
|
|
PROJECT CONTEXT: links that open a project-scoped page carry the active ?project=
|
|
so the drawer doesn't silently drop the job you were looking at.
|
|
|
|
Add it to a page with:
|
|
<link rel="stylesheet" href="wp-sidenav.css">
|
|
<script src="wp-sidenav.js"></script>
|
|
after auth-guard.js. It mounts itself into whichever top bar the page has.
|
|
It used to skip iframes, because the embedded WP creator lived inside a page
|
|
that already had a drawer; B7/T7.1 dissolved that frame. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── the map ────────────────────────────────────────────────────────────────
|
|
// `match` is what marks a link current; `project` means "carry ?project=".
|
|
// `show` is an optional gate, evaluated once the user is known.
|
|
var LINKS = [
|
|
{ section: 'Work' },
|
|
{ href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home',
|
|
sub: 'Projects & what\'s next' },
|
|
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
|
|
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
|
|
// B7/T7.1: both of these were tabs of the suite page, opened by swapping an
|
|
// iframe, so `match` had to be null - one URL could not tell them apart. The
|
|
// creator is its own document now, so they have real addresses and the drawer
|
|
// can mark which one you are on.
|
|
{ href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤',
|
|
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
|
|
{ href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦',
|
|
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
|
|
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
|
|
sub: 'Update packages on site', project: true },
|
|
{ section: 'People' },
|
|
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
|
sub: 'Who\'s on the project' },
|
|
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console',
|
|
sub: 'Settings & diagnostics',
|
|
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
|
// Account actions, inherited from the flat user menu that used to sit in the app
|
|
// bar (T2.2). Everything else that menu offered — Admin, Users, Sign out — the
|
|
// drawer already had; these two were its only unique contents, so they moved here
|
|
// rather than being lost with it. `action` items render as buttons, not links.
|
|
{ section: 'Account' },
|
|
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
|
sub: 'Dates, numbers and time zone' },
|
|
];
|
|
|
|
function esc(v) {
|
|
return String(v == null ? '' : v)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function isDark(node) {
|
|
try {
|
|
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
if (!m) return true;
|
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
|
} catch (e) { return true; }
|
|
}
|
|
|
|
function activeProjectId() {
|
|
try {
|
|
var q = new URLSearchParams(location.search).get('project');
|
|
if (q) return q;
|
|
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
// The suite page reads ?tab= and ?project=; keeping the current project on the link
|
|
// is the difference between "open the dashboard" and "open the dashboard, then pick
|
|
// the job again".
|
|
function hrefFor(item) {
|
|
if (!item.project) return item.href;
|
|
var pid = activeProjectId();
|
|
if (!pid) return item.href;
|
|
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
|
|
return item.href + sep + 'project=' + encodeURIComponent(pid);
|
|
}
|
|
|
|
// Current-page marking. The three suite tabs share one file, so they're told apart
|
|
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
|
|
function isCurrent(item) {
|
|
var path = location.pathname;
|
|
if (item.tab) {
|
|
if (!/work-package-suite\.html$/.test(path)) return false;
|
|
var tab = '';
|
|
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
|
|
return tab === item.tab;
|
|
}
|
|
return !!(item.match && item.match.test(path));
|
|
}
|
|
|
|
// ── build ──────────────────────────────────────────────────────────────────
|
|
var drawer, scrim, btn, lastFocus = null;
|
|
|
|
function buildDrawer(user) {
|
|
scrim = document.createElement('div');
|
|
scrim.className = 'wp-navscrim';
|
|
scrim.hidden = true;
|
|
scrim.addEventListener('click', close);
|
|
|
|
drawer = document.createElement('nav');
|
|
drawer.className = 'wp-sidenav';
|
|
drawer.id = 'wp-sidenav';
|
|
drawer.setAttribute('aria-label', 'Suite navigation');
|
|
drawer.setAttribute('aria-hidden', 'true');
|
|
|
|
var rows = '';
|
|
LINKS.forEach(function (item) {
|
|
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
|
|
if (item.show && !item.show()) return;
|
|
var inner =
|
|
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
|
|
'<span class="wp-sidenav-label">' + esc(item.label) +
|
|
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span>';
|
|
// An action opens a dialog on the current page rather than going anywhere, so
|
|
// it is a button. Never a <div> with a click handler — CLAUDE.md, and the
|
|
// drawer is keyboard-navigable precisely because everything in it is focusable.
|
|
if (item.action) {
|
|
rows += '<button type="button" class="wp-sidenav-link" data-action="' +
|
|
esc(item.action) + '">' + inner + '</button>';
|
|
return;
|
|
}
|
|
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
|
|
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
|
|
inner + '</a>';
|
|
});
|
|
|
|
var who = user ? (user.full_name || user.username || '') : '';
|
|
drawer.innerHTML =
|
|
'<div class="wp-sidenav-head">' +
|
|
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
|
|
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
|
|
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
|
|
'</div>' +
|
|
// The active project in full, wrapped rather than truncated. Below 1024px the
|
|
// app bar shows the project NUMBER alone (B2), so this is where the whole name
|
|
// is always readable. It is also the only place it is guaranteed to fit.
|
|
'<div class="wp-sidenav-proj" id="wp-sidenav-proj"></div>' +
|
|
'<div class="wp-sidenav-body">' + rows + '</div>' +
|
|
'<div class="wp-sidenav-foot">' +
|
|
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
|
|
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
|
|
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
|
|
'<span class="wp-sidenav-label">Sign out</span></button>' +
|
|
'</div>';
|
|
|
|
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
|
|
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
|
|
if (typeof window.wpLogout === 'function') window.wpLogout();
|
|
});
|
|
// Close first, then act: these open a dialog, and leaving the drawer over it
|
|
// would put a scrim between the user and the thing they just asked for. The
|
|
// handler is looked up at click time because wp-format.js may still be parsing
|
|
// when the drawer is built — the flat menu had the same note.
|
|
Array.prototype.forEach.call(drawer.querySelectorAll('[data-action]'), function (el) {
|
|
el.addEventListener('click', function () {
|
|
var fn = window[el.getAttribute('data-action')];
|
|
close();
|
|
if (typeof fn === 'function') fn();
|
|
});
|
|
});
|
|
document.body.appendChild(scrim);
|
|
document.body.appendChild(drawer);
|
|
paintProject();
|
|
// Selecting a project on the launcher does not reload, so subscribe rather than
|
|
// paint once — the same single source T1.1 established.
|
|
try {
|
|
if (window.ProjectData && ProjectData.onActiveChange) {
|
|
ProjectData.onActiveChange(paintProject);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Full name, never abbreviated. Absent rather than empty when no project is active,
|
|
// so the drawer does not carry a stray blank band.
|
|
function paintProject() {
|
|
var box = document.getElementById('wp-sidenav-proj');
|
|
if (!box) return;
|
|
var p = null;
|
|
try { p = (window.ProjectData && ProjectData.getActive && ProjectData.getActive()) || null; } catch (e) {}
|
|
if (!p || !(p.name || p.number)) { box.innerHTML = ''; box.hidden = true; return; }
|
|
box.hidden = false;
|
|
box.innerHTML = '<span class="wp-sidenav-proj-k">Project</span>' +
|
|
'<strong>' + esc(p.name || '(unnamed)') + '</strong>' +
|
|
(p.number ? '<span class="wp-sidenav-proj-n">' + esc(p.number) + '</span>' : '');
|
|
}
|
|
|
|
function focusables() {
|
|
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
|
|
}
|
|
|
|
function open() {
|
|
if (!drawer) return;
|
|
lastFocus = document.activeElement;
|
|
scrim.hidden = false;
|
|
// Two frames: the element has to be laid out un-transitioned before the class
|
|
// that animates it lands, or it simply appears.
|
|
requestAnimationFrame(function () {
|
|
scrim.classList.add('is-open');
|
|
drawer.classList.add('is-open');
|
|
});
|
|
drawer.setAttribute('aria-hidden', 'false');
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
var f = focusables();
|
|
if (f.length) f[0].focus();
|
|
}
|
|
|
|
function close() {
|
|
if (!drawer) return;
|
|
drawer.classList.remove('is-open');
|
|
scrim.classList.remove('is-open');
|
|
drawer.setAttribute('aria-hidden', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
|
|
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
|
|
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
}
|
|
|
|
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
|
|
|
|
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
|
|
// off into the page behind the scrim.
|
|
document.addEventListener('keydown', function (e) {
|
|
if (!isOpen()) return;
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
|
if (e.key !== 'Tab') return;
|
|
var f = focusables();
|
|
if (!f.length) return;
|
|
var first = f[0], last = f[f.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
});
|
|
|
|
// ── mount ──────────────────────────────────────────────────────────────────
|
|
// The button goes at the START of the bar, before the brand: that is where a menu
|
|
// affordance is looked for, and it keeps clear of the project switcher and search
|
|
// that wp-chrome.js inserts into the middle of the same bar.
|
|
function mount() {
|
|
if (document.getElementById('wp-sidenav')) return;
|
|
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
|
if (!host) return;
|
|
|
|
btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'wp-navbtn';
|
|
btn.id = 'wp-navbtn';
|
|
btn.title = 'Menu';
|
|
btn.setAttribute('aria-label', 'Open navigation');
|
|
btn.setAttribute('aria-haspopup', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.setAttribute('aria-controls', 'wp-sidenav');
|
|
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
|
|
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
|
|
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
|
|
|
|
host.insertBefore(btn, host.firstChild);
|
|
buildDrawer(window.WP_USER);
|
|
}
|
|
|
|
// Wait for the auth guard: the gated links depend on the signed-in role, and an
|
|
// unauthenticated page is about to redirect anyway.
|
|
if (window.WP_USER) mount();
|
|
else document.addEventListener('wp-auth-ready', mount);
|
|
})();
|