A 55-agent audit of the last few commits confirmed 32 findings. The high and medium ones are fixed here; the ranked leftovers are listed at the end. Boot-order crash (my regression, wave 2) - wp-format.js loaded AFTER wp-creation-app.js on every page, but the creator boots synchronously at parse time and its comment renderer calls wpFormatDateTime(). With any review comment present that threw a ReferenceError and aborted the rest of boot. The formatter now parses before the app scripts on all five pages. Verified with a comment seeded: the date renders and boot completes. The network-first fix didn't actually work - `fetch(req)` inherits the request's default cache mode, so it consults the browser HTTP cache — the previous commit's "network-first" still allowed a page to run against a stale sibling. Code is now fetched with cache:'no-cache' and precached with cache:'reload'. - Nothing pinned freshness on the wire either: no Cache-Control anywhere, so browsers applied heuristic caching (~10% of a file's age) and each file expired at a different moment. NGINX and the dev server now send no-cache for html/css/js/ webmanifest; images stay cacheable. Verified on the wire. - Non-ok responses were returned verbatim, so a 502 broke pages the cache could have served; they now fall back to the cache. Cache keys drop the query string, which fixes both the offline miss on every in-app link (?project=…&tab=…) and unbounded cache growth. respondWith can no longer resolve to undefined. Cache bumped to v5. Embedded creator - Dropped the &t=Date.now() cache-buster and made the frame's identity the PROJECT. The view and which package to open are now applied by calling into the loaded document, so switching tabs no longer reloads it — that reload discarded unsaved form edits, made the creator unreachable offline, and stored a fresh copy per click. - ?view=dashboard was re-read on every tab switch, so after one deep link the "Work Package Creation" tab kept opening the Dashboard for the rest of the session. Deep-link params are consumed once now. - ?wp=<id> — which the global search has been emitting since wave 2 — was read by nothing, so picking a work package in search opened a blank one. The creator now exposes openWpById() and the shell applies it after a new 'wp-creator-ready' event, because the frame's load fires before pullProject() resolves. - Math.max(320,…) could make the frame taller than the space available while page scrolling was disabled, pushing content off a window that couldn't scroll. Full-bleed is now only used when at least 460px remains, and the SOP-incomplete gate never runs inside it. A ResizeObserver re-measures when wp-chrome.js grows the app bar. Contract drift - .field-hint and .user-pick are used on the SOP suite page but their only rules lived in wp-creation-styles.css, which that page doesn't link — the CM hint and the sign-off pickers had no styling at all. Rules added to the suite's stylesheet. - The creator's critical floor now also hides modal overlays (a stale stylesheet rendered their contents inline in the form) and gives the jump bar a sane sticky top. - login.js dereferenced ids unguarded where the old version guarded, so a cached older login.html would break sign-in itself. Guarded. - The "Language & time" menu item was added only if wp-format.js had already parsed; the check now happens at click time. Verified: 157 API checks across five suites on a clean database, plus 22 driven UI checks — boot-with-comment, tab switching with a no-reload probe, short-viewport fallback, and the search deep link landing on the right package. Not done, ranked: ~50 dead CSS rules across three stylesheets; dead .team-pick and .constraint-option contracts; wp-chrome.js's documented '.header' mount branch is unreachable because the creator loads neither wp-chrome.js nor its CSS; the squeeze half of the embed layout (.content-area.embed-full) is still CSS-only, which degrades to the old narrow column rather than breaking; fingerprinted asset URLs would make a mismatched pair unrepresentable rather than merely unlikely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
14 KiB
JavaScript
261 lines
14 KiB
JavaScript
/* Auth guard for the Work Package Suite.
|
|
Included in the <head> of every protected page (before other scripts). It
|
|
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
|
the user to the login page. The real protection is server-side (the API
|
|
refuses data requests without a session) — this guard is for UX so people
|
|
land on the login screen instead of an empty app.
|
|
|
|
It also exposes:
|
|
window.WP_USER the logged-in user object (set once verified)
|
|
window.wpLogout() clears the session and returns to the login page
|
|
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
|
|
|
// Register the PWA service worker (caches the app shell for offline use). Only
|
|
// from the top window; the API and writes are never cached (see sw.js).
|
|
if (!inIframe && 'serviceWorker' in navigator) {
|
|
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
|
}
|
|
|
|
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
|
// before a redirect. A safety timer reveals it even if the check hangs.
|
|
var root = document.documentElement;
|
|
var style = document.createElement('style');
|
|
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
|
(document.head || root).appendChild(style);
|
|
root.className += ' wp-auth-pending';
|
|
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
|
var safety = setTimeout(reveal, 4000);
|
|
|
|
function goToLogin() {
|
|
clearTimeout(safety);
|
|
var next = encodeURIComponent(location.pathname + location.search);
|
|
var url = 'login.html?next=' + next;
|
|
// If we're inside the WP-creator iframe, redirect the whole window.
|
|
var w = inIframe ? window.top : window;
|
|
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
|
}
|
|
|
|
window.wpLogout = function () {
|
|
try {
|
|
// Clear the auth cache AND all cached project data (customer IP) from this
|
|
// device on sign-out — important on shared/field tablets. The outbox
|
|
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
|
// (localStorage is not a security boundary; field devices still need
|
|
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
|
localStorage.removeItem('wp_auth_cache');
|
|
Object.keys(localStorage).forEach(function (k) {
|
|
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
|
localStorage.removeItem(k);
|
|
}
|
|
});
|
|
} catch (e) {}
|
|
fetch('/api/auth/logout', { method: 'POST' })
|
|
.catch(function () {})
|
|
.then(function () { window.location.replace('login.html'); });
|
|
};
|
|
|
|
// Change-password dialog (uses POST /api/auth/password, which requires the
|
|
// current password). Available from the top-right pill on any page.
|
|
window.wpChangePassword = function () {
|
|
if (document.getElementById('wp-pw-modal')) return;
|
|
var ov = document.createElement('div');
|
|
ov.id = 'wp-pw-modal';
|
|
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
|
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
|
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
|
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
|
ov.innerHTML =
|
|
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
|
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
|
'<div style="padding:16px 18px;">' +
|
|
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
|
'<label style="' + lbl + '">Current password</label>' +
|
|
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
|
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
|
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
|
'<label style="' + lbl + '">Confirm new password</label>' +
|
|
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
|
'</div>' +
|
|
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
|
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
|
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
|
function msg(text, ok) {
|
|
var el = document.getElementById('wp-pw-msg');
|
|
el.style.display = 'block'; el.textContent = text;
|
|
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
|
}
|
|
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
|
document.body.appendChild(ov);
|
|
document.getElementById('wp-pw-cancel').onclick = close;
|
|
document.getElementById('wp-pw-cur').focus();
|
|
document.getElementById('wp-pw-save').onclick = function () {
|
|
var cur = document.getElementById('wp-pw-cur').value;
|
|
var n1 = document.getElementById('wp-pw-new').value;
|
|
var n2 = document.getElementById('wp-pw-new2').value;
|
|
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
|
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
|
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
|
fetch('/api/auth/password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
|
})
|
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
|
.then(function (res) {
|
|
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
|
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
|
})
|
|
.catch(function () { msg('Could not reach the server.', false); });
|
|
};
|
|
};
|
|
|
|
// ── permissions helpers ────────────────────────────────────────────────────
|
|
// The server enforces all of this; these are for hiding controls the signed-in
|
|
// user can't use, so nobody clicks a button just to get a 403.
|
|
// 'user' is the legacy value for what is now 'project_user'.
|
|
window.wpRole = function () {
|
|
var r = (window.WP_USER && window.WP_USER.role) || '';
|
|
return r === 'user' ? 'project_user' : r;
|
|
};
|
|
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
|
window.wpIsProjectAdmin = function () {
|
|
var r = window.wpRole();
|
|
return r === 'admin' || r === 'project_admin';
|
|
};
|
|
// Deleting a work package, deleting a project, and editing a completed SOP are
|
|
// all Project Admin actions (see server require_project_admin).
|
|
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
|
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
|
|
|
|
// ── app feature flags ──────────────────────────────────────────────────────
|
|
// Cached per page load. Pages that must know before rendering should await
|
|
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
|
|
window.WP_FLAGS = null;
|
|
var _flagsPromise = null;
|
|
window.wpFlags = function () {
|
|
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
|
|
if (_flagsPromise) return _flagsPromise;
|
|
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
|
.catch(function () { return {}; }) // offline: fall through to defaults
|
|
.then(function (f) {
|
|
window.WP_FLAGS = f || {};
|
|
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
|
|
return window.WP_FLAGS;
|
|
});
|
|
return _flagsPromise;
|
|
};
|
|
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
|
|
// stale cache errs toward hiding the unfinished tooling rather than showing it.
|
|
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
|
|
|
|
function isDarkBg(el) {
|
|
try {
|
|
var m = (getComputedStyle(el).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; }
|
|
}
|
|
|
|
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
|
|
// bar it sits in (light links on a dark bar, blue links on a light bar).
|
|
function buildUserMenu(user, dark) {
|
|
var wrap = document.createElement('div');
|
|
wrap.id = 'wp-usermenu';
|
|
var linkColor = dark ? '#ffffff' : '#0f62fe';
|
|
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
|
|
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
|
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
|
|
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
|
|
function link(text, onClick, href) {
|
|
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
|
|
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
|
|
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
|
|
return a;
|
|
}
|
|
var who = document.createElement('span');
|
|
who.textContent = user.full_name || user.username;
|
|
who.style.color = dark ? '#ffffff' : '#161616';
|
|
wrap.appendChild(who);
|
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
|
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
|
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
|
// the check happens at click time rather than once, up front.
|
|
wrap.appendChild(sep());
|
|
wrap.appendChild(link('Language & time', function () {
|
|
if (typeof window.wpPreferences === 'function') window.wpPreferences();
|
|
}));
|
|
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
|
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
|
return wrap;
|
|
}
|
|
|
|
function addLogoutPill(user) {
|
|
if (inIframe) return; // the parent page already shows it
|
|
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
|
|
|
// Preferred: drop the menu INTO the top bar so it never floats over the
|
|
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
|
// the older .header bars alike.
|
|
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
|
if (host) {
|
|
var menu = buildUserMenu(user, isDarkBg(host));
|
|
// The older .header bars already right-align their own toolbar (via flex:1
|
|
// or a button's margin-left:auto). A second auto-margin would split the free
|
|
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
|
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
|
host.appendChild(menu);
|
|
return;
|
|
}
|
|
|
|
// Fallback for any page with no header bar: a floating pill (as before).
|
|
var pill = document.createElement('div');
|
|
pill.id = 'wp-logout-pill';
|
|
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
|
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
|
|
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
|
pill.appendChild(buildUserMenu(user, false));
|
|
document.body.appendChild(pill);
|
|
}
|
|
|
|
function proceed(user) {
|
|
clearTimeout(safety);
|
|
window.WP_USER = user;
|
|
reveal();
|
|
if (window.WP_USER) {
|
|
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
|
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
|
if (document.body) addLogoutPill(window.WP_USER);
|
|
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
|
}
|
|
}
|
|
|
|
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) {
|
|
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
|
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
|
return r.json().then(function (data) {
|
|
var user = data && data.user;
|
|
// Remember the last good auth so the PWA can open offline. The server is
|
|
// still the real gate; offline writes queue in the outbox until reconnect.
|
|
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
|
proceed(user);
|
|
});
|
|
})
|
|
.catch(function () {
|
|
// Offline / API unreachable: fall back to a recent cached auth if present,
|
|
// so the app (and the field view) still open without a network.
|
|
try {
|
|
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
|
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
|
} catch (e) {}
|
|
goToLogin();
|
|
});
|
|
})();
|