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

@@ -62,11 +62,17 @@ AUTH_SECRET_KEY=<strong-random-secret>
# unrecoverable: openssl rand -base64 32 # unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase> BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email notifications. Email is OFF # OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# by default and enabled from the Admin console; the host/port/from-address are # is OFF by default and enabled from the Admin console; the host/port/from-address
# configured there, but the password is only ever read from this variable (never # are configured there, but the password is only ever read from this variable
# stored in the DB or shown in the UI). Leave unset until you have SMTP details. # (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
``` ```
The API builds its own DB connection string from the `POSTGRES_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -295,6 +301,51 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set in the Admin console's user table.
| Role | May do |
|---|---|
| `admin` | User administration, app settings, and every project |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before this change
carried the role `user`, which the migration rewrites to `project_user`.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Schema migrations (Alembic) ## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs Schema is managed by **Alembic** (`server/alembic/`). The API container runs

View File

@@ -110,17 +110,29 @@
<input id="nu-username" placeholder="Username *" autocomplete="off"> <input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off"> <input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off"> <input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select> <select id="nu-role" title="Permissions — what this account may do">
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password"> <option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
<button class="primary" onclick="createUser()">Create user</button> <button class="primary" onclick="createUser()">Create user</button>
</div> </div>
<div id="users-create-msg" class="note"></div> <div id="users-create-msg" class="note"></div>
</div> </div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL --> <!-- NOTIFICATIONS / EMAIL -->
<div class="card"> <div class="card">
<h2>Notifications &amp; email</h2> <h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div> <div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div> <div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div> <div id="notif-box" class="note" style="margin-top:14px"></div>
</div> </div>

View File

@@ -9,6 +9,7 @@
function reveal(){ function reveal(){
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
fillProjectRoleOptions();
checkHealth(); checkHealth();
loadUsers(); loadUsers();
loadSettings(); loadSettings();
@@ -179,6 +180,23 @@ async function loadUsers(){
renderUsers(json, meId); renderUsers(json, meId);
} }
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
const PERM_ROLES = ['admin','project_admin','project_user'];
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function fillProjectRoleOptions(){
const sel=document.getElementById('nu-project-role'); if(!sel) return;
sel.innerHTML='<option value="">Project role…</option>'+
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
}
function renderUsers(list, meId){ function renderUsers(list, meId){
const wrap=document.getElementById('users-table'); const wrap=document.getElementById('users-table');
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; } if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
@@ -195,17 +213,33 @@ function renderUsers(list, meId){
// Role can be changed at any time via an inline dropdown. Your own row is // Role can be changed at any time via an inline dropdown. Your own row is
// locked (a shown-as-tag) so an admin can't accidentally demote themselves. // locked (a shown-as-tag) so an admin can't accidentally demote themselves.
const escUname = uesc(u.username).replace(/'/g,"\\'"); const escUname = uesc(u.username).replace(/'/g,"\\'");
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me const roleCell = me
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>' ? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this users role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+ : '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+ PERM_ROLES.map(function(r){
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</option>'+ return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
'</select>'; '</select>';
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
}).join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
return '<tr>'+ return '<tr>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+ '<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+ '<td>'+uesc(u.full_name||'')+'</td>'+
'<td>'+uesc(u.email||'')+'</td>'+ '<td>'+uesc(u.email||'')+'</td>'+
'<td>'+roleCell+'</td>'+ '<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+ '<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+ '<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+ '<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
@@ -216,8 +250,19 @@ function renderUsers(list, meId){
'</tr>'; '</tr>';
}).join(''); }).join('');
wrap.innerHTML='<table class="users"><thead><tr>'+ wrap.innerHTML='<table class="users"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+ '<th>Username</th><th>Name</th><th>Email</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'; '<th title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
'change a completed SOP, and delete the project. '+
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
'but cannot delete WPs or change the SOP once it\'s complete. '+
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
'team pickers and notification routing, and grants nothing on its own.</div>';
} }
async function createUser(){ async function createUser(){
@@ -226,11 +271,12 @@ async function createUser(){
const full_name=document.getElementById('nu-fullname').value.trim(); const full_name=document.getElementById('nu-fullname').value.trim();
const email=document.getElementById('nu-email').value.trim(); const email=document.getElementById('nu-email').value.trim();
const role=document.getElementById('nu-role').value; const role=document.getElementById('nu-role').value;
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
const password=document.getElementById('nu-password').value; const password=document.getElementById('nu-password').value;
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; } if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; } if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
msg.style.color='var(--muted)'; msg.textContent='Creating…'; msg.style.color='var(--muted)'; msg.textContent='Creating…';
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password}); const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
if(status===200){ if(status===200){
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.'; msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value=''); ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
@@ -263,7 +309,15 @@ async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role}); const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); } if(status===200){ loadUsers(); }
else { else {
alert('Could not change role for '+username+': '+((json && json.detail)||('HTTP '+status))); alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status===200){ loadUsers(); }
else {
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers(); loadUsers();
} }
} }
@@ -403,7 +457,39 @@ async function loadSettings(){
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; } if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings(); _settings = json; renderSettings();
} }
// Feature flags live in the same settings record but get their own card — they're
// not email, and they change what every project sees.
function renderFeatures(){
const s = _settings, box = document.getElementById('features-box');
if(!box) return;
const bim = !!s.bim_enabled;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>';
}
async function saveFeatures(){
const el = document.getElementById('set-bim');
const msg = document.getElementById('features-msg');
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
if(status===200){
_settings = json; renderFeatures();
const m = document.getElementById('features-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else if(msg){
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
}
}
function renderSettings(){ function renderSettings(){
renderFeatures();
const s = _settings, box = document.getElementById('settings-box'); const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled; const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set; const pwOk = !!s.smtp_password_set;

View File

@@ -75,7 +75,7 @@
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' + '<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' + '<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' + '<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 8 characters)</label>' + '<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' + '<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' + '<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' + '<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
@@ -100,7 +100,7 @@
var n1 = document.getElementById('wp-pw-new').value; var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value; var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; } if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; } if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; } if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', { fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -115,6 +115,46 @@
}; };
}; };
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.
// 'user' is the legacy value for what is now 'project_user'.
window.wpRole = function () {
var r = (window.WP_USER && window.WP_USER.role) || '';
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
window.WP_FLAGS = null;
var _flagsPromise = null;
window.wpFlags = function () {
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
if (_flagsPromise) return _flagsPromise;
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : {}; })
.catch(function () { return {}; }) // offline: fall through to defaults
.then(function (f) {
window.WP_FLAGS = f || {};
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
return window.WP_FLAGS;
});
return _flagsPromise;
};
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
// stale cache errs toward hiding the unfinished tooling rather than showing it.
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
function isDarkBg(el) { function isDarkBg(el) {
try { try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/); var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
@@ -144,7 +184,7 @@
who.style.color = dark ? '#ffffff' : '#161616'; who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who); wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname); var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); } if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); })); wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); })); wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap; return wrap;
@@ -183,6 +223,7 @@
window.WP_USER = user; window.WP_USER = user;
reveal(); reveal();
if (window.WP_USER) { if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER); if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); }); else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });

View File

@@ -70,6 +70,25 @@
} }
.error.show { display: block; } .error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; } .foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok {
display: none;
background: #defbe6;
border-left: 3px solid var(--cds-support-success);
color: #0e6027;
padding: 0.75rem;
font-size: 0.8125rem;
margin-bottom: 1.25rem;
}
.ok.show { display: block; }
.note {
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style> </style>
</head> </head>
<body> <body>
@@ -77,29 +96,64 @@
<div class="brand"> <div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'"> <img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
</div> </div>
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<div id="error" class="error" role="alert"></div> <div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div>
<form id="login-form" autocomplete="on"> <!-- SIGN IN -->
<div class="field"> <section id="view-login">
<label for="username">Username</label> <h1>Sign in</h1>
<input id="username" name="username" type="text" autocomplete="username" autofocus required> <p class="sub">Work Package Suite</p>
</div> <form id="login-form" autocomplete="on">
<div class="field"> <div class="field">
<label for="password">Password</label> <label for="username">Username</label>
<input id="password" name="password" type="password" autocomplete="current-password" required> <input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div> </div>
<button id="submit" type="submit">Sign in</button> <div class="field">
</form> <label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;"> <!-- FORGOT PASSWORD (email reset) -->
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a> <section id="view-forgot" style="display:none">
</p> <h1>Reset password</h1>
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem;"> <p class="sub">We'll email you a link to set a new one.</p>
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner. <div id="forgot-unavailable" class="note" style="display:none">
</div> Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p> <p class="foot">Authorized use only · BTG / Pilot</p>
</main> </main>

View File

@@ -1,13 +1,42 @@
/* Login page logic for the Work Package Suite. /* Login page logic for the Work Package Suite.
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
session cookie (not readable here — that's the point) and we redirect to the Three views on one page:
page the user was trying to reach, or the home page. */ • sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
(function () { (function () {
'use strict'; 'use strict';
var form = document.getElementById('login-form');
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
var submitBtn = document.getElementById('submit'); var okBox = document.getElementById('ok');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
okBox.classList.remove('show');
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Where to go after signing in: the ?next= param if it's a safe same-site // Where to go after signing in: the ?next= param if it's a safe same-site
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an // path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
@@ -20,44 +49,55 @@
return 'index.html'; return 'index.html';
} }
function showError(msg) { function resetToken() {
errorBox.textContent = msg; try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
errorBox.classList.add('show');
} }
var forgot = document.getElementById('forgot-link'); function postJson(url, payload) {
if (forgot) { return fetch(url, {
forgot.addEventListener('click', function (e) { method: 'POST',
e.preventDefault(); headers: { 'Content-Type': 'application/json' },
var m = document.getElementById('forgot-msg'); body: JSON.stringify(payload)
if (m) m.style.display = 'block'; }).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
}); });
} }
function detail(res, fallback) {
var d = res && res.json && res.json.detail;
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
errorBox.classList.remove('show'); clearBanners();
var username = document.getElementById('username').value.trim(); var username = byId('username').value.trim();
var password = document.getElementById('password').value; var password = byId('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; } if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…'; submitBtn.textContent = 'Signing in…';
postJson('/api/auth/login', { username: username, password: password })
fetch('/api/auth/login', { .then(function (res) {
method: 'POST', if (res.ok) { location.replace(nextTarget()); return; }
headers: { 'Content-Type': 'application/json' }, if (res.status === 401) showError('Invalid username or password.');
body: JSON.stringify({ username: username, password: password }) else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
}) else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
.then(function (r) { else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
if (r.ok) { location.replace(nextTarget()); return null; } submitBtn.disabled = false;
return r.json().catch(function () { return null; }).then(function (j) { submitBtn.textContent = 'Sign in';
if (r.status === 401) showError('Invalid username or password.');
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
}) })
.catch(function () { .catch(function () {
showError('Could not reach the server. Check your connection and try again.'); showError('Could not reach the server. Check your connection and try again.');
@@ -65,4 +105,108 @@
submitBtn.textContent = 'Sign in'; submitBtn.textContent = 'Sign in';
}); });
}); });
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
byId('forgot-link').addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
byId('back-to-login').addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form');
var forgotBtn = byId('forgot-submit');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
byId('reset-to-login').addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form');
var resetBtn = byId('reset-submit');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})(); })();

View File

@@ -59,10 +59,18 @@
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only .catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
}, },
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) { remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' }) return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () { cacheRemove(id); }) .then(function (r) {
.catch(function () { cacheRemove(id); }); if (r.ok || r.status === 404) { cacheRemove(id); return true; }
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
}, },
// ── active project context ──────────────────────────────────────────────── // ── active project context ────────────────────────────────────────────────

View File

@@ -26,7 +26,12 @@ function onSizePresetChange(){
let state = { 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. 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:''}, 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:''}, team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [], teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [], wpTypes: [],
@@ -202,6 +207,31 @@ const BIM_SOURCES = [
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the // 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 // project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable. // 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){ function setBimEnabled(on){
state.bimEnabled = !!on; state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM(); if(on) enableBIM(); else disableBIM();
@@ -259,6 +289,8 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP(); restoreSavedSOP();
updateStepUI(); updateStepUI();
updateProjectDisplay(); 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. // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab'); const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard'); if(params.get('view') === 'dashboard') switchTool('dashboard');
@@ -369,6 +401,9 @@ function restoreSavedSOP(){
state = savedState; state = savedState;
sop = savedSop; sop = savedSop;
sopComplete = true; 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. // Re-render dynamic lists from restored state.
renderWPTypes(); renderWPTypes();
@@ -390,10 +425,9 @@ function repopulateForm(){
set('proj_client', state.project.client); set('proj_client', state.project.client);
set('proj_division', state.project.division); set('proj_division', state.project.division);
set('proj_site', state.project.site); set('proj_site', state.project.site);
set('proj_pm', state.team.pm); // The four leadership slots are user-account pickers, not text inputs —
set('proj_apm', state.team.apm); // renderTeamPickers() builds their options and marks the current selection.
set('proj_cm', state.team.cm); renderTeamPickers();
set('proj_qm', state.team.qm);
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); } 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); } 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); set('gov_woformat', state.governance.woformat);
@@ -562,20 +596,106 @@ function removeWPType(i){
renderWPTypes(); 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(){ function renderTeamMembers(){
const container = document.getElementById('team-members-list'); const container = document.getElementById('team-members-list');
if(!container) return; 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)=>` 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);"> <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;"> <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="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;"> <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> <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> </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(''); `).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(){ function addTeamMember(){
state.teamMembers.push({role:'',name:''}); state.teamMembers.push({role:'',name:'',userId:''});
renderTeamMembers(); renderTeamMembers();
} }
@@ -621,6 +741,7 @@ function renderStandardConstraints(){
_constraintsSeeded = true; _constraintsSeeded = true;
} }
const active = name => state.constraints.some(c=>c.name===name); 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=>` 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;"> <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;"> <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> <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 style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div> </div>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div> </div>
`).join(''); `).join('');
renderCustomConstraints(); 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. // Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){ function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return; const el = document.getElementById('custom-constraints-list'); if(!el) return;
@@ -641,7 +784,10 @@ function renderCustomConstraints(){
el.innerHTML = customs.length ? customs.map(c=>` 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;"> <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> <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> <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>`; </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){ function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name); const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1); 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(){ function showConstraintLibrary(){
@@ -921,7 +1073,19 @@ function validateStep(n){
} }
// ── SOP COMPLETION ──────────────────────────────────────────────────────────── // ── 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(){ 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; if(!validateStep(10)) return;
collectStepData(); collectStepData();
@@ -937,8 +1101,15 @@ function completeSOP(){
apm: state.team.apm, apm: state.team.apm,
cm: state.team.cm, cm: state.team.cm,
qm: state.team.qm, 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, 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), roles: state.signoffRoles.filter(r=>r.role),
governance: { governance: {
@@ -978,7 +1149,11 @@ function completeSOP(){
kind: s.kind || 'step' kind: s.kind || 'step'
})), })),
costCodes: LABOR_COST_CODES, 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; sopComplete = true;

