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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user