login.html/login.js: the two reset views are gone along with the reset-token handling, and the sign-in form now says which password to type - "your Windows password, the same one you use to sign in to your computer" - using the .hint class the page already had, so no new CSS and no new literal. "Forgot password?" is KEPT and points at https://primecontrols.okta.com/. An earlier draft of this task deleted the link and I proposed a plain "contact IT" sentence instead; Okta is the better answer, and with no app password and no break-glass it is the only recovery path that exists. Three details that would each have broken it: - The old click handler on #forgot-link called preventDefault() to swap views. Left in place it would have silently swallowed the navigation, so the link would look right and do nothing. There is now deliberately no handler, and login.js says why so nobody adds one back. - target="_blank" without rel="noopener noreferrer" hands the opened page a window.opener handle back to the login page. - Worth recording since it was checked rather than assumed: the CSP allows this. form-action 'self' governs form submission, not link navigation, and no navigate-to directive is set - so a plain <a href> off-origin is fine and the nginx config needs no change. login.js also handles 503 distinctly now. T10.2 made that mean "the directory is unreachable or misconfigured", which is our fault - showing "invalid password" would send people hunting for a password they no longer have while a deploy is broken. Also removed, because T10.3 deleted the endpoints behind them and leaving them would have produced visible 404s rather than dead-but-harmless markup: auth-guard.js the whole change-password dialog (POST /api/auth/password) wp-sidenav.js the "Password / Change your password" menu entry that opened it users.js the per-row "Reset password" action users.js the password field in the create-account form - NewUserIn no users.html longer accepts one, so the form was posting a rejected field The self-row placeholder button pointed at a top-bar Password link that no longer exists; it is now a plain "you" marker. Verified: node --check passes on all four touched JS files; the only password references left in html/ are the sign-in form and the SMTP config in admin.js, which is unrelated and stays. Logged BL-027 rather than acted on: the Okta URL is the first sign of an Okta tenant on this estate, which means an OIDC flow is available in principle and would remove the domain-lockout hazard that forced AUTH_MAX_ATTEMPTS to 2. D13 was decided and reaffirmed and T10.1-T10.4 are built, so swapping the mechanism mid-wave is the reordering CLAUDE.md forbids. Recording is not reopening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
467 lines
25 KiB
JavaScript
467 lines
25 KiB
JavaScript
/* 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 don’t 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 person’s 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 person’s 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>');
|
||
// D13: no self-service action left on your own row — the domain owns the password
|
||
// and role changes are never self-applied.
|
||
if(me) actions.push('<span class="note" style="margin:0" title="Your own account">you</span>');
|
||
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 don’t 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 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 project’s 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 don’t 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 & 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();
|