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) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:36:14 -07:00
parent 3cccdf1c4b
commit 4ace2afb1c
19 changed files with 1769 additions and 643 deletions

473
html/users.js Normal file
View File

@@ -0,0 +1,473 @@
/* 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="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
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(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</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 resetPw(id, username){
const pw = prompt('New password for "'+username+'" (min 12 characters):');
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.');
else alert('Could not reset the password: '+apiError(status, json));
}
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('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) alert('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) alert('Could not set the project role for '+username+': '+apiError(status, json));
loadUsers();
}
async function deleteUser(id, username){
if(!confirm('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.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers();
else alert('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 || '';
const username = val('nu-username').trim();
const password = val('nu-password');
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(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); 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, password, 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','nu-password'].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){ alert('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 alert('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();