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 +