View File

@@ -103,23 +103,28 @@
<!-- STEP 2: PROJECT TEAM --> <!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;"> <div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2> <h2>2. Project Team Leadership</h2>
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div> <div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
as an owner and lets the suite email them — so add anyone missing to the project first, in the
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="field-grid"> <div class="field-grid">
<div class="field"> <div class="field">
<label>Project Manager (PM)</label> <label>Project Manager (PM)</label>
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez"> <select id="proj_pm" class="team-pick" data-team="pm"></select>
</div> </div>
<div class="field"> <div class="field">
<label>Assistant Project Manager (APM)</label> <label>Assistant Project Manager (APM)</label>
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name"> <select id="proj_apm" class="team-pick" data-team="apm"></select>
</div> </div>
<div class="field"> <div class="field">
<label>Construction Manager (CM)</label> <label>Construction Manager (CM)</label>
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd"> <select id="proj_cm" class="team-pick" data-team="cm"></select>
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Quality Manager (QM)</label> <label>Quality Manager (QM)</label>
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen"> <select id="proj_qm" class="team-pick" data-team="qm"></select>
</div> </div>
</div> </div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;"> <div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -160,11 +165,13 @@
<div class="step" id="sop-step-4" style="display: none;"> <div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2> <h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div> <div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<label style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;"> <label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;"> <input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br> <span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span> <span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label> </label>
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div> <div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div> </div>

