From 79b0e955b40fa92ea00597fdb5371c1bde13c34c Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 3 Aug 2026 14:48:59 -0700 Subject: [PATCH] Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DEPLOYMENT.md | 59 ++++- html/admin.html | 18 +- html/admin.js | 104 ++++++++- html/auth-guard.js | 47 +++- html/login.html | 94 ++++++-- html/login.js | 208 ++++++++++++++--- html/project-data.js | 12 +- html/work-package-suite-app.js | 195 +++++++++++++++- html/work-package-suite.html | 19 +- html/wp-creation-app.js | 72 +++++- html/wp-creation-styles.css | 5 + ...0d52_permissions_roles_and_project_role.py | 35 +++ server/app.py | 218 ++++++++++++++++-- server/auth.py | 94 +++++++- server/models.py | 18 +- server/notify.py | 36 +++ 16 files changed, 1106 insertions(+), 128 deletions(-) create mode 100644 server/alembic/versions/b41c7ae90d52_permissions_roles_and_project_role.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fe7f079..2fc137d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -62,11 +62,17 @@ AUTH_SECRET_KEY= # unrecoverable: openssl rand -base64 32 BACKUP_ENC_PASSPHRASE= -# OPTIONAL — SMTP password for WP-assignment email notifications. Email is OFF -# by default and enabled from the Admin console; the host/port/from-address are -# configured there, but the password is only ever read from this variable (never -# stored in the DB or shown in the UI). Leave unset until you have SMTP details. +# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email +# is OFF by default and enabled from the Admin console; the host/port/from-address +# are configured there, but the password is only ever read from this variable +# (never stored in the DB or shown in the UI). Leave unset until you have SMTP +# details. # SMTP_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_*` @@ -295,6 +301,51 @@ TLS / From address and flips the master toggle. package contents — so customer IP stays behind the login. - 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 is managed by **Alembic** (`server/alembic/`). The API container runs diff --git a/html/admin.html b/html/admin.html index fdb71e9..db918e7 100644 --- a/html/admin.html +++ b/html/admin.html @@ -110,17 +110,29 @@ - - + + +
+ +
+

Features

+
Switches that change what the suite offers on every project.
+
Loading…
+
+

Notifications & email

