Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag

Acts on the site comments from 8/3 plus the follow-ups. Foundation work first —
four of the comments all needed the project team to resolve to real user accounts.

Permissions vs project role (new)
- User.role is now the PERMISSIONS role: admin | project_admin | project_user.
  project_admin may delete work packages, change a SOP after it is complete, and
  delete a project; project_user may not (archiving a WP is still open to them).
  Enforced by require_project_admin() server-side; the UI only hides dead ends.
- New User.project_role holds the person's JOB FUNCTION on the project. It grants
  nothing — it feeds the SOP team pickers and notification routing.
- Admin console shows both columns and explains the difference. Migration rewrites
  the legacy role 'user' to 'project_user'.
- Deleting a project was previously open to any member and unaudited; it now needs
  project_admin and writes an audit event. ProjectData.remove no longer drops the
  project from the local cache when the server refuses.

SOP project team from user accounts
- PM/APM/CM/QM and additional team members are pickers over the project's members,
  storing the account id next to the display name. A name from an older SOP with no
  matching account is kept and flagged rather than dropped.
- The WP Creator lists the SOP team first in the Owner picker, and a new package
  defaults to whoever is creating it.

Critical constraints
- SOP constraints carry a Critical flag; buildConstraints() now copies the whole
  definition through to the package (it previously reduced them to names, losing
  description too), and critical rows are marked in the WP form. The email on
  reopen-after-release is wave 3.

Password reset by email
- login.html gains Forgot password and a set-a-new-password view, offered only when
  the server reports email is actually configured.
- Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version,
  sent immediately rather than through the notifications outbox so a reset link is
  never persisted. Identical response for unknown accounts; per-account send
  cooldown; a completed reset clears any login lockout.
- Session and reset tokens are no longer interchangeable.

BIM kill-switch
- New admin Features card with bim_enabled, OFF by default. The SOP creator hides
  the BIM section and the Creator treats every package as install-only while it is
  off; a SOP that already has BIM keeps its data untouched.

Verified with two throwaway-database test scripts: 44 checks on the permissions
matrix and token handling, 22 on the reset flow end-to-end against a local SMTP
sink (real message captured, link extracted and used). Front-end files parse-checked
in headless Chrome. Not yet exercised in a browser against a real login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:48:59 -07:00
parent 1d004cab75
commit 79b0e955b4
16 changed files with 1106 additions and 128 deletions

View File