View File

@@ -112,7 +112,11 @@ function applySOP(){
// Per-package kind. A project whose SOP has bimEnabled produces both install (IWP) // 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 // and BIM (EWP) packages; the kind selector tailors which fields, WP types, and
// release gates apply. Install-only projects never show the selector. // 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 isEwp(){ return bimSOP() && pkgKind === 'ewp'; }
function setKind(k){ function setKind(k){
if(pkgKind === k) return; if(pkgKind === k) return;
@@ -550,13 +554,27 @@ function addPastedFileLinks(){
// ── CONSTRAINTS + RELEASE GATE ─────────────────────────────────────────────── // ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
function buildConstraints(){ function buildConstraints(){
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n))); // Carry the SOP's definition through, not just the name: `critical` decides
// preserve existing statuses if rebuilding // 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); 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=''; const tb=document.getElementById('constraint-body'); tb.innerHTML='';
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr'); 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"> <td><span class="cstatus">
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button> <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> <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>`:''; 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> 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>${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(''); }).join('');
} }
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } 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>'+ '<span class="hist-detail">'+det(e.detail)+'</span></div>'+
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</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); } // Deleting a work package is a Project Admin action (server: require_project_admin).
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)); } // 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 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 loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){ function loadPackageIntoForm(p){
@@ -1112,7 +1136,7 @@ function newPackage(){
pkgHolds=[]; pkgOverrides={}; pkgHolds=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft'; 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 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(){ function exportPackages(){
@@ -1405,19 +1429,45 @@ function bootSOP(){
} }
// Populate the Owner picker with this project's members (+ admins). The list is // 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. // 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(){ async function loadMembers(){
const sel=document.getElementById('wp_assignee'); const sel=document.getElementById('wp_assignee');
if(!sel || !activeProjectId) return; if(!sel || !activeProjectId) return;
try { try {
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'}); const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
if(!r.ok) return; if(!r.ok) return;
const list=await r.json(); projectMembers=await r.json();
const cur=sel.value; 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>'+ 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; if(cur) sel.value=cur;
else defaultOwnerToMe();
} catch(e){} } 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(){ function bootData(){
loadStore(); // reads the localStorage cache (hydrated from the server below) loadStore(); // reads the localStorage cache (hydrated from the server below)

View File

@@ -631,6 +631,11 @@
.wp-nav-reopen { display:none !important; } .wp-nav-reopen { display:none !important; }
} }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* Sticky save bar */ /* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center; .sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff; justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;

View File

@@ -0,0 +1,35 @@
"""permissions roles + project (job function) role
Adds `users.project_role` (job function on the project — carries no permissions)
and migrates the permissions vocabulary: the legacy role 'user' becomes
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
granted explicitly from the admin console.
Revision ID: b41c7ae90d52
Revises: 57dec34f11cb
Create Date: 2026-08-03 15:12:04.118322
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b41c7ae90d52'
down_revision = '57dec34f11cb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# server_default backfills existing rows (the column is NOT NULL).
op.add_column('users', sa.Column('project_role', sa.String(length=120),
nullable=False, server_default=''))
# Legacy 'user' means exactly what 'project_user' means now.
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
def downgrade() -> None:
# Fold the new role back onto the legacy value so an older build still reads
# the table. A project_admin loses its elevated rights on downgrade.
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
op.drop_column('users', 'project_role')

View File

@@ -12,6 +12,7 @@ import os
import re import re
import uuid import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from time import monotonic
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -111,7 +112,7 @@ def check_id(v: Optional[str]) -> None:
# mutating endpoints raise 403 on no access. # mutating endpoints raise 403 on no access.
def accessible_project_ids(db: Session, user: "models.User"): def accessible_project_ids(db: Session, user: "models.User"):
"""Return the set of project ids the user may access, or None for 'all' (admin).""" """Return the set of project ids the user may access, or None for 'all' (admin)."""
if user.role == "admin": if auth.is_admin(user):
return None return None
rows = db.scalars( rows = db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id) select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
@@ -120,7 +121,7 @@ def accessible_project_ids(db: Session, user: "models.User"):
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None: def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin": if auth.is_admin(user):
return return
if not project_id: if not project_id:
# Non-admins may not read/mutate resources with no project assignment # Non-admins may not read/mutate resources with no project assignment
@@ -136,6 +137,19 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
raise HTTPException(status_code=403, detail="You don't have access to this project") raise HTTPException(status_code=403, detail="You don't have access to this project")
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
what: str = "this action") -> None:
"""Destructive / baseline-changing operations: deleting a work package or a
project, and editing a SOP that has already been completed. Requires project
access AND the project_admin (or admin) permissions role."""
require_project_access(db, user, project_id)
if not auth.is_project_admin(user):
raise HTTPException(
status_code=403,
detail=f"{what} requires the Project Admin permissions role",
)
def scope_to_access(stmt, column, db: Session, user: "models.User"): def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins).""" """Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user) ids = accessible_project_ids(db, user)
@@ -174,7 +188,7 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) ->
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
if not u or not u.is_active: if not u or not u.is_active:
raise HTTPException(status_code=400, detail="Assignee is not a valid user") raise HTTPException(status_code=400, detail="Assignee is not a valid user")
if u.role == "admin": if auth.is_admin(u):
return return
ok = db.scalar( ok = db.scalar(
select(models.ProjectMember.id).where( select(models.ProjectMember.id).where(
@@ -251,6 +265,7 @@ class SettingsIn(BaseModel):
from_addr: Optional[str] = None from_addr: Optional[str] = None
from_name: Optional[str] = None from_name: Optional[str] = None
app_base_url: Optional[str] = None app_base_url: Optional[str] = None
bim_enabled: Optional[bool] = None
class TestEmailIn(BaseModel): class TestEmailIn(BaseModel):
@@ -296,7 +311,21 @@ class NewUserIn(BaseModel):
password: str password: str
full_name: str = "" full_name: str = ""
email: str = "" email: str = ""
role: str = "user" # 'admin' | 'user' role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
project_role: str = "" # job function on the project (no permissions)
class ProjectRoleIn(BaseModel):
project_role: str = ""
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel): class PasswordChangeIn(BaseModel):
@@ -313,7 +342,7 @@ class ActiveIn(BaseModel):
class RoleIn(BaseModel): class RoleIn(BaseModel):
role: str # 'admin' | 'user' role: str # permissions role — see auth.ROLES
class ProjectAssignIn(BaseModel): class ProjectAssignIn(BaseModel):
@@ -367,6 +396,115 @@ def logout(response: Response):
return {"ok": True} return {"ok": True}
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
)
if _reset_throttled(request, body.username):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0
user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
db.commit()
return {"ok": True}
@app.get("/api/auth/me") @app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)): def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load.""" """Who is logged in. The frontend guard calls this on every page load."""
@@ -401,8 +539,8 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
problem = auth.password_problem(body.password, body.username, body.email) problem = auth.password_problem(body.password, body.username, body.email)
if problem: if problem:
raise HTTPException(status_code=400, detail=problem) raise HTTPException(status_code=400, detail=problem)
if body.role not in ("admin", "user"): if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
if auth.find_user(db, body.username): if auth.find_user(db, body.username):
raise HTTPException(status_code=409, detail="A user with that username already exists") raise HTTPException(status_code=409, detail="A user with that username already exists")
u = models.User( u = models.User(
@@ -412,9 +550,11 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
full_name=body.full_name.strip(), full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password), password_hash=auth.hash_password(body.password),
role=body.role, role=body.role,
project_role=body.project_role.strip()[:120],
) )
db.add(u) db.add(u)
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role}) log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role})
db.commit() db.commit()
db.refresh(u) db.refresh(u)
return u.to_dict() return u.to_dict()
@@ -450,20 +590,22 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
@app.post("/api/auth/users/{user_id}/role") @app.post("/api/auth/users/{user_id}/role")
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Change a user's role (admin ↔ user). Admins can do this at any time. """Change a user's PERMISSIONS role (admin / project_admin / project_user).
Their job function on the project is separate — see set_user_project_role.
Guards: you can't change your own role (avoids self-lockout), and the last Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable).""" remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in ("admin", "user"): if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
if not u: if not u:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id: if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot change your own role") raise HTTPException(status_code=400, detail="You cannot change your own role")
if u.role == "admin" and body.role != "admin": if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
other_admins = db.scalars( other_admins = db.scalars(
select(models.User.id).where( select(models.User.id).where(
(models.User.role == "admin") (models.User.role == auth.ROLE_ADMIN)
& (models.User.id != u.id) & (models.User.id != u.id)
& (models.User.is_active.is_(True)) & (models.User.is_active.is_(True))
) )
@@ -479,6 +621,23 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
return u.to_dict() return u.to_dict()
@app.post("/api/auth/users/{user_id}/project-role")
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Set a user's job function on the project (Project Manager, Superintendent,
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
and notification routing read, so it's worth keeping accurate."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
old = u.project_role or ""
u.project_role = (body.project_role or "").strip()[:120]
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
detail={"from": old, "to": u.project_role})
db.commit()
db.refresh(u)
return u.to_dict()
@app.delete("/api/auth/users/{user_id}") @app.delete("/api/auth/users/{user_id}")
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
@@ -545,7 +704,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
project_id=proj.id, summary=(proj.name or proj.number or proj.id)) project_id=proj.id, summary=(proj.name or proj.number or proj.id))
db.commit() db.commit()
# A project created by a non-admin auto-grants its creator access. # A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin": if is_new and not auth.is_admin(user):
grant_project_access(db, user.id, proj.id) grant_project_access(db, user.id, proj.id)
db.commit() db.commit()
db.refresh(proj) db.refresh(proj)
@@ -573,7 +732,10 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id) # Cascades to every SOP and work package on the project — Project Admin only.
require_project_admin(db, user, proj.id, "Deleting a project")
log_event(db, user, "deleted", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
db.delete(proj) db.delete(proj)
db.commit() db.commit()
return {"deleted": project_id} return {"deleted": project_id}
@@ -587,6 +749,11 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
sop = db.get(models.Sop, body.id) if body.id else None sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None: if sop is not None:
require_project_access(db, user, sop.project_id) require_project_access(db, user, sop.project_id)
# The SOP is the project's baseline: once it's been completed, changing it
# is a Project Admin action. Authoring and revising a draft is open to any
# project member, including marking it complete the first time.
if sop.complete:
require_project_admin(db, user, sop.project_id, "Changing a completed SOP")
is_new = sop is None is_new = sop is None
if sop is None: if sop is None:
sop = models.Sop(id=body.id or gen_id("sop")) sop = models.Sop(id=body.id or gen_id("sop"))
@@ -645,7 +812,7 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id) require_project_admin(db, user, sop.project_id, "Deleting a SOP")
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id, log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
summary=(sop.name or sop.number or sop.id)) summary=(sop.name or sop.number or sop.id))
db.delete(sop) db.delete(sop)
@@ -816,7 +983,9 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id) # Deleting a work package is irreversible — Project Admin only. A project_user
# who wants one out of the way can archive it instead (reversible).
require_project_admin(db, user, wp.project_id, "Deleting a work package")
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id, log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id)) summary=(wp.number or wp.subject or wp.id))
db.delete(wp) db.delete(wp)
@@ -924,6 +1093,13 @@ def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Sess
return notify.public_settings(db) return notify.public_settings(db)
@app.get("/api/app-flags")
def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Feature flags every signed-in page reads (e.g. whether the BIM/VDC tooling
is switched on). No secrets — safe for any authenticated user."""
return notify.app_flags(db)
@app.put("/api/settings") @app.put("/api/settings")
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
patch = {k: v for k, v in body.model_dump().items() if v is not None} patch = {k: v for k, v in body.model_dump().items() if v is not None}
@@ -955,7 +1131,7 @@ def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500), def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Notification) stmt = select(models.Notification)
if not (all and user.role == "admin"): if not (all and auth.is_admin(user)):
stmt = stmt.where(models.Notification.user_id == user.id) stmt = stmt.where(models.Notification.user_id == user.id)
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all() rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
return [n.to_dict() for n in rows] return [n.to_dict() for n in rows]
@@ -967,13 +1143,15 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
require_project_access(db, user, project_id) require_project_access(db, user, project_id)
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all()) member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else [] members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all() admins = db.scalars(select(models.User).where(models.User.role == auth.ROLE_ADMIN)).all()
out, seen = [], set() out, seen = [], set()
for u in list(members) + list(admins): for u in list(members) + list(admins):
if u.id in seen or not u.is_active: if u.id in seen or not u.is_active:
continue continue
seen.add(u.id) seen.add(u.id)
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email}) out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
"email": u.email, "project_role": u.project_role or "",
"role": auth.normalize_role(u.role)})
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower()) out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
return out return out

View File

@@ -16,7 +16,19 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works. warning and invalidates every session on restart) so dev still works.
Roles: 'admin' (may manage users) and 'user'. Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
""" """
import os import os
import secrets import secrets
@@ -39,6 +51,48 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again. # How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI). # Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12")) MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
@@ -134,11 +188,43 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None.""" """Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
try: try:
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ──────────────────────────────────────────────────────────── # ── cookie helpers ────────────────────────────────────────────────────────────
@@ -206,7 +292,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User": def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if user.role != "admin": if not is_admin(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user return user

View File

@@ -121,8 +121,15 @@ class WorkPackage(Base):
class User(Base): class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt """A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with; `role` is hash (see server/auth.py). `username` is what people sign in with.
either 'admin' (can manage users) or 'user'."""
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String(40), primary_key=True) id: Mapped[str] = mapped_column(String(40), primary_key=True)
@@ -130,7 +137,9 @@ class User(Base):
email: Mapped[str] = mapped_column(String(200), default="") email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="") full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="") password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user' role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -146,7 +155,8 @@ class User(Base):
"""Public view of a user — NEVER includes the password hash.""" """Public view of a user — NEVER includes the password hash."""
return { return {
"id": self.id, "username": self.username, "email": self.email, "id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role, "is_active": self.is_active, "full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at), "created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
} }

View File

@@ -33,9 +33,19 @@ DEFAULTS = {
"from_addr": "", "from_addr": "",
"from_name": "Work Package Suite", "from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links "app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
# with it off, the SOP creator hides the BIM section entirely and every SOP is
# install-only, so no project can be put on the BIM path by accident.
"bim_enabled": False,
} }
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + whether
# self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled",)
def get_settings(db: Session) -> dict: def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY) row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS) s = dict(DEFAULTS)
@@ -65,6 +75,16 @@ def public_settings(db: Session) -> dict:
return s return s
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool: def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr")) return bool(s.get("smtp_host") and s.get("from_addr"))
@@ -91,6 +111,22 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
srv.send_message(msg) srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str, def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification": link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready + """Record a notification. Marked 'pending' only if email is enabled + SMTP ready +