-
Email notifications for work-package assignments. Off by default — turn this on only once SMTP is configured. The SMTP password is read from the SMTP_PASSWORD environment variable and is never stored here.
+
Email notifications for work-package assignments, and self-service password resets. Off by default — turn this on only once SMTP is configured. The SMTP password is read from the SMTP_PASSWORD environment variable and is never stored here.
Loading…
diff --git a/html/admin.js b/html/admin.js index 4388e69..e558bf4 100644 --- a/html/admin.js +++ b/html/admin.js @@ -9,6 +9,7 @@ function reveal(){ document.getElementById('admin-main').style.display=''; + fillProjectRoleOptions(); checkHealth(); loadUsers(); loadSettings(); @@ -179,6 +180,23 @@ async function loadUsers(){ 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=''+ + PROJECT_ROLES.map(r=>'').join(''); +} + function renderUsers(list, meId){ const wrap=document.getElementById('users-table'); if(!list.length){ wrap.innerHTML='
No users yet.
'; return; } @@ -195,17 +213,33 @@ function renderUsers(list, meId){ // 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. 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 - ? ''+uesc(u.role)+'locked' - : ''+ + PERM_ROLES.map(function(r){ + return ''; + }).join('')+ ''; + // PROJECT role — the person's job function. Descriptive only; grants nothing. + const pr = u.project_role || ''; + const projRoleCell = + ''; return ''+ ''+uesc(u.username)+''+(me?'you':'')+''+ ''+uesc(u.full_name||'')+''+ ''+uesc(u.email||'')+''+ ''+roleCell+''+ + ''+projRoleCell+''+ ''+(active?'active':'disabled')+''+ ''+fmt(u.last_login_at)+''+ '
'+ @@ -216,8 +250,19 @@ function renderUsers(list, meId){ ''; }).join(''); wrap.innerHTML=''+ - ''+ - ''+rows+'
UsernameNameEmailRoleStatusLast loginActions
'; + 'UsernameNameEmail'+ + 'Permissions'+ + 'Project role'+ + 'StatusLast loginActions'+ + ''+rows+''+ + '
Permissions — '+ + 'Administrator: manages users, settings and every project. '+ + 'Project Admin: on their assigned projects, may delete work packages, '+ + 'change a completed SOP, and delete the project. '+ + 'Project User: creates and edits work packages and authors the SOP, '+ + 'but cannot delete WPs or change the SOP once it\'s complete. '+ + 'Project role is the person\'s job function — it feeds the SOP '+ + 'team pickers and notification routing, and grants nothing on its own.
'; } async function createUser(){ @@ -226,11 +271,12 @@ async function createUser(){ const full_name=document.getElementById('nu-fullname').value.trim(); const email=document.getElementById('nu-email').value.trim(); const role=document.getElementById('nu-role').value; + const project_role=(document.getElementById('nu-project-role')||{}).value||''; const password=document.getElementById('nu-password').value; 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…'; - 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){ msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.'; ['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}); if(status===200){ loadUsers(); } 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(); } } @@ -403,7 +457,39 @@ async function loadSettings(){ if(status!==200 || !json){ box.innerHTML = ''; return; } _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 = + ''+ + '
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.
'+ + '
'; +} + +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(){ + renderFeatures(); const s = _settings, box = document.getElementById('settings-box'); const on = !!s.email_enabled; const pwOk = !!s.smtp_password_set; diff --git a/html/auth-guard.js b/html/auth-guard.js index 6f336b7..e9107fe 100644 --- a/html/auth-guard.js +++ b/html/auth-guard.js @@ -75,7 +75,7 @@ '' + '' + '' + - '' + + '' + '' + '' + '' + @@ -100,7 +100,7 @@ var n1 = document.getElementById('wp-pw-new').value; var n2 = document.getElementById('wp-pw-new2').value; 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; } fetch('/api/auth/password', { 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) { try { var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/); @@ -144,7 +184,7 @@ who.style.color = dark ? '#ffffff' : '#161616'; wrap.appendChild(who); 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('Sign out', function () { window.wpLogout(); })); return wrap; @@ -183,6 +223,7 @@ window.WP_USER = user; reveal(); 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) {} if (document.body) addLogoutPill(window.WP_USER); else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); }); diff --git a/html/login.html b/html/login.html index 8568ea9..526d5a0 100644 --- a/html/login.html +++ b/html/login.html @@ -70,6 +70,25 @@ } .error.show { display: block; } .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; } @@ -77,29 +96,64 @@
Prime Controls
-

Sign in

-

Work Package Suite

- +
-
-
- - -
-
- - -
- -
+ +
+

Sign in

+

Work Package Suite

+
+
+ + +
+
+ + +
+ +
+

Forgot password?

+
-

- Forgot password? -

- + + + + +

Authorized use only · BTG / Pilot

diff --git a/html/login.js b/html/login.js index 7255c28..db808e2 100644 --- a/html/login.js +++ b/html/login.js @@ -1,13 +1,42 @@ /* 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 - page the user was trying to reach, or the home page. */ + + Three views on one 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= + 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 () { 'use strict'; - var form = document.getElementById('login-form'); 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 // path, otherwise the home page. (Reject absolute/scheme URLs to avoid an @@ -20,44 +49,55 @@ return 'index.html'; } - function showError(msg) { - errorBox.textContent = msg; - errorBox.classList.add('show'); + function resetToken() { + try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; } } - var forgot = document.getElementById('forgot-link'); - if (forgot) { - forgot.addEventListener('click', function (e) { - e.preventDefault(); - var m = document.getElementById('forgot-msg'); - if (m) m.style.display = 'block'; + function postJson(url, payload) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).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) { e.preventDefault(); - errorBox.classList.remove('show'); - var username = document.getElementById('username').value.trim(); - var password = document.getElementById('password').value; + clearBanners(); + var username = byId('username').value.trim(); + var password = byId('password').value; if (!username || !password) { showError('Enter your username and password.'); return; } submitBtn.disabled = true; submitBtn.textContent = 'Signing in…'; - - fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: username, password: password }) - }) - .then(function (r) { - if (r.ok) { location.replace(nextTarget()); return null; } - return r.json().catch(function () { return null; }).then(function (j) { - 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'; - }); + postJson('/api/auth/login', { username: username, password: password }) + .then(function (res) { + if (res.ok) { location.replace(nextTarget()); return; } + if (res.status === 401) showError('Invalid username or 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.')); + else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').')); + submitBtn.disabled = false; + submitBtn.textContent = 'Sign in'; }) .catch(function () { showError('Could not reach the server. Check your connection and try again.'); @@ -65,4 +105,108 @@ 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'); })(); diff --git a/html/project-data.js b/html/project-data.js index 202709b..0e08ae8 100644 --- a/html/project-data.js +++ b/html/project-data.js @@ -59,10 +59,18 @@ .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) { return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' }) - .then(function () { cacheRemove(id); }) - .catch(function () { cacheRemove(id); }); + .then(function (r) { + 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 ──────────────────────────────────────────────── diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index e31a0a3..89227a0 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -26,7 +26,12 @@ function onSizePresetChange(){ let state = { bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only. project: {name:'', number:'', client:'', division:'', site:''}, + // Leadership names are kept as display strings (so existing SOPs and exports + // still read the same) alongside the user-account id each one resolves to. + // The ids are what let the Creator offer these people as a WP owner and what + // notification routing uses — a typed name can't be emailed. team: {pm:'', apm:'', cm:'', qm:''}, + teamIds: {pm:'', apm:'', cm:'', qm:''}, teamMembers: [], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], wpTypes: [], @@ -202,6 +207,31 @@ const BIM_SOURCES = [ // types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the // project produces both install (IWP) and BIM (EWP) packages. OFF strips the // bim-flagged items. Everything stays editable. +// ── BIM/VDC app switch (admin console → Features) ───────────────────────────── +// BIM is off until it's ready for the field. With the flag off we hide the +// per-project toggle so no new project can be put on the BIM path — but we never +// strip a SOP that already has it on, because that would silently delete its BIM +// types, gates and sequence steps. Such a SOP just stops offering BIM until the +// flag comes back. +function applyBimFlag(){ + const wrap = document.getElementById('bim-toggle-wrap'); + const note = document.getElementById('bim-disabled-note'); + const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false; + if(wrap) wrap.style.display = enabled ? '' : 'none'; + if(note){ + const stale = !enabled && !!state.bimEnabled; + note.style.display = enabled ? 'none' : ''; + note.innerHTML = stale + ? 'BIM / VDC is switched off for the whole suite. 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.' + : 'BIM / VDC packages aren\'t available yet. Every project is install-only (IWP) for now. ' + + 'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.'; + } +} +// Re-check once the flags land (they arrive asynchronously after auth). +document.addEventListener('wp-flags-ready', applyBimFlag); + function setBimEnabled(on){ state.bimEnabled = !!on; if(on) enableBIM(); else disableBIM(); @@ -259,6 +289,8 @@ window.addEventListener('DOMContentLoaded',()=>{ restoreSavedSOP(); updateStepUI(); updateProjectDisplay(); + loadProjectUsers(); // team pickers: who's on this project + applyBimFlag(); // hide the BIM section unless an admin enabled it // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. const tab = params.get('tab'); if(params.get('view') === 'dashboard') switchTool('dashboard'); @@ -369,6 +401,9 @@ function restoreSavedSOP(){ state = savedState; sop = savedSop; sopComplete = true; + // A SOP saved before the team was account-backed has no teamIds; default them + // so the pickers render (the stored names show as "(no account)" until linked). + if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''}; // Re-render dynamic lists from restored state. renderWPTypes(); @@ -390,10 +425,9 @@ function repopulateForm(){ set('proj_client', state.project.client); set('proj_division', state.project.division); set('proj_site', state.project.site); - set('proj_pm', state.team.pm); - set('proj_apm', state.team.apm); - set('proj_cm', state.team.cm); - set('proj_qm', state.team.qm); + // The four leadership slots are user-account pickers, not text inputs — + // renderTeamPickers() builds their options and marks the current selection. + renderTeamPickers(); if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); } if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); } set('gov_woformat', state.governance.woformat); @@ -562,20 +596,106 @@ function removeWPType(i){ renderWPTypes(); } +// ── PROJECT TEAM (drawn from user accounts on the project) ──────────────── +// The people nameable on a SOP are the project's members (plus admins), so every +// name on the team resolves to an account the suite can assign work to and email. +// Their `project_role` (job function, set in the Admin Console) is offered as the +// default title for an additional team member. +let projectUsers = []; // [{id, full_name, username, email, project_role}] +let projectUsersLoaded = false; + +function userLabel(u){ + const name = u.full_name || u.username || ''; + return u.project_role ? `${name} — ${u.project_role}` : name; +} +function userById(id){ return projectUsers.find(u => u.id === id) || null; } + +async function loadProjectUsers(){ + const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : ''; + if(pid){ + try { + const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'}); + if(r.ok) projectUsers = await r.json(); + } catch(e){ /* offline — fall back to whatever the SOP already stored */ } + } + projectUsersLoaded = true; + renderTeamPickers(); + renderTeamMembers(); +} + +// One - + +
+ ${(m.name && !m.userId) ? `
“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.
` : ''} `).join(''); } +// Picking the person fills the title from their project role but leaves it +// editable — the same person can wear a different hat on a given project. +function setExtraTeamMember(i, userId){ + const m = state.teamMembers[i]; if(!m) return; + const u = userById(userId); + m.userId = u ? u.id : ''; + m.name = u ? (u.full_name || u.username) : ''; + if(u && !m.role) m.role = u.project_role || ''; + renderTeamMembers(); +} + function addTeamMember(){ - state.teamMembers.push({role:'',name:''}); + state.teamMembers.push({role:'',name:'',userId:''}); renderTeamMembers(); } @@ -621,6 +741,7 @@ function renderStandardConstraints(){ _constraintsSeeded = true; } const active = name => state.constraints.some(c=>c.name===name); + const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); }; container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
@@ -628,11 +749,33 @@ function renderStandardConstraints(){
${c.description}
+ ${criticalToggle(c.name, active(c.name), critical(c.name))} `).join(''); renderCustomConstraints(); } +// A CRITICAL constraint is one whose reopening after release is announced by +// email (PM + CM + the package owner) rather than only dropping the package to +// Issue (Hold). Only meaningful for a constraint that's switched on. +function criticalToggle(name, enabled, isCritical){ + const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_'); + const tip = 'Critical: if this constraint reopens after the work package has been released, ' + + 'notify the PM, CM and the package owner by email.'; + return ``; +} + +function toggleCriticalConstraint(name, on){ + const c = state.constraints.find(x=>x.name===name); + if(!c) return; + c.critical = !!on; + renderStandardConstraints(); + track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name}); +} + // Render the custom (non-standard) constraints into their own list with remove buttons. function renderCustomConstraints(){ const el = document.getElementById('custom-constraints-list'); if(!el) return; @@ -641,7 +784,10 @@ function renderCustomConstraints(){ el.innerHTML = customs.length ? customs.map(c=>`
${escAttr(c.name)} + + ${criticalToggle(c.name, true, !!c.critical)} +
`).join('') : `
No custom constraints added yet.
`; } @@ -653,7 +799,13 @@ function removeCustomConstraint(name){ function toggleConstraint(name){ const idx = state.constraints.findIndex(c=>c.name===name); if(idx>=0) state.constraints.splice(idx,1); - else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name)); + else { + // Copy the library entry — pushing the shared object would let one project's + // `critical` flag leak into every other project's default constraint set. + const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name); + if(def) state.constraints.push({...def}); + } + renderStandardConstraints(); // the Critical toggle enables/disables with the row } function showConstraintLibrary(){ @@ -921,7 +1073,19 @@ function validateStep(n){ } // ── SOP COMPLETION ──────────────────────────────────────────────────────────── +// Re-saving a SOP that is already complete changes the project's baseline, which +// the server restricts to a Project Admin. Check before doing the work so the +// answer is a clear message rather than a 403 from the sync outbox. +function canEditCompletedSOP(){ + return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true; +} + function completeSOP(){ + if(sopComplete && !canEditCompletedSOP()){ + alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' + + 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.'); + return; + } if(!validateStep(10)) return; collectStepData(); @@ -937,8 +1101,15 @@ function completeSOP(){ apm: state.team.apm, cm: state.team.cm, qm: state.team.qm, + // User-account ids for the same four people. These are what the Creator + // uses to offer an owner and what notification routing needs — a display + // name alone can't be assigned work or emailed. + pmId: state.teamIds.pm || '', + apmId: state.teamIds.apm || '', + cmId: state.teamIds.cm || '', + qmId: state.teamIds.qm || '', site: state.project.site, - teamMembers: state.teamMembers.filter(m=>(m.role||m.name)) + teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId)) }, roles: state.signoffRoles.filter(r=>r.role), governance: { @@ -978,7 +1149,11 @@ function completeSOP(){ kind: s.kind || 'step' })), costCodes: LABOR_COST_CODES, - constraints: state.constraints.map(c=>({name: c.name, description: c.description || '', bim: !!c.bim})) + constraints: state.constraints.map(c=>({ + name: c.name, description: c.description || '', bim: !!c.bim, + // Critical → reopening after release is emailed, not just flagged on the package. + critical: !!c.critical + })) }; sopComplete = true; diff --git a/html/work-package-suite.html b/html/work-package-suite.html index 717dd5b..a3cd1e8 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -103,23 +103,28 @@ ').join('')+''; } -function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } -function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } +// Deleting a work package is a Project Admin action (server: require_project_admin). +// A project_user gets no delete button, and archiving is offered instead. +function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; } + +function deletePackage(i){ const p=savedPackages[i]; if(!p) return; + if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } +function clearSaved(){ if(!savedPackages.length) return; + if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadPackageIntoForm(p){ @@ -1112,7 +1136,7 @@ function newPackage(){ pkgHolds=[]; pkgOverrides={}; set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); prevStatus='Draft'; - updateNumber(); updateReleaseBanner(); showForm(); renderWpNav(); track('new_package'); + updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package'); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function exportPackages(){ @@ -1405,19 +1429,45 @@ function bootSOP(){ } // Populate the Owner picker with this project's members (+ admins). The list is // only used to pick an assignee; the server re-validates on save. +// +// The SOP's project team is listed first (they're the people this project has +// actually named), with everyone else on the project after them — so the common +// pick is at the top without hiding anyone who could legitimately own a package. +let projectMembers = []; +function sopTeamIds(){ + const p = (SOP && SOP.project) || {}; + const ids = [p.pmId, p.apmId, p.cmId, p.qmId]; + (p.teamMembers || []).forEach(m => ids.push(m && m.userId)); + return ids.filter(Boolean); +} async function loadMembers(){ const sel=document.getElementById('wp_assignee'); if(!sel || !activeProjectId) return; try { const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'}); if(!r.ok) return; - const list=await r.json(); + projectMembers=await r.json(); const cur=sel.value; + const team=sopTeamIds(); + const onTeam=projectMembers.filter(u=>team.includes(u.id)); + const others=projectMembers.filter(u=>!team.includes(u.id)); + const opt=u=>``; sel.innerHTML=''+ - list.map(u=>``).join(''); + (onTeam.length?`${onTeam.map(opt).join('')}`:'')+ + (others.length?`${others.map(opt).join('')}`:''); if(cur) sel.value=cur; + else defaultOwnerToMe(); } catch(e){} } +// A new package defaults to the person creating it — they're accountable until +// they hand it over. Only applies to an unsaved, unassigned package, and only if +// they're actually assignable on this project. +function defaultOwnerToMe(){ + const sel=document.getElementById('wp_assignee'); + if(!sel || sel.value || editingId) return; + const me=(window.WP_USER||{}).id; + if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me; +} function bootData(){ loadStore(); // reads the localStorage cache (hydrated from the server below) diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index b757730..74875f8 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -631,6 +631,11 @@ .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{ 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; diff --git a/server/alembic/versions/b41c7ae90d52_permissions_roles_and_project_role.py b/server/alembic/versions/b41c7ae90d52_permissions_roles_and_project_role.py new file mode 100644 index 0000000..09a3d7e --- /dev/null +++ b/server/alembic/versions/b41c7ae90d52_permissions_roles_and_project_role.py @@ -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') diff --git a/server/app.py b/server/app.py index dfdb95a..e7eef97 100644 --- a/server/app.py +++ b/server/app.py @@ -12,6 +12,7 @@ import os import re import uuid from datetime import timedelta, timezone +from time import monotonic from typing import Any, Optional from urllib.parse import urlparse @@ -111,7 +112,7 @@ def check_id(v: Optional[str]) -> None: # mutating endpoints raise 403 on no access. def accessible_project_ids(db: Session, user: "models.User"): """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 rows = db.scalars( 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: - if user.role == "admin": + if auth.is_admin(user): return if not project_id: # 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") +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"): """Restrict a SELECT to the user's accessible projects (no-op for admins).""" 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) if not u or not u.is_active: raise HTTPException(status_code=400, detail="Assignee is not a valid user") - if u.role == "admin": + if auth.is_admin(u): return ok = db.scalar( select(models.ProjectMember.id).where( @@ -251,6 +265,7 @@ class SettingsIn(BaseModel): from_addr: Optional[str] = None from_name: Optional[str] = None app_base_url: Optional[str] = None + bim_enabled: Optional[bool] = None class TestEmailIn(BaseModel): @@ -296,7 +311,21 @@ class NewUserIn(BaseModel): password: str full_name: 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): @@ -313,7 +342,7 @@ class ActiveIn(BaseModel): class RoleIn(BaseModel): - role: str # 'admin' | 'user' + role: str # permissions role — see auth.ROLES class ProjectAssignIn(BaseModel): @@ -367,6 +396,115 @@ def logout(response: Response): 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") def whoami(user: models.User = Depends(auth.get_current_user)): """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) if problem: raise HTTPException(status_code=400, detail=problem) - if body.role not in ("admin", "user"): - raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") + if body.role not in auth.ROLES: + raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}") if auth.find_user(db, body.username): raise HTTPException(status_code=409, detail="A user with that username already exists") 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(), password_hash=auth.hash_password(body.password), role=body.role, + project_role=body.project_role.strip()[:120], ) 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.refresh(u) 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") 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 remaining admin can't be demoted (keeps the app manageable).""" - if body.role not in ("admin", "user"): - raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") + if body.role not in auth.ROLES: + raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}") u = db.get(models.User, user_id) if not u: raise HTTPException(status_code=404, detail="User not found") if u.id == admin.id: 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( select(models.User.id).where( - (models.User.role == "admin") + (models.User.role == auth.ROLE_ADMIN) & (models.User.id != u.id) & (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() +@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}") 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) @@ -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)) db.commit() # 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) db.commit() 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) if not proj: 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.commit() 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 if sop is not None: 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 if sop is None: 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) if not sop: 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, summary=(sop.name or sop.number or sop.id)) 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) if not wp: 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, summary=(wp.number or wp.subject or wp.id)) 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) +@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") 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} @@ -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), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): 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) rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all() 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) 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 [] - 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() for u in list(members) + list(admins): if u.id in seen or not u.is_active: continue 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()) return out diff --git a/server/auth.py b/server/auth.py index c4866fe..6ddc778 100644 --- a/server/auth.py +++ b/server/auth.py @@ -16,7 +16,19 @@ Security model: 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. -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 secrets @@ -39,6 +51,48 @@ COOKIE_NAME = "wp_session" JWT_ALG = "HS256" # How long a login lasts before the user must sign in again. 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). 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]: - """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: - return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) + claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) except jwt.PyJWTError: 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 ──────────────────────────────────────────────────────────── @@ -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": - if user.role != "admin": + if not is_admin(user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return user diff --git a/server/models.py b/server/models.py index 50a693c..f2d5cf8 100644 --- a/server/models.py +++ b/server/models.py @@ -121,8 +121,15 @@ class WorkPackage(Base): class User(Base): """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 - either 'admin' (can manage users) or 'user'.""" + hash (see server/auth.py). `username` is what people sign in with. + + 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" 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="") full_name: 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=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.""" return { "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), } diff --git a/server/notify.py b/server/notify.py index f699a61..1e2344a 100644 --- a/server/notify.py +++ b/server/notify.py @@ -33,9 +33,19 @@ DEFAULTS = { "from_addr": "", "from_name": "Work Package Suite", "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: row = db.get(models.AppSetting, SETTINGS_KEY) s = dict(DEFAULTS) @@ -65,6 +75,16 @@ def public_settings(db: Session) -> dict: 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: 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) +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, 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 +