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:
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user