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

164
html/field.js Normal file
View File

@@ -0,0 +1,164 @@
/* Field view — a touch-optimized screen for updating a Work Package's status,
constraints, and a photo/note log from the work face. Reads the same shared
data as the desktop creator (via project-data.js) and saves through the sync
outbox, so it works offline and syncs when the network returns. */
'use strict';
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function nsKey(id) { return 'wp_iwp_v1__' + id; }
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
function fmtTs(s) { try { return new Date(s).toLocaleString(); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
// ── boot / data ──────────────────────────────────────────────────────────────
function boot() {
var params = new URLSearchParams(location.search);
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
if (!PID) { showNoProject(); return; }
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
loadWPs();
}
function renderCtx() {
var el = document.getElementById('fld-ctx'); if (!el) return;
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
else el.textContent = 'Project: ' + PID;
}
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
function loadWPs() {
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
renderList();
if (ProjectData.pullProject) {
ProjectData.pullProject(PID).then(function () {
WPS = activePkgs(readCache());
if (!curId) renderList(); else renderDetail();
}).catch(function () {});
}
}
function showNoProject() {
var s = document.getElementById('screen-list');
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
}
// ── list ───────────────────────────────────────────────────────────────────
function renderList() {
var box = document.getElementById('wp-list'); if (!box) return;
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
box.innerHTML = rows.map(function (p) {
var open = openCount(p);
var cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>' : (open ? '<span class="pill warn">' + open + ' open</span>' : '<span class="pill ok">Ready</span>');
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
'<div class="subj">' + esc(p.subject || '') + '</div>' +
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
}).join('');
}
// ── detail ─────────────────────────────────────────────────────────────────
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
function backToList() {
curId = null; pendingPhoto = ''; draftNote = '';
document.getElementById('screen-detail').style.display = 'none';
document.getElementById('screen-list').style.display = '';
renderList();
}
function renderDetail() {
var p = curWP(); if (!p) { backToList(); return; }
document.getElementById('screen-list').style.display = 'none';
var d = document.getElementById('screen-detail'); d.style.display = '';
var stBtns = STATUSES.map(function (s) {
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
}).join('');
var cx = (p.constraints) || [];
var cxRows = cx.length ? cx.map(function (c, i) {
var st = c.status || 'open';
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
d.innerHTML =
'<button class="fld-back" onclick="backToList()"> All packages</button>' +
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
}
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
function saveWP(p) {
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) WPS[ix] = p;
writeCache();
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
}
function setStatus(s) {
var p = curWP(); if (!p) return;
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
if (p.status === s) return;
p.status = s;
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
}
function cycleConstraint(i) {
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
var order = ['open', 'cleared', 'na'];
var cur = p.constraints[i].status || 'open';
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
saveWP(p); renderDetail();
}
function onPhoto(ev) {
var f = ev.target.files && ev.target.files[0]; if (!f) return;
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
var url = URL.createObjectURL(f);
var img = new Image();
img.onload = function () {
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
var cv = document.createElement('canvas');
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
URL.revokeObjectURL(url);
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
};
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
img.src = url;
}
function addUpdate() {
var p = curWP(); if (!p) return;
var note = (draftNote || '').trim();
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
if (!p.fieldLog) p.fieldLog = [];
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
pendingPhoto = ''; draftNote = '';
saveWP(p); renderDetail(); toast('Update added to log');
}
boot();