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

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -107,6 +107,7 @@
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(),
data: p
};
@@ -120,6 +121,8 @@
if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p;
}
@@ -152,37 +155,200 @@
return Promise.all(jobs).then(function () {});
};
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
// deterministic id per project so re-completing updates the same row.
// ── Durable write-through outbox ───────────────────────────────────────────
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null);
var body = {
id: 'sop__' + projectId,
project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true,
created_by: currentUser(),
data: { sop: sop, state: state }
};
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({
kind: 'sop', key: 'sop__' + projectId,
body: {
id: 'sop__' + projectId, project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
}
});
return Promise.resolve(true);
};
// Upsert a single Work Package to the API (fire-and-forget from the caller's
// perspective; the local cache is the source of truth for immediate rendering).
// Upsert a single Work Package. The local cache stays the source of truth for
// immediate rendering; the outbox guarantees the write reaches the server.
ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null);
return fetch(API + '/wps', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
return Promise.resolve(true);
};
ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve();
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () {}).catch(function () {});
enqueue({ kind: 'wp-del', key: id });
return Promise.resolve(true);
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale