/* Admin console for the Work Package Suite. Browser-side diagnostics + tests that call the same /api on this host. 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. */ function reveal(){ document.getElementById('admin-main').style.display=''; fillProjectRoleOptions(); checkHealth(); loadUsers(); loadProjects(); loadDefaultMembers(); loadSettings(); loadNotifications(); loadComments(); loadAudit(); loadUsage(); } 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'); b.className='banner'; b.textContent='Checking…'; const { status, json } = await api('GET','/api/health'); if(status===200 && json && json.ok){ b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.'; } else if(status===404){ b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.'; } else if(status===0){ b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json; } else { b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status; } } // ── db snapshot ─────────────────────────────────────────────────────────────── async function snapshot(){ const out = document.getElementById('snapshot-out'); out.textContent='Loading…'; // archived=all: /api/projects now hides archived projects by default, and a row // count that silently drops them is not a snapshot of the database. const [p,s,w,c] = await Promise.all([ api('GET','/api/projects?archived=all'), api('GET','/api/sops'), api('GET','/api/wps'), api('GET','/api/comments')]); if(p.status!==200){ out.innerHTML = ``; return; } const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status); const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0; out.innerHTML = `
Projects${n(p)}${archived ? ` (${archived} archived)` : ''}
SOPs${n(s)}
Work Packages${n(w)}
Comments${n(c)}
`; } // ── smoke test ──────────────────────────────────────────────────────────────── function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; } async function runSmokeTest(){ const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null; const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('PASS '+name); } else { fail++; smLog('FAIL '+name+(detail?' ('+detail+')':'')); } return cond; }; try { let r = await api('GET','/api/health'); if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){ smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail); } r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'}); pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status); r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001'); r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid)); r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}}); const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid); r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid); r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}}); const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid); r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status); await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}}); r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status); chk('issued_at timestamp set', !!(r.json && r.json.issued_at)); r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress'); r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json)); r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id)); r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid)); // Archive round-trip: out of the default list, still there with archived=all, // frozen against writes, and all three undone by unarchiving. r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status); r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid)); r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid)); r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}}); chk('write to an archived project refused (409)', r.status===409, 'status '+r.status); r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status); } catch(e){ chk('unexpected error', false, String(e)); } finally { if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); } finishSmoke(pass,fail); } } function finishSmoke(pass,fail){ const total=pass+fail; smLog('\n'+pass+'/'+total+' checks passed.'); smLog(fail ? 'RESULT: FAIL ('+fail+')' : 'RESULT: ALL PASS — API, Python logic, and SQL are working.'); } // ── demo data ───────────────────────────────────────────────────────────────── function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; } function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)'] .map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); } async function seedDemo(){ const o=document.getElementById('demo-out'); o.innerHTML=''; let r = await api('GET','/api/health'); if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; } r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'}); if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; } const pid=r.json.id; demoLog('Project created: '+r.json.name); r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}}); const sid=r.json && r.json.id; demoLog('SOP created (complete).'); const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; }; await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'}); await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'}); const masterId='wp_demo_master_chiller'; const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']]; const kidIds=[]; for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id); await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}}); demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); } await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}}); demoLog(' WP WP03-CHILLER [master, split into A/B/C]'); await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'}); await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'}); r = await api('GET','/api/wps/metrics?project_id='+pid); demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json)); demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.'); snapshot(); } async function cleanDemo(){ if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return; const o=document.getElementById('demo-out'); o.innerHTML=''; // archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from // this button — the default list hides it and nothing else here can delete it. const r = await api('GET','/api/projects?archived=all'); if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; } const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||''))); if(!targets.length){ demoLog('Nothing to remove.'); return; } for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); } demoLog('\n✅ Removed '+targets.length+' project(s).'); snapshot(); } // ── user administration ──────────────────────────────────────────────────────── function uesc(v){ return v==null ? '' : String(v).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=''+ PROJECT_ROLES.map(r=>'').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 'all projects'; } const n = _userProjectCounts[u.id]; const label = (n === undefined) ? 'Projects…' : (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's')); return ''; } // 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='
No users yet.
'; 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 ? '' : ''; const delBtn = me ? '' : ''; // 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 ? ''+uesc(PERM_LABELS[role]||role)+'locked' : ''; // PROJECT role — the person's job function. Descriptive only; grants nothing. const pr = u.project_role || ''; const projRoleCell = ''; 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)+'
'+ ''+(active?'active':'disabled')+''+ ''+fmt(u.last_login_at)+''+ '
'+ ''+ disableBtn+delBtn+ '
'+ ''; }).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='
'+ ''+ ''+ ''+ ''+ ''+ ''+rows+'
UsernameNameEmailPermissionsProject roleProject accessStatusLast loginActions
'+ '
Permissions — '+ 'Administrator: manages users, settings and every project. '+ 'Project Admin: on their assigned projects, may delete work packages, '+ 'change a completed SOP, and delete the project. '+ 'Project User: creates and edits work packages and authors the SOP, '+ 'but cannot delete WPs or change the SOP once it\'s complete. '+ 'Project role is the person\'s job function — it feeds the SOP '+ 'team pickers and notification routing, and grants nothing on its own.
'; } 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 = ''; return '
'+ ''+ sel + '
'; }).join('') : '
No projects exist yet.
'; 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 = '
'+ '
Project access & permissions — '+uesc(username)+'
'+ '
'+ (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 User cannot. Leave it on Same as account to use their Permissions setting.
')+ '
'+items+'
'+ '
'+ '
'+ ''+ (isAdmin ? '' : '')+ '
'+ '
'; 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 // suite and is frozen read-only; nothing is deleted. That makes this card the ONLY // place an archived project is still visible, so it asks for archived=all and does // the hiding itself — otherwise an admin could never find one to unarchive. let _adminProjects = []; async function loadProjects(){ const banner=document.getElementById('projects-banner'); const wrap=document.getElementById('projects-table'); if(!banner || !wrap) return; banner.className='banner'; banner.textContent='Loading…'; banner.style.display=''; const { status, json } = await api('GET','/api/projects?archived=all'); if(status===403){ banner.className='banner bad'; banner.textContent='❌ Your account is not an admin, so you can’t archive or delete projects here.'; 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 projects (HTTP '+status+').'; wrap.innerHTML=''; return; } banner.style.display='none'; _adminProjects = json; renderProjects(); } function renderProjects(){ const wrap=document.getElementById('projects-table'); if(!wrap) return; const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked; const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase(); const total = _adminProjects.length; if(!total){ wrap.innerHTML='
No projects yet.
'; return; } const list = _adminProjects.filter(p => { if(!showArchived && p.archived) return false; if(!q) return true; return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0; }); const count = '
'+list.length+' of '+total+' project'+(total===1?'':'s')+ (showArchived ? '' : ' · archived hidden')+'
'; if(!list.length){ wrap.innerHTML = count + '
Nothing matches'+ (showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.
'; return; } const fmt = s => s ? wpFormatDateTime(s) : '—'; const rows = list.map(p => { // Project names are free text written by whoever created the job — jsq(), not // uesc(), is what makes them safe to bind into the handlers below. const pid = jsq(p.id); const pname = jsq(p.name||'(unnamed)'); const arch = !!p.archived; return ''+ ''+uesc(p.name||'(unnamed)')+''+ (p.number ? ' '+uesc(p.number)+'' : '')+''+ ''+uesc(p.client||'—')+''+ ''+uesc(p.site||'—')+''+ ''+fmt(p.created_at)+''+ ''+(arch ? 'archived' : 'active')+''+ '
'+ ''+ ''+ '
'+ ''; }).join(''); wrap.innerHTML = count + '
'+ ''+ ''+rows+'
ProjectClientSiteCreatedStatusActions
'+ '
Delete is not archive: it removes the project, its SOP and every '+ 'work package on it for good. Archive first if there is any doubt.
'; } // Both directions are explained in full before anything happens: archiving makes a // project vanish for everyone else in the company, and there is no undo prompt on // the other side of that. async function archiveProject(id, name, archived){ const ask = archived ? 'Archive “'+name+'”?\n\n'+ '• It disappears from every project picker, switcher and search across the suite.\n'+ '• It becomes read-only — nobody can add or change its SOP or work packages.\n'+ '• Nothing is deleted. Unarchive here at any time to bring it back.' : 'Unarchive “'+name+'”?\n\n'+ 'It becomes visible in the pickers again and can be edited as normal.'; if(!confirm(ask)) return; const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived}); if(status===200) loadProjects(); else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))); } // Named deleteProjectAdmin, not deleteProject: every function in this file is a // global shared with the other scripts the page loads, and "deleteProject" is broad // enough to collide with one of them later. The -Admin suffix also says which of the // two project deletions this is — the console's, not a project member's. async function deleteProjectAdmin(id, name){ if(!confirm('DELETE “'+name+'” permanently?\n\n'+ 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+ '(database cascade). This cannot be undone.\n\n'+ 'If you only want it out of the way, cancel and use Archive instead.')) return; const { status, json } = await api('DELETE','/api/projects/'+id); if(status===200) loadProjects(); else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))); } // ── default members on new projects ───────────────────────────────────────────── // A rule about the FUTURE: flagged users are auto-added to every project created // from now on. It is not a bulk assignment — existing projects are untouched, which // is what the note under the table is there to say. let _defMemUsers = []; async function loadDefaultMembers(){ const banner=document.getElementById('defmem-banner'); const wrap=document.getElementById('defmem-table'); if(!banner || !wrap) return; 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 change who is added to new projects.'; 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'; _defMemUsers = json; renderDefaultMembers(); } // Same idea as projRowToggled() — the role only matters while the tick is on — but // here the select sits in a sibling , so the lookup is scoped to the row. function defMemToggled(cb){ const row = cb.closest('tr'); const sel = row && row.querySelector('select'); if(sel) sel.disabled = !cb.checked; } function renderDefaultMembers(){ const wrap=document.getElementById('defmem-table'); if(!wrap) return; if(!_defMemUsers.length){ wrap.innerHTML='
No users yet.
'; return; } const rows = _defMemUsers.map(u => { const uid = jsq(u.id); const uname = jsq(u.username); const role = normRole(u.role); const who = ''+uesc(u.username)+''+ (u.full_name ? ' '+uesc(u.full_name)+'' : '')+''+ ''+uesc(u.email||'—')+''; // Admins reach every project already, so there is nothing to add them to — // the same thing projAccessCell() says in the user table. if(role === 'admin'){ return ''+who+ ''+uesc(PERM_LABELS.admin)+''+ 'all projects'+ ' Administrators already reach every project.'+ ''; } const on = !!u.auto_add_projects; const cur = u.auto_add_role || ''; return ''+who+ ''+uesc(PERM_LABELS[role]||role)+''+ ''+ ''+ ''; }).join(''); wrap.innerHTML = '
'+ ''+ ''+ ''+ ''+ ''+rows+'
UserEmailAccount permissionsAdd to new projectsRole on those projects
'+ '
This only affects projects created from now on — existing projects '+ 'are untouched. Use Project access in the user table above to add someone to a project '+ 'that already exists.
'; } // Saves on every tick and every dropdown change — there is no Save button, so a // failure must not leave a control showing something the server never accepted. // On success we swap in the row the server returned (it clears the role whenever // the flag is off); on failure we reload so the controls snap back to the truth. async function setAutoAdd(id, username){ const cb = document.getElementById('defmem-cb-'+id); if(!cb) return; const sel = document.getElementById('defmem-role-'+id); const auto_add = !!cb.checked; const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add', { auto_add, role: auto_add ? ((sel && sel.value) || '') : '' }); if(status===200 && json && json.id){ _defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u); renderDefaultMembers(); } else { alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))); loadDefaultMembers(); } } // ── all feedback / comments ───────────────────────────────────────────────────── let _comments = []; async function loadComments(){ const box = document.getElementById('comments-admin'); box.textContent = 'Loading…'; const { status, json } = await api('GET','/api/comments'); if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; } _comments = json; const sel = document.getElementById('cmt-filter'); const cur = sel.value; const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort(); sel.innerHTML = '' + sources.map(s=>'').join(''); sel.value = cur; renderComments(); } function renderComments(){ const box = document.getElementById('comments-admin'); const src = document.getElementById('cmt-filter').value; const q = (document.getElementById('cmt-search').value||'').toLowerCase(); let rows = _comments.filter(c => (!src || c.source===src) && (!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0)); if(!rows.length){ box.innerHTML = '
No comments'+((src||q)?' match the filter.':' yet.')+'
'; return; } rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||''))); const fmt = s => s ? wpFormatDateTime(s) : '—'; const where = c => { const bits = []; if(c.page) bits.push(uesc(c.page)); if(c.step!=null) bits.push('step '+c.step); if(c.sop_id) bits.push('SOP '+uesc(c.sop_id)); if(c.wp_id) bits.push('WP '+uesc(c.wp_id)); return bits.join(' · ') || '—'; }; box.innerHTML = ''+ rows.map(c => ''+ ''+ ''+ ''+ ''+ ''+ '').join('')+'
WhenWhoSourceWhereComment
'+fmt(c.created_at)+''+uesc(c.author||'Anonymous')+''+uesc(c.source||'—')+''+where(c)+''+uesc(c.text||'')+'
'; } // ── activity log (audit trail) ────────────────────────────────────────────────── let _audit = []; async function loadAudit(){ const box = document.getElementById('audit-admin'); box.textContent = 'Loading…'; const { status, json } = await api('GET','/api/audit?limit=500'); if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; } _audit = json; renderAudit(); } function renderAudit(){ const box = document.getElementById('audit-admin'); const type = document.getElementById('audit-type').value; const q = (document.getElementById('audit-search').value||'').toLowerCase(); let rows = _audit.filter(e => (!type || e.entity_type===type) && (!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0)); if(!rows.length){ box.innerHTML = '
No activity'+((type||q)?' matches the filter.':' yet.')+'
'; return; } const fmt = s => s ? wpFormatDateTime(s) : '—'; const det = e => { const d = e.detail || {}; if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', ')); }; box.innerHTML = ''+ rows.map(e => ''+ ''+ ''+ ''+ ''+ ''+ ''+ '').join('')+'
WhenWhoActionTypeItemDetail
'+fmt(e.at)+''+uesc(e.actor||'—')+''+uesc((e.action||'').replace(/_/g,' '))+''+uesc(e.entity_type||'')+''+uesc(e.summary||e.entity_id||'')+''+det(e)+'
'; } // ── notifications / email settings ────────────────────────────────────────────── let _settings = {}; async function loadSettings(){ const box = document.getElementById('settings-box'); const { status, json } = await api('GET','/api/settings'); if(status!==200 || !json){ box.innerHTML = ''; return; } _settings = json; renderSettings(); } // Feature flags live in the same settings record but get their own card — they're // not email, and they change what every project sees. function renderFeatures(){ const s = _settings, box = document.getElementById('features-box'); if(!box) return; const bim = !!s.bim_enabled; box.innerHTML = ''+ '
When OFF, the SOP creator hides the BIM/VDC section entirely and '+ 'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+ 'stops being shown or offered, so no project can be put on the BIM path while it\'s off.
'+ '
'+ // Localization defaults. A user's own "Language & time" preference wins over // these; these decide what everyone else sees instead of the browser's guess. '

Localization defaults

'+ '
How dates, times and numbers are written for users who haven\'t '+ 'set their own preference. Each user can override this from Language & time in the '+ 'top-right menu.
'+ '
'+ ''+ ''+ ''+ ''+ '
'+ '
'; fillLocalization(); } // Locale shortlist mirrors wp-format.js so the admin default and the per-user // preference offer the same choices. const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'], ['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'], ['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']]; const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix', 'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore', 'Europe/Dublin','Europe/London','UTC']; function fillLocalization(){ const s = _settings; const loc = document.getElementById('set-locale'); const tz = document.getElementById('set-tz'); if(!loc || !tz) return; const curL = s.default_locale || '', curZ = s.default_timezone || ''; loc.innerHTML = L10N_LOCALES.map(p => '').join(''); if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true)); let browserZone = ''; try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){} tz.innerHTML = ''+ L10N_ZONES.map(z => '').join('')+ (curZ && L10N_ZONES.indexOf(curZ)<0 ? '' : ''); const preview = () => { const el = document.getElementById('l10n-preview'); if(!el) return; let out; try { out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric', hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date()); } catch(e){ out = 'not supported by this browser'; } el.textContent = 'Preview — right now reads: ' + out; }; loc.onchange = preview; tz.onchange = preview; preview(); // Offer the server's full zone list once it arrives (it validates against the // same list, so anything offered here will be accepted). api('GET','/api/timezones').then(({status,json}) => { if(status!==200 || !Array.isArray(json) || !json.length) return; const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0); if(!rest.length) return; const g = document.createElement('optgroup'); g.label = 'All time zones'; rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ))); tz.appendChild(g); if(curZ) tz.value = curZ; }); } async function saveLocalization(){ const msg = document.getElementById('l10n-msg'); const patch = { default_locale: document.getElementById('set-locale').value, default_timezone: document.getElementById('set-tz').value, }; msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; const { status, json } = await api('PUT','/api/settings', patch); if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('l10n-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } } else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; } } async function saveFeatures(){ const el = document.getElementById('set-bim'); const msg = document.getElementById('features-msg'); if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; } const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) }); if(status===200){ _settings = json; renderFeatures(); const m = document.getElementById('features-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } } else if(msg){ msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; } } function renderSettings(){ renderFeatures(); const s = _settings, box = document.getElementById('settings-box'); const on = !!s.email_enabled; const pwOk = !!s.smtp_password_set; box.innerHTML = ''+ '
'+ ''+ ''+ ''+ '
'+ '
'+ ''+ ''+ ''+ '
'+ '
'+ ''+ '
'+ '
SMTP password: '+(pwOk?'set via SMTP_PASSWORD env ✓':'not set — add SMTP_PASSWORD to the environment before enabling')+'
'+ '
'+ ''+ ''+ ''+ '
'; } async function saveSettings(){ const v = id => document.getElementById(id); const patch = { email_enabled: v('set-enabled').checked, smtp_host: v('set-host').value.trim(), smtp_port: parseInt(v('set-port').value, 10) || 587, smtp_use_tls: v('set-tls').checked, from_addr: v('set-from').value.trim(), from_name: v('set-fromname').value.trim(), smtp_username: v('set-user').value.trim(), app_base_url: v('set-baseurl').value.trim(), }; const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; const { status, json } = await api('PUT','/api/settings', patch); if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } } else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; } } async function testEmail(){ const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)'; const { status, json } = await api('POST','/api/settings/test-email', {}); if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; } else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; } } async function loadNotifications(){ const box = document.getElementById('notif-box'); if(!box) return; const { status, json } = await api('GET','/api/notifications?all=1&limit=50'); if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; } if(!json.length){ box.innerHTML = '
No notifications yet.
'; return; } const fmt = s => s ? wpFormatDateTime(s) : '—'; const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)'; box.innerHTML = '
Recent notifications
'+ ''+ json.map(n => ''+ ''+ ''+ ''+ ''+ ''+ '').join('')+'
WhenToKindSubjectStatus
'+fmt(n.created_at)+''+uesc(n.email||n.user_id)+''+uesc((n.kind||'').replace(/_/g,' '))+''+uesc(n.subject||'')+''+uesc(n.status)+(n.error?' ':'')+'
'; } // ── usage logs (read from this browser's localStorage) ────────────────────────── const USAGE_KEY = 'wp_suite_analytics_v1'; function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } } function loadUsage(){ const box = document.getElementById('usage-admin'); const evs = (usageLoad().events) || []; if(!evs.length){ box.innerHTML = '
No usage recorded in this browser yet.
'; return; } const byEvent = {}, byStep = {}, sessions = new Set(); let first = evs[0].ts, last = evs[0].ts; evs.forEach(e => { byEvent[e.event] = (byEvent[e.event]||0)+1; if(e.session) sessions.add(e.session); if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1; if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts; }); const fmt = s => s ? wpFormatDateTime(s) : '—'; let html = ''+ ''+ ''+ '
Sessions'+sessions.size+'
Events'+evs.length+'
Range'+fmt(first)+' → '+fmt(last)+'
'; html += '

Step views

'; for(let i=1;i<=10;i++) html += ''; html += '
StepViews
Step '+i+''+(byStep[i]||0)+'
'; html += '

Actions

'; Object.keys(byEvent).sort().forEach(k => html += ''); html += '
EventCount
'+uesc(k)+''+byEvent[k]+'
'; box.innerHTML = html; } function downloadUsage(){ const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000); } // ── access control: admins only ───────────────────────────────────────────────── // auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready'). // Show the console for admins; otherwise show the "Admins only" notice. let _adminGated = false; function gateByRole(){ if(_adminGated) return; const u = window.WP_USER; if(!u) return; // not resolved yet — wait for wp-auth-ready _adminGated = true; if(u.role === 'admin') reveal(); else showDenied(); } document.addEventListener('wp-auth-ready', gateByRole); gateByRole(); // in case WP_USER was already set before this ran