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:
@@ -112,7 +112,11 @@ function applySOP(){
|
||||
// Per-package kind. A project whose SOP has bimEnabled produces both install (IWP)
|
||||
// and BIM (EWP) packages; the kind selector tailors which fields, WP types, and
|
||||
// release gates apply. Install-only projects never show the selector.
|
||||
function bimSOP(){ return !!(SOP && SOP.bimEnabled); }
|
||||
// A project is on the BIM path only if its SOP enabled it AND an administrator
|
||||
// has the BIM/VDC tooling switched on app-wide (Admin Console → Features). With
|
||||
// the flag off, a SOP that already has BIM keeps its data but every package here
|
||||
// behaves as install-only — no IWP/EWP choice, no BIM fields or gates.
|
||||
function bimSOP(){ return !!(SOP && SOP.bimEnabled) && (typeof wpBimEnabled === 'function' ? wpBimEnabled() : false); }
|
||||
function isEwp(){ return bimSOP() && pkgKind === 'ewp'; }
|
||||
function setKind(k){
|
||||
if(pkgKind === k) return;
|
||||
@@ -550,13 +554,27 @@ function addPastedFileLinks(){
|
||||
|
||||
// ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
|
||||
function buildConstraints(){
|
||||
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n)));
|
||||
// preserve existing statuses if rebuilding
|
||||
// Carry the SOP's definition through, not just the name: `critical` decides
|
||||
// whether reopening this constraint after release is emailed, and `description`
|
||||
// is the tooltip. Statuses already set on the package are preserved.
|
||||
const defs=constraintNames().map(n=> (typeof n==='string') ? {name:n} : (n||{}));
|
||||
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
|
||||
pkgConstraints=names.map(n=> prev[n] || {name:n, status:'open', comment:''});
|
||||
pkgConstraints=defs.filter(d=>d.name).map(d=>{
|
||||
const p=prev[d.name];
|
||||
return {
|
||||
name: d.name,
|
||||
status: p ? p.status : 'open',
|
||||
comment: p ? (p.comment||'') : '',
|
||||
critical: !!d.critical,
|
||||
description: d.description || (p && p.description) || ''
|
||||
};
|
||||
});
|
||||
const tb=document.getElementById('constraint-body'); tb.innerHTML='';
|
||||
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
|
||||
tr.innerHTML=`<td class="row-label">${esc(c.name)}</td>
|
||||
const critTag = c.critical
|
||||
? ` <span class="crit-tag" title="Critical constraint — if this reopens after release the PM, CM and owner are notified by email.">⚠ critical</span>`
|
||||
: '';
|
||||
tr.innerHTML=`<td class="row-label">${esc(c.name)}${critTag}</td>
|
||||
<td><span class="cstatus">
|
||||
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
|
||||
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
|
||||
@@ -926,7 +944,7 @@ function renderSavedList(){
|
||||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> ${canDeleteWP()?`<button class="row-del" onclick="deletePackage(${i})">✕</button>`:''}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
@@ -1015,8 +1033,14 @@ async function showHistory(wpId, label){
|
||||
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
|
||||
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
|
||||
}
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||
// Deleting a work package is a Project Admin action (server: require_project_admin).
|
||||
// A project_user gets no delete button, and archiving is offered instead.
|
||||
function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
|
||||
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
|
||||
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||
function clearSaved(){ if(!savedPackages.length) return;
|
||||
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); }
|
||||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||||
function loadPackageIntoForm(p){
|
||||
@@ -1112,7 +1136,7 @@ function newPackage(){
|
||||
pkgHolds=[]; pkgOverrides={};
|
||||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
prevStatus='Draft';
|
||||
updateNumber(); updateReleaseBanner(); showForm(); renderWpNav(); track('new_package');
|
||||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package');
|
||||
}
|
||||
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
|
||||
function exportPackages(){
|
||||
@@ -1405,19 +1429,45 @@ function bootSOP(){
|
||||
}
|
||||
// Populate the Owner picker with this project's members (+ admins). The list is
|
||||
// only used to pick an assignee; the server re-validates on save.
|
||||
//
|
||||
// The SOP's project team is listed first (they're the people this project has
|
||||
// actually named), with everyone else on the project after them — so the common
|
||||
// pick is at the top without hiding anyone who could legitimately own a package.
|
||||
let projectMembers = [];
|
||||
function sopTeamIds(){
|
||||
const p = (SOP && SOP.project) || {};
|
||||
const ids = [p.pmId, p.apmId, p.cmId, p.qmId];
|
||||
(p.teamMembers || []).forEach(m => ids.push(m && m.userId));
|
||||
return ids.filter(Boolean);
|
||||
}
|
||||
async function loadMembers(){
|
||||
const sel=document.getElementById('wp_assignee');
|
||||
if(!sel || !activeProjectId) return;
|
||||
try {
|
||||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
|
||||
if(!r.ok) return;
|
||||
const list=await r.json();
|
||||
projectMembers=await r.json();
|
||||
const cur=sel.value;
|
||||
const team=sopTeamIds();
|
||||
const onTeam=projectMembers.filter(u=>team.includes(u.id));
|
||||
const others=projectMembers.filter(u=>!team.includes(u.id));
|
||||
const opt=u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}${u.project_role?' — '+esc(u.project_role):''}</option>`;
|
||||
sel.innerHTML='<option value="">— Unassigned —</option>'+
|
||||
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join('');
|
||||
(onTeam.length?`<optgroup label="Project team (from SOP)">${onTeam.map(opt).join('')}</optgroup>`:'')+
|
||||
(others.length?`<optgroup label="${onTeam.length?'Others on this project':'On this project'}">${others.map(opt).join('')}</optgroup>`:'');
|
||||
if(cur) sel.value=cur;
|
||||
else defaultOwnerToMe();
|
||||
} catch(e){}
|
||||
}
|
||||
// A new package defaults to the person creating it — they're accountable until
|
||||
// they hand it over. Only applies to an unsaved, unassigned package, and only if
|
||||
// they're actually assignable on this project.
|
||||
function defaultOwnerToMe(){
|
||||
const sel=document.getElementById('wp_assignee');
|
||||
if(!sel || sel.value || editingId) return;
|
||||
const me=(window.WP_USER||{}).id;
|
||||
if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me;
|
||||
}
|
||||
|
||||
function bootData(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
|
||||
Reference in New Issue
Block a user