The set mixed colour emoji with dingbats, and the same glyph read as two things partly BECAUSE emoji render as per-platform artwork. The system chosen: monochrome text-presentation glyphs - the suite is classic-script vanilla HTML with no bundler, so an SVG sprite or icon font is a new asset pipeline, while text glyphs render through the same font stack as the words beside them. The enforceable form of "renders identically on Windows, macOS and a tablet": no emoji-range codepoint and no U+FE0F selector anywhere in UI source, swept by the probe on every run. Converted: green-check/red-cross emoji in the admin and users consoles to checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the pencil already meaning "edit with a logged reason" on sign-offs, the star to the diamond, the folder to the reference marker, the side nav's lightning to the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per Unicode) to a text-presentation clock face. Dropped where the label already carried the meaning: lightning on Save & view, the camera on Add photo, the page/frame pictograms on file rows (the filename is the label). Stale help copy fixed while its emoji left: it still described the pre-T9.4 "Load sample" and the pre-T7.10 "Usage Logs" locations. The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per glyph, one glyph per meaning, both directions asserted from the document itself; the probe also sweeps every page for glyphs not in the approved set, so an unmapped icon cannot creep in. Verification (each probe run alone): NEW tests/icon_check.py 5/5. Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22, cards_check 44/44. Items: S6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
474 lines
25 KiB
JavaScript
474 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="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 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 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 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 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();
|