From 4ace2afb1cf925015cc19337c6e54c43f97a2a2d Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 5 Aug 2026 17:36:14 -0700 Subject: [PATCH 01/78] Move user administration to its own page; add Project Super User User accounts lived in the Admin Console, which is admins-only. Project admins need to create the accounts on their own jobs without an app admin on the phone, so accounts move to a new User Directory page and a new role carries the right. server/auth.py, server/app.py New permissions role `project_super_user`, between admin and project_admin: everything a project admin may do, plus user administration SCOPED to the projects they hold the role on. Four limits make it safe to hand out, all enforced server-side: * Scope comes from projects, not the job title. It resolves per membership (managed_project_ids), so an ordinary account can hold it on one job via ProjectMember.role, and a super user demoted on one job administers nobody there. No projects, no authority. * Account-level changes (password, disable, rename, permissions, delete) require EXCLUSIVE scope: refused when the target is also on a project the caller does not administer, because those changes are global. The directory renders such rows read-only with the reason. * No admin or super-user targets, and neither role can be granted by a super user -- that is the line that stops it becoming app-wide control. * PUT .../projects rebuilds only the caller's own slice; memberships on projects they do not administer are left untouched. A payload that simply omits them must not cut someone off a job the caller cannot see. Creating requires naming at least one of your own projects: an account with none would be one the creator instantly cannot manage. /api/auth/users is now scoped rather than admin-only, and carries a per-row `manageable` verdict plus the reason. Non-managers get a contact card only -- a project user has no business reading colleagues' login history. New /api/auth/user-scope tells the page what it may offer. Administrative password resets are now audited; they were the one account change that left no trace. Settings, feature flags and the auto-add rule stay admin-only. While here: one definition of "is a user manager", derived from the managed set. An account-role-only version disagreed with the scoped one and locked per-project super users out of routes they were entitled to. html/users.html, html/users.js The directory: three renderings from one page -- admin (everything), super user (controls per row, read-only where scope is shared), everyone else (a read-only directory of the people on their own projects). html/console.css, html/console-util.js Extracted from admin.html/admin.js so both console pages share them. A divergent jsq() is an XSS and a divergent role list offers permissions the server refuses, so neither may exist twice. html/wp-sidenav.{js,css} Global nav drawer, role-gated, carrying ?project= across links. Mounted on the field view (which had no way to anywhere) plus both console pages. No migration: users.role is already String(20) and the new value fits. Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency stack, 33 static JS checks. Not verified in a browser -- no JS engine on this machine -- so users.html and field.html want one manual load. server/smoketest.py still fails with 401s. Pre-existing: it has no login code, so auth_gate refuses it. Confirmed unchanged by stashing this work. Co-Authored-By: Claude Opus 5 (1M context) --- DEPLOY-runbook-2026-08-04.md | 16 +- DEPLOYMENT.md | 33 ++- KNOWN-ISSUES.md | 19 +- html/admin.html | 244 +++--------------- html/admin.js | 363 ++------------------------- html/auth-guard.js | 17 +- html/console-util.js | 89 +++++++ html/console.css | 191 ++++++++++++++ html/field.html | 2 + html/index.html | 7 + html/sw.js | 9 +- html/users.html | 106 ++++++++ html/users.js | 473 +++++++++++++++++++++++++++++++++++ html/wp-sidenav.css | 90 +++++++ html/wp-sidenav.js | 221 ++++++++++++++++ server/app.py | 469 ++++++++++++++++++++++++++++------ server/auth.py | 37 ++- server/manage_users.py | 16 +- server/models.py | 10 +- 19 files changed, 1769 insertions(+), 643 deletions(-) create mode 100644 html/console-util.js create mode 100644 html/console.css create mode 100644 html/users.html create mode 100644 html/users.js create mode 100644 html/wp-sidenav.css create mode 100644 html/wp-sidenav.js diff --git a/DEPLOY-runbook-2026-08-04.md b/DEPLOY-runbook-2026-08-04.md index 53234ec..8b5373a 100644 --- a/DEPLOY-runbook-2026-08-04.md +++ b/DEPLOY-runbook-2026-08-04.md @@ -195,12 +195,18 @@ good deploy look broken. Sanity checks — all four should take under a minute: 1. Log in. The home page offers to select or create a project. -2. Open **Admin Console** (the link is on the home page; you need an admin account). - The user table should read as **one line per user** — if rows are three lines tall - and the table spills outside its white card, you are still on the old cached +2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the + home page). The table should read as **one line per user** — if rows are three lines + tall and the table spills outside its white card, you are still on the old cached files: hard-reload again. -3. Two new cards are present and load: **Projects**, and **Default members on new - projects**. Both should list rows, not an error. + + > Changed since this runbook was written: user accounts moved out of the Admin + > Console into `users.html` when the **Project Super User** role was added, so that + > a project admin can create accounts on their own job. If you are deploying a build + > from before that change, read this step as "Admin Console → the user table". +3. Open **Admin Console** (admin account required). Two new cards are present and + load: **Projects**, and **Default members on new projects**. Both should list rows, + not an error. 4. In the **Projects** card, click **Archive** on a project you don't mind hiding (a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived** — it should reappear marked `archived`. Click **Unarchive** to put it back. That diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 489480b..0190e16 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -332,18 +332,45 @@ console's **Reset password** button). `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. +Both are set on the **User Directory** page (`users.html`) — not the Admin Console, +which no longer manages accounts. | Role | May do | |---|---| -| `admin` | User administration, app settings, and every project | +| `admin` | User administration everywhere, app settings, and every project | +| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access | | `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 +only hides controls to avoid dead-end clicks. Accounts created before roles existed carried the role `user`, which the migration rewrites to `project_user`. +### Project Super User — what bounds it + +The role exists so a project admin can staff their own job without an app admin. +Its limits are what make it safe to hand out, and all of them are server-side +(`managed_project_ids`, `manage_user_problem`, `grantable_roles` in `server/app.py`): + +* **Scope comes from projects, not the job title.** A super user administers the users + of the projects they hold the role on — via their account role, or via + `ProjectMember.role` for a super user on one job only. No projects, no authority. +* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming, + changing permissions or deleting are global acts, so they are refused when the + target is also on a project the caller does not administer. The directory shows + those rows read-only with the reason. An app admin has to make the change. +* **No admin or super-user targets, and none granted.** A super user may hand out + `project_admin` / `project_user` only, and may not touch an admin's or another + super user's account — so the role cannot become a route to app-wide control. +* **Saving project access never reaches outside scope.** `PUT + /api/auth/users/{id}/projects` rebuilds only the caller's own slice; memberships on + projects they don't administer are left untouched. +* **App settings, feature flags and the default-member rule stay admin-only.** + +No migration is needed for the new role — `users.role` is already `String(20)` and +`project_super_user` fits. Grant it from the User Directory (Permissions column), or +per project from **Project access → Project Super User here**. + ## Feature flags **Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index aa88274..5f4952d 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -62,6 +62,14 @@ the token cannot be read, but the injected code does not need it: it runs in the victim's page and can call any API the victim can, including `POST /api/auth/users/{id}/role`. +**The `project_super_user` role (added 2026-08-05) widens the set of victims whose +session is worth stealing, without raising the ceiling.** Previously only an app +admin's session could create accounts or change permissions; now a super user's can +too, within the projects they administer. The ceiling is unchanged — it was already +`admin` — but the odds of landing on a session that can mint an account go up, and a +super user is likelier than an admin to be reading a WP creator on a live job. It is +one more reason the accidental-breakage case is not the only one that matters. + Two controls that look like they would contain this do not: - **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves @@ -83,6 +91,12 @@ malicious one. suite is exposed outside the corporate network, accounts are issued to subcontractors or clients, or self-registration is added. +Note that the second of those got easier to reach without anyone deciding to: a +Project Super User can now issue accounts on their own job without an app admin +involved, so "accounts are issued to subcontractors" can become true by ordinary +delegated use rather than by a policy change. Worth checking the directory +occasionally against who is actually on staff. + ### What closing it takes Small — roughly half an hour. The helper already exists; it was added to the SOP @@ -102,8 +116,9 @@ function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\ 4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to clicks and the name must display intact. -The equivalent fix on the admin side is `jsq()` in `html/admin.js` — same ordering, -same reasoning, worth reading before starting. +The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved +out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) — +same ordering, same reasoning, worth reading before starting. --- diff --git a/html/admin.html b/html/admin.html index 43a6214..a0132ea 100644 --- a/html/admin.html +++ b/html/admin.html @@ -13,198 +13,45 @@ + + @@ -241,29 +88,14 @@ - +
-

