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:
363
html/admin.js
363
html/admin.js
@@ -4,14 +4,20 @@
|
||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||
already requires a login (redirecting to login.html otherwise) and publishes
|
||||
window.WP_USER; here we show the console only when that user is an admin, and
|
||||
show an "Admins only" notice otherwise. Every user-management API is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||
show an "Admins only" notice otherwise. Every API this page calls is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation.
|
||||
|
||||
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
|
||||
User role arrived: administering users is no longer an admin-only act, so the
|
||||
page that does it can't be behind an admins-only gate. What stays here is what
|
||||
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
|
||||
project archiving, and the default-member rule for future projects.
|
||||
|
||||
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||
|
||||
function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
fillProjectRoleOptions();
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadProjects();
|
||||
loadDefaultMembers();
|
||||
loadSettings();
|
||||
@@ -24,18 +30,6 @@ function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||
async function checkHealth(){
|
||||
const b = document.getElementById('health-banner');
|
||||
@@ -165,317 +159,6 @@ async function cleanDemo(){
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// ── user administration ────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||
// survives, and the browser decodes the entities before the JS parser runs.
|
||||
//
|
||||
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||
// the quote closes the literal, and everything after it runs as code. Project names
|
||||
// and full names are free text that any signed-in user can write, so that is a real
|
||||
// path from a project_user to whatever an admin's session can do. Use jsq() for
|
||||
// EVERY value that lands inside an inline handler.
|
||||
function jsq(v){
|
||||
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||
}
|
||||
|
||||
async function currentUserId(){
|
||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||
const { status, json } = await api('GET','/api/auth/me');
|
||||
return (status===200 && json && json.user) ? json.user.id : null;
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const banner=document.getElementById('users-banner');
|
||||
const wrap=document.getElementById('users-table');
|
||||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
renderUsers(json, meId);
|
||||
// Fill in the project-access counts, then repaint that column.
|
||||
await loadProjectCounts(json);
|
||||
renderUsers(json, meId);
|
||||
}
|
||||
|
||||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||||
const PERM_ROLES = ['admin','project_admin','project_user'];
|
||||
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
|
||||
// Job functions on a project. Descriptive only — no permissions attached.
|
||||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||||
'Field Technician'];
|
||||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||||
|
||||
function fillProjectRoleOptions(){
|
||||
const sel=document.getElementById('nu-project-role'); if(!sel) return;
|
||||
sel.innerHTML='<option value="">Project role…</option>'+
|
||||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||
}
|
||||
|
||||
// Per-user project access gets its own column: it was buried among the action
|
||||
// buttons, which is exactly where you'd fail to find "which projects can this
|
||||
// person see, and what may they do there".
|
||||
let _userProjectCounts = {}; // user id -> number of assigned projects
|
||||
|
||||
function projAccessCell(u){
|
||||
const uname = jsq(u.username);
|
||||
if(normRole(u.role) === 'admin'){
|
||||
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||
}
|
||||
const n = _userProjectCounts[u.id];
|
||||
const label = (n === undefined) ? 'Projects…'
|
||||
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
|
||||
return '<button class="mini' + (n === 0 ? ' danger' : '') +
|
||||
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
|
||||
' title="Choose which projects this user can access, and their role on each">' +
|
||||
label + '</button>';
|
||||
}
|
||||
|
||||
// A project's role dropdown only matters while that project is ticked.
|
||||
function projRowToggled(cb){
|
||||
const row = cb.closest('div');
|
||||
const sel = row && row.querySelector('select');
|
||||
if(sel) sel.disabled = !cb.checked;
|
||||
}
|
||||
|
||||
// Counts for that column. One call per user, but only for non-admins and only on a
|
||||
// refresh — the admin console is not a hot path.
|
||||
async function loadProjectCounts(list){
|
||||
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
|
||||
await Promise.all(targets.map(async u => {
|
||||
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
|
||||
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
const disableBtn = me
|
||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">Delete</button>';
|
||||
// Role can be changed at any time via an inline dropdown. Your own row is
|
||||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||||
const escUname = jsq(u.username);
|
||||
const escUid = jsq(u.id);
|
||||
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
||||
// a tag) so an admin can't accidentally demote themselves.
|
||||
const role = normRole(u.role);
|
||||
const roleCell = me
|
||||
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
||||
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
PERM_ROLES.map(function(r){
|
||||
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
||||
}).join('')+
|
||||
'</select>';
|
||||
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
||||
const pr = u.project_role || '';
|
||||
const projRoleCell =
|
||||
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||
PROJECT_ROLES.map(function(r){
|
||||
return '<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 && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||||
'</select>';
|
||||
return '<tr>'+
|
||||
'<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 '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td><div class="cellactions">'+
|
||||
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
|
||||
// rather than forcing every cell to wrap. The explanatory note stays outside it.
|
||||
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
|
||||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||||
'<th title="What this account may do in the app">Permissions</th>'+
|
||||
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
||||
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
|
||||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||
'<em>Administrator</em>: manages users, settings and every project. '+
|
||||
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
||||
'change a completed SOP, and delete the project. '+
|
||||
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
|
||||
'but cannot delete WPs or change the SOP once it\'s complete. '+
|
||||
'<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>';
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg=document.getElementById('users-create-msg');
|
||||
const username=document.getElementById('nu-username').value.trim();
|
||||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||
const email=document.getElementById('nu-email').value.trim();
|
||||
const role=document.getElementById('nu-role').value;
|
||||
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
|
||||
const password=document.getElementById('nu-password').value;
|
||||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
|
||||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
|
||||
if(status===200){
|
||||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.style.color='var(--red)';
|
||||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPw(id, username){
|
||||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||
if(pw===null) return;
|
||||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status===200) alert('Password reset for '+username+'.');
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
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('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// Change a user's role (user ↔ admin) at any time. The server enforces the same
|
||||
// admin-only rule as every other user-management call, and refuses to remove the
|
||||
// last admin. On any failure we reload so the dropdown snaps back to the truth.
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
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){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// ── project access assignment ───────────────────────────────────────────────────
|
||||
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 (HTTP '+status+').'); return; }
|
||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
|
||||
}
|
||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||
roles = roles || {};
|
||||
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||
// inherits the account's Permissions, so the common case needs no thought.
|
||||
// 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.
|
||||
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||
const items = projects.length ? projects.map(p => {
|
||||
const on = assigned.has(p.id);
|
||||
const cur = roles[p.id] || '';
|
||||
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
|
||||
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select>';
|
||||
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
|
||||
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||
' onchange="projRowToggled(this)">'+
|
||||
'<span style="overflow:hidden;text-overflow:ellipsis;"><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>'+ sel +
|
||||
'</div>';
|
||||
}).join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||
modal.innerHTML =
|
||||
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access & permissions — '+uesc(username)+'</div>'+
|
||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||
(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 User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
|
||||
'<div id="proj-list">'+items+'</div>'+
|
||||
'</div>'+
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||
'<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 } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
|
||||
if(status===200){ closeProjectModal(); loadUsers(); }
|
||||
else alert('Save failed (HTTP '+status+').');
|
||||
};
|
||||
}
|
||||
|
||||
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||
// Archiving is the answer to "this job is over but I can't throw the data away".
|
||||
// An archived project disappears from every picker, switcher and search in the
|
||||
@@ -615,8 +298,8 @@ async function loadDefaultMembers(){
|
||||
renderDefaultMembers();
|
||||
}
|
||||
|
||||
// Same idea as projRowToggled() — the role only matters while the tick is on — but
|
||||
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
|
||||
// The role only matters while the tick is on. The select sits in a sibling <td>, so
|
||||
// the lookup is scoped to the row.
|
||||
function defMemToggled(cb){
|
||||
const row = cb.closest('tr');
|
||||
const sel = row && row.querySelector('select');
|
||||
@@ -634,8 +317,7 @@ function renderDefaultMembers(){
|
||||
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
||||
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
|
||||
// Admins reach every project already, so there is nothing to add them to —
|
||||
// the same thing projAccessCell() says in the user table.
|
||||
// Admins reach every project already, so there is nothing to add them to.
|
||||
if(role === 'admin'){
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||
@@ -645,8 +327,15 @@ function renderDefaultMembers(){
|
||||
}
|
||||
const on = !!u.auto_add_projects;
|
||||
const cur = u.auto_add_role || '';
|
||||
// Every project-scoped role is offered, super user included: this card is
|
||||
// admin-only, and "the QA lead runs the users on every new job" is exactly the
|
||||
// sort of standing rule it exists to express.
|
||||
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
|
||||
uesc(PERM_LABELS[role]||role)+')</option>']
|
||||
.concat(PROJECT_SCOPED_ROLES.map(r =>
|
||||
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><label class="chk">'+
|
||||
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
||||
' title="Add this user to every project created from now on"'+
|
||||
@@ -654,11 +343,7 @@ function renderDefaultMembers(){
|
||||
'</label></td>'+
|
||||
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
||||
' title="The role this user gets on those projects"'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select></td>'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
wrap.innerHTML =
|
||||
@@ -669,8 +354,8 @@ function renderDefaultMembers(){
|
||||
'<th title="Their role on those projects">Role on those projects</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
||||
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
|
||||
'that already exists.</div>';
|
||||
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
|
||||
'Directory</a> to add someone to a project that already exists.</div>';
|
||||
}
|
||||
|
||||
// Saves on every tick and every dropdown change — there is no Save button, so a
|
||||
|
||||
Reference in New Issue
Block a user