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>
222 lines
10 KiB
JavaScript
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, '&').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;
|
|
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);
|
|
})();
|