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) <noreply@anthropic.com>
90 lines
5.2 KiB
JavaScript
90 lines
5.2 KiB
JavaScript
/* Shared helpers for the suite's admin pages (Admin Console, User Directory).
|
|
|
|
These used to live in admin.js. They are here because the User Directory needs
|
|
the same escaping and the same role vocabulary, and a second copy of either is a
|
|
liability: a divergent jsq() is an XSS, and a divergent role list quietly offers
|
|
a permission the server will refuse.
|
|
|
|
Loaded as plain globals (no modules) to match the rest of the suite. */
|
|
|
|
// ── api ──────────────────────────────────────────────────────────────────────
|
|
// Never throws: returns {status, json} with status 0 when the request itself
|
|
// failed, so every caller can branch on one shape.
|
|
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) }; }
|
|
}
|
|
|
|
// The message to show for a failed call, preferring the server's own words.
|
|
function apiError(status, json, fallback){
|
|
if(json && json.detail) return json.detail;
|
|
if(status === 0) return 'Could not reach the server.';
|
|
if(status === 401) return 'Not signed in. Reload and log in again.';
|
|
return (fallback || 'Request failed') + ' (HTTP ' + status + ').';
|
|
}
|
|
|
|
// ── escaping ─────────────────────────────────────────────────────────────────
|
|
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').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,
|
|
// full names and usernames are free text that a 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,"\\'"));
|
|
}
|
|
|
|
// ── role vocabulary (mirrors server/auth.py) ─────────────────────────────────
|
|
// Permissions roles: what an account may DO. Ordered most- to least-privileged,
|
|
// same as auth.ROLES, because that is the order the dropdowns render in.
|
|
const PERM_ROLES = ['admin','project_super_user','project_admin','project_user'];
|
|
const PERM_LABELS = {
|
|
admin:'Administrator',
|
|
project_super_user:'Project Super User',
|
|
project_admin:'Project Admin',
|
|
project_user:'Project User',
|
|
};
|
|
// One-line description of each, used in the legends and dropdown titles.
|
|
const PERM_HELP = {
|
|
admin:'Manages users, app settings and every project.',
|
|
project_super_user:'On their assigned projects: everything a Project Admin can do, '+
|
|
'plus creating and managing that project\'s user accounts.',
|
|
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 is complete.',
|
|
};
|
|
// Roles that can be held on a SINGLE project (ProjectMember.role); '' inherits the
|
|
// account's own. 'admin' is app-wide by definition and never appears here.
|
|
const PROJECT_SCOPED_ROLES = ['project_super_user','project_admin','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 roleLabel(r){ const n = normRole(r); return PERM_LABELS[n] || n; }
|
|
// Which pill a role wears. Admin and super user each get their own colour because
|
|
// "can reach every project" and "can create users here" are the two facts you scan
|
|
// this column for.
|
|
function roleTagClass(r){
|
|
const n = normRole(r);
|
|
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
|
|
}
|