Files
Matt Mabrey 73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00

468 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* User Directory for the Work Package Suite.
Moved out of the Admin Console because user administration is no longer
admin-only: a PROJECT SUPER USER creates and manages the accounts on the projects
they administer, which means the page has to be reachable by people who must never
see the console's settings, diagnostics or app-wide switches.
ACCESS — three audiences on one page, decided by GET /api/auth/user-scope:
• App admin every account, every control.
• Project super user the accounts on the projects they administer. Controls
appear per row: an account that is also on a job they
don't administer is read-only, and the row says why.
• Everyone else a read-only directory of the people on their own
projects. No controls at all.
The server enforces every one of those rules (server/app.py: require_user_manager,
require_manage_user, visible_user_ids). Nothing here is a security boundary — it is
here so nobody is shown a button that would only 403, and so the reason is on the
page instead of in an alert.
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
let _users = []; // the directory as the server scoped it
let _scope = null; // GET /api/auth/user-scope
let _meId = null;
// ── boot ──────────────────────────────────────────────────────────────────────
async function boot(){
document.getElementById('users-main').style.display = '';
_meId = (window.WP_USER && window.WP_USER.id) || null;
const { status, json } = await api('GET','/api/auth/user-scope');
// A failed scope call must not leave the page pretending to be read-only-with-no-
// reason: fall back to the least-privileged rendering and say so.
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
if(status !== 200){
banner('scope-banner','bad','✕ '+apiError(status, json, 'Could not work out what you may do here')+
' Showing the directory read-only.');
} else {
renderScope();
}
renderCreateForm();
loadUsers();
}
function banner(id, kind, text){
const el = document.getElementById(id);
if(!el) return;
if(!text){ el.innerHTML=''; return; }
el.className = 'banner' + (kind ? ' '+kind : '');
el.textContent = text;
}
// What this account may do here, stated once at the top rather than implied by which
// buttons happen to be missing.
function renderScope(){
const el = document.getElementById('scope-banner');
const sub = document.getElementById('dir-sub');
if(!_scope.can_manage_users){
el.innerHTML = '';
if(sub) sub.textContent = 'The people on your projects — who they are, and how to reach them. '+
'Only an administrator or a Project Super User can change accounts.';
return;
}
if(_scope.scope === 'all'){
el.className = 'banner';
el.innerHTML = 'You are an <strong>Administrator</strong>: you manage every account in the suite. '+
'App settings, diagnostics and the default-member rules live in the '+
'<a class="home" href="admin.html">Admin Console</a>.';
if(sub) sub.textContent = 'Every login account in the suite.';
return;
}
const names = (_scope.managed_projects||[]).map(p => p.name || p.number || p.id);
el.className = 'banner';
el.innerHTML = 'You are a <strong>Project Super User</strong> on '+
(names.length === 1 ? uesc(names[0]) : names.length+' projects')+
' — you create and manage the accounts on '+(names.length === 1 ? 'that project' : 'those projects')+
(names.length > 1 ? ': <strong>'+names.map(uesc).join('</strong>, <strong>')+'</strong>' : '')+'. '+
'An account that is also on a project you dont administer is read-only here.';
if(sub) sub.textContent = 'The people on your projects, and the accounts you administer.';
}
// ── the table ─────────────────────────────────────────────────────────────────
async function loadUsers(){
const wrap = document.getElementById('users-table');
const { status, json } = await api('GET','/api/auth/users');
if(status !== 200 || !Array.isArray(json)){
banner('users-banner','bad','✕ '+apiError(status, json, 'Could not load the directory'));
wrap.innerHTML = ''; return;
}
banner('users-banner','', '');
_users = json;
renderUsers();
}
function manages(){ return !!(_scope && _scope.can_manage_users); }
function renderUsers(){
const wrap = document.getElementById('users-table');
const q = ((document.getElementById('user-search')||{}).value||'').trim().toLowerCase();
const f = ((document.getElementById('user-filter')||{}).value||'');
const total = _users.length;
const sub = document.getElementById('people-sub');
if(sub){
sub.textContent = manages()
? 'Login accounts you can see. The ones you administer carry controls; the rest are listed for reference.'
: 'Everyone on the projects you can access, plus the administrators.';
}
if(!total){ wrap.innerHTML = '<div class="note">Nobody to show yet.</div>'; return; }
const list = _users.filter(u => {
if(f === 'active' && !u.is_active) return false;
if(f === 'disabled' && u.is_active) return false;
if(f === 'mine' && !u.manageable) return false;
if(!q) return true;
return ((u.username||'')+' '+(u.full_name||'')+' '+(u.email||'')+' '+
(u.project_role||'')+' '+roleLabel(u.role)).toLowerCase().indexOf(q) >= 0;
});
const count = '<div class="note">'+list.length+' of '+total+' '+(total===1?'person':'people')+'</div>';
if(!list.length){ wrap.innerHTML = count+'<div class="note">Nothing matches.</div>'; return; }
const head = manages()
? ['Username','Name','Email',
['Permissions','What this account may do in the app'],
['Project role','Job function on the project — descriptive only'],
['Project access','Which projects this user can access, and their role on each'],
'Status','Last login','Actions']
: ['Name','Username','Email',
['Permissions','What this account may do in the app'],
['Project role','Job function on the project — descriptive only'],
'Status'];
const ths = head.map(h => Array.isArray(h)
? '<th title="'+uesc(h[1])+'">'+uesc(h[0])+'</th>' : '<th>'+uesc(h)+'</th>').join('');
const rows = list.map(manages() ? managerRow : readonlyRow).join('');
wrap.innerHTML = count+'<div class="tscroll"><table class="grid"><thead><tr>'+ths+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+legend();
}
function legend(){
if(!manages()){
return '<div class="note" style="margin-top:10px"><strong>Project role</strong> is the persons job '+
'function — it feeds the SOP team pickers and notification routing, and grants nothing on its own.</div>';
}
return '<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
PERM_ROLES.map(r => '<em>'+uesc(PERM_LABELS[r])+'</em>: '+uesc(PERM_HELP[r])).join(' ')+
' <strong>Project role</strong> is the persons job function — it feeds the SOP team pickers '+
'and notification routing, and grants nothing on its own.</div>';
}
// The read-only card: name, contact, role. No ids are bound into handlers because
// there are no handlers — that is the point of this rendering.
function readonlyRow(u){
return '<tr>'+
'<td><strong>'+uesc(u.full_name || u.username)+'</strong>'+(u.id===_meId?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.username)+'</td>'+
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+
(u.email ? '<a class="home" href="mailto:'+uesc(u.email)+'">'+uesc(u.email)+'</a>' : '—')+'</span></td>'+
'<td><span class="tag '+roleTagClass(u.role)+'">'+uesc(roleLabel(u.role))+'</span></td>'+
'<td>'+uesc(u.project_role || '—')+'</td>'+
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
'</tr>';
}
function managerRow(u){
const me = u.id === _meId;
const uid = jsq(u.id), uname = jsq(u.username);
const can = !!u.manageable;
const why = u.manage_blocked_reason || '';
const fmt = s => s ? wpFormatDateTime(s) : '—';
const role = normRole(u.role);
// Your own row never offers the controls that could lock you out of the app.
const roleCell = me
? '<span class="tag '+roleTagClass(role)+'">'+uesc(roleLabel(role))+'</span><span class="me-tag">locked</span>'
: !can
? '<span class="tag '+roleTagClass(role)+'" title="'+uesc(why)+'">'+uesc(roleLabel(role))+'</span>'
: roleSelect(uid, uname, role);
// Job function follows the same permission as everything else on the row. Note a
// super user cannot edit their OWN row: the server refuses account changes to any
// admin or super-user account, including the caller's.
const projRoleCell = can
? projRoleSelect(uid, uname, u.project_role || '')
: projRoleReadonly(u, can, why);
const actions = [];
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+
// 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.
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td><div class="cellactions">'+actions.join('')+'</div></td>'+
'</tr>';
}
// Only the roles the server said this caller may grant are offered. The account's
// CURRENT role is always included even when it isn't grantable, or the dropdown would
// silently misreport a Project Admin as a Project User the moment it renders.
function roleSelect(uid, uname, role){
const grantable = (_scope && _scope.grantable_roles) || [];
const opts = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === role);
return '<select class="role-select'+(role==='admin'?' is-admin':'')+
'" title="Change what this account may do" onchange="changeRole(\''+uid+'\',this.value,\''+uname+'\')">'+
opts.map(r => '<option value="'+r+'"'+(role===r?' selected':'')+
(grantable.indexOf(r) < 0 ? ' disabled' : '')+'>'+uesc(PERM_LABELS[r])+'</option>').join('')+
'</select>';
}
function projRoleSelect(uid, uname, pr){
const list = (_scope && _scope.project_roles) || PROJECT_ROLES;
return '<select class="role-select" title="Job function on the project" '+
'onchange="changeProjectRole(\''+uid+'\',this.value,\''+uname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
list.map(r => '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>').join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && list.indexOf(pr) < 0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
}
function projRoleReadonly(u, can, why){
return '<span'+(can?'':' title="'+uesc(why)+'"')+'>'+uesc(u.project_role || '—')+'</span>';
}
// Per-user project access gets its own column: buried among the action buttons, it
// was exactly where you'd fail to find "which projects can this person see, and what
// may they do there".
function projAccessCell(u){
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
const n = u.project_count;
const label = (n === undefined || n === null) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n+' project'+(n===1?'':'s'));
if(!u.manageable){
return '<span class="note" style="margin:0" title="'+uesc(u.manage_blocked_reason||'')+'">'+uesc(label)+'</span>';
}
return '<button class="mini'+(n === 0 ? ' danger' : '')+
'" onclick="manageProjects(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')"'+
' title="Choose which projects this user can access, and their role on each">'+
uesc(label)+'</button>';
}
// ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers();
else { wpAlertDialog({title:'Change failed', message:'Could not change that account: '+apiError(status, json)}); loadUsers(); }
}
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not change permissions for '+username+': '+apiError(status, json)});
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) wpAlertDialog({title:'Change failed', message:'Could not set the project role for '+username+': '+apiError(status, json)});
loadUsers();
}
async function deleteUser(id, username){
if(!(await wpConfirmDialog({title:'Delete user',
message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
'This cannot be undone — disable the account instead if you only want to block sign-in.',
okLabel:'Delete user'}))) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers();
else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
}
// ── create ────────────────────────────────────────────────────────────────────
function renderCreateForm(){
const card = document.getElementById('create-card');
if(!card) return;
if(!manages()){ card.style.display = 'none'; return; }
card.style.display = '';
const grantable = _scope.grantable_roles || [];
const roleSel = document.getElementById('nu-role');
roleSel.innerHTML = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0)
.map(r => '<option value="'+r+'"'+(r==='project_user'?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>').join('');
const prSel = document.getElementById('nu-project-role');
prSel.innerHTML = '<option value="">Project role…</option>'+
(_scope.project_roles||PROJECT_ROLES).map(r => '<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
// The project picker is REQUIRED for a super user and optional for an admin —
// because a super user's authority over an account comes from the projects it is
// on, so an account created with none is one they instantly cannot manage. The
// server refuses that; the form says so up front rather than after a failed save.
const admin = _scope.scope === 'all';
const projects = _scope.managed_projects || [];
document.getElementById('create-sub').innerHTML = admin
? 'Creates a login account. Assign projects here or later from <strong>Project access</strong> in the table above.'
: 'Creates a login account on your project'+(projects.length===1?'':'s')+
'. You administer users per project, so a new account has to start on at least one of them.';
document.getElementById('nu-projects-label').innerHTML = admin
? 'Projects (optional — you can assign them later)'
: 'Projects <strong>*</strong> — pick at least one';
const live = projects.filter(p => !p.archived);
const list = document.getElementById('nu-project-list');
if(!projects.length){
list.innerHTML = '<div class="note" style="padding:var(--s2)">You dont administer any project yet.</div>';
} else {
// Archived projects are omitted, not disabled: staffing a frozen job is never
// what you mean when creating an account, and an admin can still assign one
// afterwards from the project-access dialog.
list.innerHTML = (live.length ? live : []).map(p =>
'<div class="pickrow"><label><input type="checkbox" value="'+uesc(p.id)+'"'+
(live.length === 1 ? ' checked' : '')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span style="color:var(--muted)">'+uesc(p.number)+'</span>' : '')+'</span></label></div>').join('')
|| '<div class="note" style="padding:var(--s2)">Every project you administer is archived.</div>';
}
}
async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
// The username entered here MUST match what Okta's identity claim will send for
// this person exactly — this creates the account ahead of their first sign-in,
// and that's how a later Okta sign-in finds this row instead of provisioning a
// second one. See create_user()'s docstring in server/app.py.
const username = val('nu-username').trim();
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return;
}
say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{
username, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)','✓ Created '+username+'.');
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
}
}
// ── project access dialog ─────────────────────────────────────────────────────
// For an admin this is the whole of a person's access. For a super user it is their
// slice of it: the server returns only the projects they administer and says how many
// more the person is on, and a save leaves those others untouched.
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
openProjectModal(id, username, json);
}
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
// A project's role dropdown only matters while that project is ticked.
function projRowToggled(cb){
const row = cb.closest('.pickrow');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
function openProjectModal(userId, username, data){
closeProjectModal();
const projects = (data.projects||[]).slice()
// 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.
.sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
const assigned = new Set(data.assigned||[]);
const roles = data.roles || {};
const userObj = data.user || {};
const isAdmin = normRole(userObj.role) === 'admin';
const acctRole = normRole(userObj.role);
const grantable = data.grantable_project_roles || PROJECT_SCOPED_ROLES;
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
uesc(PERM_LABELS[acctRole]||acctRole)+')</option>']
.concat(PROJECT_SCOPED_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === cur).map(r =>
'<option value="'+r+'"'+(cur===r?' selected':'')+(grantable.indexOf(r)<0?' disabled':'')+'>'+
uesc(PERM_LABELS[r])+' here</option>'));
return '<div class="pickrow">'+
'<label><input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+
'</span></label>'+
'<select class="role-select" data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+'>'+
opts.join('')+'</select>'+
'</div>';
}).join('') : '<div class="note">No projects to choose from.</div>';
const others = data.other_projects || 0;
const intro = isAdmin
? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can '+
'access every project regardless of assignment.</div>'
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role '+
'on each. <strong>Project Admin</strong> can delete work packages, change a completed SOP and delete '+
'that project; <strong>Project Super User</strong> can also manage that projects user accounts; '+
'<strong>Project User</strong> can do neither. Leave it on <em>Same as account</em> to use their '+
'Permissions setting.</div>'+
(others ? '<div class="banner warn" style="margin:0 0 10px">Also on '+others+' project'+
(others===1?'':'s')+' you dont administer. Those stay exactly as they are — saving here only '+
'changes the projects listed below.</div>' : '');
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.className = 'modal-ov';
modal.innerHTML =
'<div class="modal-box">'+
'<div class="modal-head">Project access &amp; permissions — '+uesc(username)+'</div>'+
'<div class="modal-body">'+intro+'<div id="proj-list">'+items+'</div></div>'+
'<div class="modal-foot">'+
'<button onclick="closeProjectModal()">Cancel</button>'+
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
'</div>'+
'</div>';
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, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
{ project_ids: ids, roles: roleMap });
if(status === 200){ closeProjectModal(); loadUsers(); }
else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
};
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeProjectModal(); });
// ── start ─────────────────────────────────────────────────────────────────────
// auth-guard.js requires a login and publishes window.WP_USER (firing
// 'wp-auth-ready'). Unlike the Admin Console there is no role gate here: everyone
// signed in gets a directory, and what they can DO comes from the scope call.
let _booted = false;
function start(){
if(_booted || !window.WP_USER) return;
_booted = true;
boot();
}
document.addEventListener('wp-auth-ready', start);
start();