Files
Project-SDE-WP-Suite/html/wp-sidenav.js
n.siegfried 4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00

222 lines
10 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, and
skips iframes (the embedded WP creator lives inside a page that already has one). */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
// ── 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' },
{ href: 'work-package-suite.html?tab=wp', match: null, icon: '▤',
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
{ href: 'work-package-suite.html?tab=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(); } },
];
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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;
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
'<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></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>' +
'<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();
});
document.body.appendChild(scrim);
document.body.appendChild(drawer);
}
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);
})();