Site comments (8/3) - BIM card: LOD removed, IFF # added next to the coordination status, and required once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable. A LOD already stored on a package is preserved and shown as legacy, not blanked. - The blue "from SOP types" subtext under a field is now a SOP chip on the label with the detail in a tooltip. The chip stays visible rather than hover-only: field tablets have no hover, and "this came from the SOP" is the part that matters. The hint elements stay in the DOM (hidden) so the code writing to them keeps working; an observer mirrors their text into the tooltip. - Specification Section is no longer typed per package. Each WP type carries a spec section on the SOP; the field is read-only in the Creator and follows the type, with the SOP's spec folder linked underneath. This reads both spec comments as one intent — stop typing it, derive it. - Assignees and Distribution are multi-selects over the SOP project team, showing each person's job function, with the CM pre-added to Distribution (removable per package) and a free-text option for people with no account. The stored display strings are unchanged so print/export/dashboard keep working; account ids ride alongside for the notification work in wave 3. Localization + time - Per-user locale/timezone (Language & time in the user menu), an app-wide default in the admin console, then the browser. Timezones are validated against the server's zoneinfo and the picker is fed from it. Calendar dates are formatted from their parts so a due date never reads a day early in another zone. - Every displayed timestamp now goes through the shared helpers. Top-bar chrome - Project switcher beside the logo and a centered global search, injected into either generation of top bar; skipped in an iframe so the embedded Creator doesn't get a second one. Ctrl/Cmd-K focuses search. - GET /api/search covers work packages, projects and SOPs, scoped to the caller's projects, hiding archived packages, with LIKE wildcards escaped. Fixed along the way: showForm() cleared every card's inline display, which undid applyKind() — so the Package Type and BIM cards reappeared on an install-only project. Split out applyKindVisibility() and re-apply it there. Verified: 100 API checks on a fresh database (44 permissions + 22 password reset + 34 search/localization), 24 driven UI checks against the real Creator page in headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags, BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
14 KiB
JavaScript
259 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')); }
|
|
if (typeof window.wpPreferences === 'function') {
|
|
wrap.appendChild(sep());
|
|
wrap.appendChild(link('Language & time', 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();
|
|
});
|
|
})();
|