User administration

-
Login accounts for the portal. Requires an admin role on your own account.
-
-
-
- -

Add a user

-
- - - - - - - -
-
+

User accounts

+
Login accounts, permissions and project access now live on the + User Directory page. They moved because user administration is no longer + admin-only: a Project Super User creates and manages the accounts on the + projects they administer, and they must never be sent through this console to do it.
+
@@ -286,8 +118,10 @@

Default members on new projects

Everyone flagged here is added automatically to every project created from now on, with the role chosen here. It does not touch projects that already exist — for those, use - Project access in the user table above. Administrators are listed with nothing - to set: they already reach every project.
+ Project access on the User Directory. + Administrators are listed with nothing to set: they already reach every project. This card stays + in the console because it is a rule about every future project, including the ones a + Project Super User has no part in — so only an admin sets it.
Click “Refresh” to load.
@@ -377,7 +211,9 @@ + + diff --git a/html/admin.js b/html/admin.js index 5f12439..b57afb8 100644 --- a/html/admin.js +++ b/html/admin.js @@ -4,14 +4,20 @@ ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js already requires a login (redirecting to login.html otherwise) and publishes window.WP_USER; here we show the console only when that user is an admin, and - show an "Admins only" notice otherwise. Every user-management API is also - enforced as admin-only server-side, so this is a real gate, not obfuscation. */ + show an "Admins only" notice otherwise. Every API this page calls is also + enforced as admin-only server-side, so this is a real gate, not obfuscation. + + USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super + User role arrived: administering users is no longer an admin-only act, so the + page that does it can't be behind an admins-only gate. What stays here is what + genuinely is app-wide and admin-only — settings, feature flags, diagnostics, + project archiving, and the default-member rule for future projects. + + Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */ function reveal(){ document.getElementById('admin-main').style.display=''; - fillProjectRoleOptions(); checkHealth(); - loadUsers(); loadProjects(); loadDefaultMembers(); loadSettings(); @@ -24,18 +30,6 @@ function showDenied(){ document.getElementById('admin-denied').style.display=''; } -// ── api helper ────────────────────────────────────────────────────────────── -async function api(method, path, body){ - const opt = { method, headers:{ 'Accept':'application/json' } }; - if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); } - try { - const r = await fetch(path, opt); - const t = await r.text(); - let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; } - return { status:r.status, json }; - } catch(e){ return { status:0, json:String(e) }; } -} - // ── connectivity ────────────────────────────────────────────────────────────── async function checkHealth(){ const b = document.getElementById('health-banner'); @@ -165,317 +159,6 @@ async function cleanDemo(){ snapshot(); } -// ── user administration ──────────────────────────────────────────────────────── -function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - -// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once -// for the JS string literal it lands in, and again for the HTML attribute carrying -// it. The order is the whole point. Escape the backslashes FIRST, then the quotes, -// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping -// survives, and the browser decodes the entities before the JS parser runs. -// -// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a -// value containing a backslash: the \ we add is itself escaped by the stored one, -// the quote closes the literal, and everything after it runs as code. Project names -// and full names are free text that any signed-in user can write, so that is a real -// path from a project_user to whatever an admin's session can do. Use jsq() for -// EVERY value that lands inside an inline handler. -function jsq(v){ - return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); -} - -async function currentUserId(){ - if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id; - const { status, json } = await api('GET','/api/auth/me'); - return (status===200 && json && json.user) ? json.user.id : null; -} - -async function loadUsers(){ - const banner=document.getElementById('users-banner'); - const wrap=document.getElementById('users-table'); - banner.className='banner'; banner.textContent='Loading…'; banner.style.display=''; - const { status, json } = await api('GET','/api/auth/users'); - if(status===403){ - banner.className='banner bad'; - banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users'; - wrap.innerHTML=''; return; - } - if(status===401){ - banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return; - } - if(status!==200 || !Array.isArray(json)){ - banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return; - } - banner.style.display='none'; - const meId = await currentUserId(); - renderUsers(json, meId); - // Fill in the project-access counts, then repaint that column. - await loadProjectCounts(json); - 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(''); -} - -// Per-user project access gets its own column: it was buried among the action -// buttons, which is exactly where you'd fail to find "which projects can this -// person see, and what may they do there". -let _userProjectCounts = {}; // user id -> number of assigned projects - -function projAccessCell(u){ - const uname = jsq(u.username); - if(normRole(u.role) === 'admin'){ - return 'all projects'; - } - const n = _userProjectCounts[u.id]; - const label = (n === undefined) ? 'Projects…' - : (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's')); - return ''; -} - -// A project's role dropdown only matters while that project is ticked. -function projRowToggled(cb){ - const row = cb.closest('div'); - const sel = row && row.querySelector('select'); - if(sel) sel.disabled = !cb.checked; -} - -// Counts for that column. One call per user, but only for non-admins and only on a -// refresh — the admin console is not a hot path. -async function loadProjectCounts(list){ - const targets = (list || []).filter(u => normRole(u.role) !== 'admin'); - await Promise.all(targets.map(async u => { - const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects'); - if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length; - })); -} - -function renderUsers(list, meId){ - const wrap=document.getElementById('users-table'); - if(!list.length){ wrap.innerHTML='
No users yet.
'; return; } - const fmt = s => s ? wpFormatDateTime(s) : '—'; - let rows = list.map(u=>{ - const me = u.id===meId; - const active = u.is_active; - const disableBtn = me - ? '' - : ''; - const delBtn = me - ? '' - : ''; - // 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 = jsq(u.username); - const escUid = jsq(u.id); - // 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(PERM_LABELS[role]||role)+'locked' - : ''; - // 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||'')+''+ - // The address is truncated with the full value on the title: a long one used - // to wrap mid-word and push the whole row onto three lines. - ''+uesc(u.email||'')+''+ - ''+roleCell+''+ - ''+projRoleCell+''+ - '
'+projAccessCell(u)+'
'+ - ''+(active?'active':'disabled')+''+ - ''+fmt(u.last_login_at)+''+ - '
'+ - ''+ - disableBtn+delBtn+ - '
'+ - ''; - }).join(''); - // Nine columns outrun even the widened card, so the table scrolls inside .tscroll - // rather than forcing every cell to wrap. The explanatory note stays outside it. - wrap.innerHTML='
'+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+rows+'
UsernameNameEmailPermissionsProject roleProject accessStatusLast loginActions
'+ - '
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(){ - const msg=document.getElementById('users-create-msg'); - const username=document.getElementById('nu-username').value.trim(); - 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<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,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=''); - loadUsers(); - } else { - msg.style.color='var(--red)'; - msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').')); - } -} - -async function resetPw(id, username){ - const pw=prompt('New password for "'+username+'" (min 8 characters):'); - if(pw===null) return; - if(pw.length<8){ alert('Password must be at least 8 characters.'); return; } - const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw}); - if(status===200) alert('Password reset for '+username+'.'); - else alert('Failed: '+((json && json.detail)||('HTTP '+status))); -} - -async function toggleActive(id, makeActive){ - const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); - if(status===200) loadUsers(); - else alert('Failed: '+((json && json.detail)||('HTTP '+status))); -} - -// Change a user's role (user ↔ admin) at any time. The server enforces the same -// admin-only rule as every other user-management call, and refuses to remove the -// last admin. On any failure we reload so the dropdown snaps back to the truth. -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 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(); - } -} - -async function deleteUser(id, username){ - if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return; - const { status, json } = await api('DELETE','/api/auth/users/'+id); - if(status===200) loadUsers(); - else alert('Failed: '+((json && json.detail)||('HTTP '+status))); -} - -// ── project access assignment ─────────────────────────────────────────────────── -async function manageProjects(id, username){ - const { status, json } = await api('GET','/api/auth/users/'+id+'/projects'); - if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; } - openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{}); -} -function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); } -function openProjectModal(userId, username, projects, assigned, userObj, roles){ - closeProjectModal(); - const isAdmin = userObj && normRole(userObj.role)==='admin'; - const acctRole = userObj ? normRole(userObj.role) : 'project_user'; - roles = roles || {}; - // Each project row: access tick + the role ON THAT project. "Same as account" - // inherits the account's Permissions, so the common case needs no thought. - // Live jobs first — an archived one is still listed (an existing assignment has to - // stay removable) but it is finished work, so it doesn't belong at the top of a - // list you're using to staff someone. - projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0)); - const items = projects.length ? projects.map(p => { - const on = assigned.has(p.id); - const cur = roles[p.id] || ''; - const sel = ''; - return '
'+ - ''+ sel + - '
'; - }).join('') : '
No projects exist yet.
'; - const modal = document.createElement('div'); - modal.id = 'proj-modal'; - modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;'; - modal.innerHTML = - '
'+ - '
Project access & permissions — '+uesc(username)+'
'+ - '
'+ - (isAdmin ? '' - : '
Tick the projects this user may access, and set their role on each. '+ - 'Project Admin can delete work packages, change a completed SOP and delete that project; '+ - 'Project User cannot. Leave it on Same as account to use their Permissions setting.
')+ - '
'+items+'
'+ - '
'+ - '
'+ - ''+ - (isAdmin ? '' : '')+ - '
'+ - '
'; - modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); }); - document.body.appendChild(modal); - const saveBtn = document.getElementById('proj-save'); - if(saveBtn) saveBtn.onclick = async () => { - const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value); - const roleMap = {}; - ids.forEach(pid => { - const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]'); - if(sel && sel.value) roleMap[pid] = sel.value; - }); - const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap}); - if(status===200){ closeProjectModal(); loadUsers(); } - else alert('Save failed (HTTP '+status+').'); - }; -} - // ── projects: archive / unarchive ─────────────────────────────────────────────── // Archiving is the answer to "this job is over but I can't throw the data away". // An archived project disappears from every picker, switcher and search in the @@ -615,8 +298,8 @@ async function loadDefaultMembers(){ renderDefaultMembers(); } -// Same idea as projRowToggled() — the role only matters while the tick is on — but -// here the select sits in a sibling , so the lookup is scoped to the row. +// The role only matters while the tick is on. The select sits in a sibling , so +// the lookup is scoped to the row. function defMemToggled(cb){ const row = cb.closest('tr'); const sel = row && row.querySelector('select'); @@ -634,8 +317,7 @@ function renderDefaultMembers(){ const who = ''+uesc(u.username)+''+ (u.full_name ? ' '+uesc(u.full_name)+'' : '')+''+ ''+uesc(u.email||'—')+''; - // Admins reach every project already, so there is nothing to add them to — - // the same thing projAccessCell() says in the user table. + // Admins reach every project already, so there is nothing to add them to. if(role === 'admin'){ return ''+who+ ''+uesc(PERM_LABELS.admin)+''+ @@ -645,8 +327,15 @@ function renderDefaultMembers(){ } const on = !!u.auto_add_projects; const cur = u.auto_add_role || ''; + // Every project-scoped role is offered, super user included: this card is + // admin-only, and "the QA lead runs the users on every new job" is exactly the + // sort of standing rule it exists to express. + const opts = [''] + .concat(PROJECT_SCOPED_ROLES.map(r => + '')); return ''+who+ - ''+uesc(PERM_LABELS[role]||role)+''+ + ''+uesc(PERM_LABELS[role]||role)+''+ '