POST /api/usage/ping writes one page_open UsageEvent per authenticated page load, identity from the session (get_current_user), never from the client. Wired from exactly one place - auth-guard.js's proceed(), after wp-auth-ready - so this can't drift into six separate per-page copies. okta_callback now also writes one login event per sign-in. Verified locally: unauthenticated ping -> 401; a real fake-Okta sign-in writes exactly one login row and one page_open row, no duplicates.
188 lines
9.3 KiB
JavaScript
188 lines
9.3 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';
|
|
|
|
// Register the PWA service worker (caches the app shell for offline use). The
|
|
// API and writes are never cached (see sw.js). This used to be skipped inside
|
|
// an iframe so the embedded creator did not register a second time; B7/T7.1
|
|
// dissolved that frame and there is no longer a document in the app that is
|
|
// not the top one.
|
|
if ('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;
|
|
// Was `inIframe ? window.top : window`, so an expired session inside the
|
|
// embedded creator replaced the whole window rather than painting a login
|
|
// page into a frame. No frame, no branch (B7/T7.1).
|
|
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'); });
|
|
};
|
|
|
|
// window.wpChangePassword used to open a change-password dialog here. Removed in
|
|
// T10.4 (D15/D16): there is no local password to change anymore — identity is
|
|
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
|
|
// too.
|
|
|
|
// ── 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'; };
|
|
// A Project Super User is a Project Admin with user administration on top, so it
|
|
// counts here too (server: auth.is_project_admin).
|
|
window.wpIsProjectAdmin = function () {
|
|
var r = window.wpRole();
|
|
return r === 'admin' || r === 'project_super_user' || 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;
|
|
// Whether this account can administer USER accounts. The account role is only half
|
|
// the answer — the role can also be held on a single project — so anything that
|
|
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
|
|
// cheap hint used to decide whether to bother offering a control.
|
|
window.wpMayManageUsers = function () {
|
|
var r = window.wpRole();
|
|
return r === 'admin' || r === 'project_super_user';
|
|
};
|
|
|
|
// ── 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); };
|
|
|
|
// The flat user menu that used to sit in this bar is gone (T2.2). It duplicated
|
|
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
|
|
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
|
|
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
|
|
// the item that was only here: Language & time. (Password was the other one; T10.4
|
|
// removed it along with the rest of local auth — D15/D16.)
|
|
//
|
|
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
|
// left that would need a floating fallback pill.
|
|
|
|
// ── CR-019: usage ping ───────────────────────────────────────────────────
|
|
// One page_open event per authenticated load, sent from exactly ONE place
|
|
// (here) rather than from each page's own script - the shared-chrome lesson
|
|
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
|
|
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
|
|
// error to the person using the app - a missed usage ping is not something
|
|
// anyone here should notice happening.
|
|
var TOOL_BY_PAGE = {
|
|
'index.html': 'launcher',
|
|
'work-package-suite.html': 'wizard',
|
|
'wp-creation-index.html': 'creator',
|
|
'field.html': 'field_view',
|
|
'admin.html': 'admin',
|
|
'users.html': 'directory'
|
|
};
|
|
function pingUsage() {
|
|
var page = (location.pathname.split('/').pop() || 'index.html');
|
|
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
|
|
try {
|
|
fetch('/api/usage/ping', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tool: tool })
|
|
}).catch(function () {});
|
|
} catch (e) {}
|
|
}
|
|
|
|
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
|
|
pingUsage();
|
|
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
})();
|