/* Auth guard for the Work Package Suite. Included in the
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 = ''; 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(); }); })();