Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email

Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:51:15 -07:00
parent dd37f1f551
commit 39b48055ff
48 changed files with 2867 additions and 507 deletions

View File

@@ -14,6 +14,12 @@
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;
@@ -34,6 +40,19 @@
}
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'); });
@@ -96,58 +115,99 @@
};
};
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 (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
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-logout-pill')) return;
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;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
pill.appendChild(who);
// Admins get a link to the Admin Console (hidden when already on it).
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) {
var adm = document.createElement('a');
adm.href = 'admin.html'; adm.textContent = 'Admin';
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pill.appendChild(sep()); pill.appendChild(adm);
}
var pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
var out = document.createElement('a');
out.href = '#'; out.textContent = 'Sign out';
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
pill.appendChild(sep()); pill.appendChild(out);
'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) {
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) { goToLogin(); return; }
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) {
clearTimeout(safety);
window.WP_USER = data && data.user;
reveal();
if (window.WP_USER) {
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); });
}
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 () { goToLogin(); }); // API unreachable → send to login
.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();
});
})();