Completes T2.2. The flat strip was #wp-usermenu, built in auth-guard.js and appended to every bar: "Root . Admin . Users . Language & time . Password . Sign out". It duplicated Admin, Users and Sign out from the drawer, and being one unbreakable 412px run it was also the thing that clipped the bar at 390px. Deleting it is what resolves F2 for real, exactly as the wave predicted. Its two unique items moved into the drawer rather than being lost with it: Language & time and Password now sit under an Account section. They open dialogs rather than navigating, so they render as <button data-action>, not links - which also removes three fake `<a href="#">` entries whose only job was to be clicked. The creator's jump-link count goes 3 -> 0 for that reason. The drawer closes before the dialog opens, so its scrim is not left over the thing you just asked for. wpPreferences and wpChangePassword are untouched, so A7's localization feature is intact - it moved, it did not go. buildUserMenu, addLogoutPill, isDarkBg and the floating-pill fallback are all gone; grep finds no reference to any of them. The fallback is not needed because every signed-in page now mounts the drawer. The T1.2 interim went with them rather than being layered on - the element it wrapped no longer exists. Also removed the Field View's "Home" link, which the drawer's first entry and the brand beside it both already offered, and replaced the creator's mono wordmark with the shared logo chip. The creator was the only page in the suite spelling "Prime Controls" out in monospace instead of showing the mark; its .header-logo rule and the 480px override for it are deleted. Deliberately kept: the launcher's Overview / Feedback / Help links. Those are in-page anchors and a help dialog belonging to that page, not global navigation, so they are not the strip and the drawer does not duplicate them. Verified on all 6 pages at 390px and 1440px: no #wp-usermenu anywhere, no bar control crossing the viewport edge at either width, the logo chip present on every page including the creator, and the same 10-entry drawer everywhere. The bar is back to 48px at 1440px on five of six pages. F2's probe reports the bar at 2 rows instead of 4. Both migrated actions verified to open their real modals (wp-prefs-modal, wp-pw-modal) with the drawer closed behind them. browser_check 71/71. f_items: F1-F5 FIXED, F6 untouched. T2.3 (B2, switcher breakpoints) is still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
12 KiB
JavaScript
209 lines
12 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'; };
|
|
// 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 two items that were only here: Language & time, and Password.
|
|
//
|
|
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
|
// left that would need a floating fallback 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) {}
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
})();
|