Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only credential this app accepts anywhere. Backend: - server/models.py: drop User.password_hash. - server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column, same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds it with server_default=''). - server/auth.py: remove hash_password/verify_password/password_problem/ MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/ RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user untouched. - server/app.py: remove login(), the whole self-service reset-password block (forgot-password/reset-available/reset-password), and change_password() (POST /api/auth/password). Rework create_user() to drop the password field (with a docstring note: the username must exactly match the eventual Okta identity claim, or a later sign-in provisions a second account instead of matching this one). Remove admin_reset_password() outright - nothing left to reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s JIT provisioning (T10.3) was still setting password_hash="", which would have raised TypeError the moment the column was actually dropped. Admin bootstrap (D16): server/manage_users.py moves from creating accounts (create/create-admin/reset-password, all password-based) to a single 'promote <username> --role <role>' command that changes the role on a row Okta's JIT provisioning already created - the documented path for naming the first admin. list/disable/enable unchanged. Frontend: html/users.js drops the password field and validation from createUser(), removes resetPw() and its button (nothing left to reset). html/users.html drops the #nu-password input, adds a tooltip on username explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item that opened it. Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a password to seed fixture rows (and the --keep-server hint now prints a ready-to-use cookie-setting snippet instead of a dead username/password). tests/pipeline_check.py and tests/token_check.py drop an unused PW import. tests/console_dialogs_check.py: the admin password-reset dialog it drove no longer exists, so that scenario is removed - the prompt-with-validate() UI pattern it exercised is still covered via creator_dialogs_check.py's wp-creation-app.js call sites, noted in this file's docstring so the coverage move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in via login" scenario is explicitly marked SKIPPED (not deleted, not faked) - that promise is specific to the login FORM this task removed and can't be honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token cookie now stands in as setup only, so scenarios 3-6 in that file still get a signed-in page to run against. server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login to minting a session the same way okta_callback() does (auth.create_token(), seeded into the cookie jar) rather than waiting on T10.7. This is a real operational change, documented in both files' own AUTHENTICATION sections: they now need to run where AUTH_SECRET_KEY and the database match the target server's (inside the api container, or local dev) - they can no longer sign in to an arbitrary remote URL from an unrelated workstation, because Okta requires a real browser and these are stdlib scripts. The account must already exist; neither script creates or promotes one. server/requirements.txt: bcrypt dropped, nothing imports it anymore. Verified: full Alembic chain (baseline through this migration) upgrades and downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT provisioning re-tested against the post-migration schema (would have thrown before the password_hash="" fix above). create_user() verified via a live HTTP call with no password field. manage_users.py promote verified end to end (seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py and seed_demo.py both run to completion against a live uvicorn instance using the new minted-session path - 25/25 checks, including logout actually invalidating the session (proving the cookie-jar seeding didn't just fake the sign-in, it preserved the real expiry mechanics). wave-10.md T10.4 / D15 / D16
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);
|
|
})();
|