/* 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 Administrator: you manage every account in the suite. '+ 'App settings, diagnostics and the default-member rules live in the '+ 'Admin Console.'; 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 Project Super User 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 ? ': '+names.map(uesc).join(', ')+'' : '')+'. '+ '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 = '
Nobody to show yet.
'; 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 = '
'+list.length+' of '+total+' '+(total===1?'person':'people')+'
'; if(!list.length){ wrap.innerHTML = count+'
Nothing matches.
'; 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) ? ''+uesc(h[0])+'' : ''+uesc(h)+'').join(''); const rows = list.map(manages() ? managerRow : readonlyRow).join(''); wrap.innerHTML = count+'
'+ths+ ''+rows+'
'+legend(); } function legend(){ if(!manages()){ return '
Project role is the person’s job '+ 'function — it feeds the SOP team pickers and notification routing, and grants nothing on its own.
'; } return '
Permissions — '+ PERM_ROLES.map(r => ''+uesc(PERM_LABELS[r])+': '+uesc(PERM_HELP[r])).join(' ')+ ' Project role is the person’s job function — it feeds the SOP team pickers '+ 'and notification routing, and grants nothing on its own.
'; } // 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 ''+ ''+uesc(u.full_name || u.username)+''+(u.id===_meId?'you':'')+''+ ''+uesc(u.username)+''+ ''+ (u.email ? ''+uesc(u.email)+'' : '—')+''+ ''+uesc(roleLabel(u.role))+''+ ''+uesc(u.project_role || '—')+''+ ''+(u.is_active?'active':'disabled')+''+ ''; } 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 ? ''+uesc(roleLabel(role))+'locked' : !can ? ''+uesc(roleLabel(role))+'' : 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(''); if(can && !me) actions.push(''); if(can && !me) actions.push(''); if(me) actions.push(''); if(!can && !me) actions.push('read-only'); 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)+'
'+ ''+(u.is_active?'active':'disabled')+''+ ''+fmt(u.last_login_at)+''+ '
'+actions.join('')+'
'+ ''; } // 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 ''; } function projRoleSelect(uid, uname, pr){ const list = (_scope && _scope.project_roles) || PROJECT_ROLES; return ''; } function projRoleReadonly(u, can, why){ return ''+uesc(u.project_role || '—')+''; } // 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 'all projects'; } 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 ''+uesc(label)+''; } return ''; } // ── 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 => '').join(''); const prSel = document.getElementById('nu-project-role'); prSel.innerHTML = ''+ (_scope.project_roles||PROJECT_ROLES).map(r => '').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 Project access 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 * — pick at least one'; const live = projects.filter(p => !p.archived); const list = document.getElementById('nu-project-list'); if(!projects.length){ list.innerHTML = '
You don’t administer any project yet.
'; } 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 => '
').join('') || '
Every project you administer is archived.
'; } } 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 = [''] .concat(PROJECT_SCOPED_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === cur).map(r => '')); return '
'+ ''+ ''+ '
'; }).join('') : '
No projects to choose from.
'; const others = data.other_projects || 0; const intro = 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 Super User can also manage that project’s user accounts; '+ 'Project User can do neither. Leave it on Same as account to use their '+ 'Permissions setting.
'+ (others ? '' : ''); const modal = document.createElement('div'); modal.id = 'proj-modal'; modal.className = 'modal-ov'; modal.innerHTML = ''; 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();