@@ -26,7 +26,12 @@ function onSizePresetChange(){
let state = {
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
project: {name:'', number:'', client:'', division:'', site:''},
// Leadership names are kept as display strings (so existing SOPs and exports
// still read the same) alongside the user-account id each one resolves to.
// The ids are what let the Creator offer these people as a WP owner and what
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [],
@@ -202,6 +207,31 @@ const BIM_SOURCES = [
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable.
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
// BIM is off until it's ready for the field. With the flag off we hide the
// per-project toggle so no new project can be put on the BIM path — but we never
// strip a SOP that already has it on, because that would silently delete its BIM
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
// flag comes back.
function applyBimFlag(){
const wrap = document.getElementById('bim-toggle-wrap');
const note = document.getElementById('bim-disabled-note');
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
if(wrap) wrap.style.display = enabled ? '' : 'none';
if(note){
const stale = !enabled && !!state.bimEnabled;
note.style.display = enabled ? 'none' : '';
note.innerHTML = stale
? '<strong>BIM / VDC is switched off for the whole suite.</strong> This SOP already has BIM enabled, so its ' +
'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' +
'feature is off. An administrator can turn it back on under Features in the Admin Console.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> Every project is install-only (IWP) for now. ' +
'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.';
}
}
// Re-check once the flags land (they arrive asynchronously after auth).
document.addEventListener('wp-flags-ready', applyBimFlag);
function setBimEnabled(on){
state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM();
@@ -259,6 +289,8 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard');
@@ -369,6 +401,9 @@ function restoreSavedSOP(){
state = savedState;
sop = savedSop;
sopComplete = true;
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
// Re-render dynamic lists from restored state.
renderWPTypes();
@@ -390,10 +425,9 @@ function repopulateForm(){
set('proj_client', state.project.client);
set('proj_division', state.project.division);
set('proj_site', state.project.site);
set('proj_pm', state.team.pm);
set('proj_apm', state.team.apm);
set('proj_cm', state.team.cm);
set('proj_qm', state.team.qm);
// The four leadership slots are user-account pickers, not text inputs —
// renderTeamPickers() builds their options and marks the current selection.
renderTeamPickers();
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); }
if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); }
set('gov_woformat', state.governance.woformat);
@@ -562,20 +596,106 @@ function removeWPType(i){
renderWPTypes();
}
// ── PROJECT TEAM (drawn from user accounts on the project) ────────────────
// The people nameable on a SOP are the project's members (plus admins), so every
// name on the team resolves to an account the suite can assign work to and email.
// Their `project_role` (job function, set in the Admin Console) is offered as the
// default title for an additional team member.
let projectUsers = []; // [{id, full_name, username, email, project_role}]
let projectUsersLoaded = false;
function userLabel(u){
const name = u.full_name || u.username || '';
return u.project_role ? `${name}${u.project_role}` : name;
}
function userById(id){ return projectUsers.find(u => u.id === id) || null; }
async function loadProjectUsers(){
const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : '';
if(pid){
try {
const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'});
if(r.ok) projectUsers = await r.json();
} catch(e){ /* offline — fall back to whatever the SOP already stored */ }
}
projectUsersLoaded = true;
renderTeamPickers();
renderTeamMembers();
}
// One <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
function setTeamLead(key, userId){
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
const u = userById(userId);
state.teamIds[key] = u ? u.id : '';
state.team[key] = u ? (u.full_name || u.username) : '';
renderTeamPickers();
}
function renderTeamMembers(){
const container = document.getElementById('team-members-list');
if(!container) return;
const opts = (cur) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select>
<input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).join('');
}
// Picking the person fills the title from their project role but leaves it
// editable — the same person can wear a different hat on a given project.
function setExtraTeamMember(i, userId){
const m = state.teamMembers[i]; if(!m) return;
const u = userById(userId);
m.userId = u ? u.id : '';
m.name = u ? (u.full_name || u.username) : '';
if(u && !m.role) m.role = u.project_role || '';
renderTeamMembers();
}
function addTeamMember(){
state.teamMembers.push({role:'',name:''});
state.teamMembers.push({role:'',name:'',userId:''});
renderTeamMembers();
}
@@ -621,6 +741,7 @@ function renderStandardConstraints(){
_constraintsSeeded = true;
}
const active = name => state.constraints.some(c=>c.name===name);
const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); };
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
@@ -628,11 +749,33 @@ function renderStandardConstraints(){
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div>
`).join('');
renderCustomConstraints();
}
// A CRITICAL constraint is one whose reopening after release is announced by
// email (PM + CM + the package owner) rather than only dropping the package to
// Issue (Hold). Only meaningful for a constraint that's switched on.
function criticalToggle(name, enabled, isCritical){
const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_');
const tip = 'Critical: if this constraint reopens after the work package has been released, ' +
'notify the PM, CM and the package owner by email.';
return `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
function toggleCriticalConstraint(name, on){
const c = state.constraints.find(x=>x.name===name);
if(!c) return;
c.critical = !!on;
renderStandardConstraints();
track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name});
}
// Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return;
@@ -641,7 +784,10 @@ function renderCustomConstraints(){
el.innerHTML = customs.length ? customs.map(c=>`
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -653,7 +799,13 @@ function removeCustomConstraint(name){
function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1);
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
else {
// Copy the library entry — pushing the shared object would let one project's
// `critical` flag leak into every other project's default constraint set.
const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name);
if(def) state.constraints.push({...def});
}
renderStandardConstraints(); // the Critical toggle enables/disables with the row
}
function showConstraintLibrary(){
@@ -921,7 +1073,19 @@ function validateStep(n){
}
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return;
}
if(!validateStep(10)) return;
collectStepData();
@@ -937,8 +1101,15 @@ function completeSOP(){
apm: state.team.apm,
cm: state.team.cm,
qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
},
roles: state.signoffRoles.filter(r=>r.role),
governance: {
@@ -978,7 +1149,11 @@ function completeSOP(){
kind: s.kind || 'step'
})),
costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({name: c.name, description: c.description || '', bim: !!c.bim}))
constraints: state.constraints.map(c=>({
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
};
sopComplete = true;