Files
Project-SDE-WP-Suite/html/admin.js
Matt Mabrey a6c3fbfe50 T11.5: admin console Activity & usage card (CR-019)
New card in admin.html/admin.js, above the old per-browser Usage logs
card (which T11.6 retires next). Filters (date range, project, user,
tool) drive GET /api/usage/summary; two export buttons call
GET /api/usage/export (raw / sanitized) and save the CSV via a Blob,
same download pattern wp-usage.js already uses.

Access: the card lives inside admin.html, already gated admin-only
client-side by gateByRole() (unchanged) - a non-admin never sees the
card. The underlying API is gated server-side by require_user_manager
regardless (admin or project_super_user on >=1 project), independent
of and stricter than the client-side gate, so a non-admin request is
refused even if someone reached the endpoint directly.

Accessibility (C1): every control is a real <input>/<select>/<button>,
keyboard-operable. Status/export banners use the existing '.banner' /
'*-banner' id convention, which console-util.js's MutationObserver
already turns into aria-live (role=status, or role=alert on a '.bad'
banner) - no new announcement plumbing needed. No new CSS: reuses
.toolbar/.banner/.card/.row/.kv/.users, so nothing here adds a second
token source (the token rule).

Verified so far:
  - node --check html/admin.js: no syntax errors
  - every id admin.js's new code references exists in admin.html
    (scripted diff against the full getElementById/id= sets)
  - live-server check: GET /api/usage/summary with the exact
    (possibly-empty) query string _activityFilters() builds returns
    the {active_users, per_user_last_active, by_tool, event_count}
    shape renderActivity() expects; project_id filter narrows
    correctly; GET /api/usage/export?sanitize=true returns
    text/csv with the expected header row
  - full smoke test + seed_demo.py still pass

NOT yet verified: rendering at 390px/1440px with before/after
screenshots (CLAUDE.md verification step). This sandbox has no
headless-capable browser (no chromium/msedge on PATH) and the
playwright/chromium download is blocked by this environment's
network allowlist, so tests/cdp.py's harness can't run here. Deferred
to T11.7, same as wave 10's browser checks — flagging rather than
skipping silently.
2026-09-23 12:31:55 -07:00

813 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 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='';
checkHealth();
loadProjects();
loadDefaultMembers();
loadSettings();
loadNotifications();
loadComments();
loadAudit();
loadActivity();
loadUsage();
}
function showDenied(){
document.getElementById('admin-denied').style.display='';
}
// ── 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 = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; 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 = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
}
// ── 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('<span class="p">PASS</span> '+name); }
else { fail++; smLog('<span class="f">FAIL</span> '+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 ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
}
// ── 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 — 35 days (≈4080 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(!(await wpConfirmDialog({title:'Delete demo data',
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
okLabel:'Delete them'}))) 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();
}
// ── 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 cant 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();
populateActivityProjectFilter();
}
// The activity project filter reuses the same project list the Projects card
// already fetched — no second /api/projects call just to fill a <select>.
function populateActivityProjectFilter(){
const sel = document.getElementById('act-project');
if(!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All projects</option>' +
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
sel.value = cur;
}
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='<div class="note">No projects yet.</div>'; 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 = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
if(!list.length){
wrap.innerHTML = count + '<div class="note">Nothing matches'+
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
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 '<tr>'+
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
'<td>'+(arch
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
: '<span class="tag on">active</span>')+'</td>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
(arch?'Unarchive':'Archive')+'</button>'+
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML = count +
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
'work package on it for good. Archive first if there is any doubt.</div>';
}
// 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(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
message:'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(!(await wpConfirmDialog({title:'Delete project permanently',
message:'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.',
okLabel:'Delete permanently'}))) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
else wpAlertDialog({title:'Delete failed',
message:'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 cant 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();
}
// 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');
if(sel) sel.disabled = !cb.checked;
}
function renderDefaultMembers(){
const wrap=document.getElementById('defmem-table');
if(!wrap) return;
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const rows = _defMemUsers.map(u => {
const uid = jsq(u.id);
const uname = jsq(u.username);
const role = normRole(u.role);
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.
if(role === 'admin'){
return '<tr>'+who+
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+
' <span class="note">Administrators already reach every project.</span></td>'+
'</tr>';
}
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 '+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"'+
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
'</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+'\')">'+opts.join('')+'</select></td>'+
'</tr>';
}).join('');
wrap.innerHTML =
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>User</th><th>Email</th>'+
'<th title="What this account may do in the app">Account permissions</th>'+
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
'<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> 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
// 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 {
wpAlertDialog({title:'Change failed',
message:'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 = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; 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 = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').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 = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; 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 = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
rows.map(c => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
'<td>'+uesc(c.source||'—')+'</td>'+
'<td style="color:var(--muted)">'+where(c)+'</td>'+
'<td>'+uesc(c.text||'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── 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 = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; 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 = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; 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 = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
rows.map(e => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(e.entity_type||'')+'</td>'+
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
'<td style="color:var(--muted)">'+det(e)+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
// Server-side, per-user activity — distinct from the "Activity log" card above
// (that's AuditLog: business mutations) and from the "Usage logs" card below
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
// filterable by date/project/user/tool, and exportable raw or sanitized.
function _activityFilters(){
const q = new URLSearchParams();
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
return q;
}
async function loadActivity(){
const banner = document.getElementById('activity-banner');
const box = document.getElementById('activity-admin');
if(!box) return;
banner.style.display='none';
box.textContent = 'Loading…';
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
if(status===403){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Your account cant see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
box.innerHTML=''; return;
}
if(status!==200 || !json){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
box.innerHTML=''; return;
}
renderActivity(json);
}
function renderActivity(sum){
const box = document.getElementById('activity-admin');
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const bucket = (label, obj, take) => {
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
if(!entries.length) return '';
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
'</table>';
};
const perTool = Object.entries(sum.by_tool||{});
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
'<div class="note">No per-tool data.</div>';
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
'</tbody></table>' : '<div class="note">No per-user data.</div>';
box.innerHTML =
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
'</div>'+
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
}
async function exportActivity(sanitize){
const banner = document.getElementById('activity-export-banner');
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
const q = _activityFilters();
if(sanitize) q.set('sanitize', 'true');
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
if(status!==200 || typeof json !== 'string'){
banner.className='banner bad';
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
return;
}
const blob = new Blob([json], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
banner.className='banner ok';
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
}
// ── 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 = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; 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 =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">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.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
// Localization defaults. A user's own "Language & time" preference wins over
// these; these decide what everyone else sees instead of the browser's guess.
'<h2 style="margin-top:22px">Localization defaults</h2>'+
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
'set their own preference. Each user can override this from <strong>Language &amp; time</strong> in the '+
'top-right menu.</div>'+
'<div class="urow">'+
'<select id="set-locale" style="min-width:220px"></select>'+
'<select id="set-tz" style="min-width:240px"></select>'+
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
'</div>'+
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
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 =>
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').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 = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
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 =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
'</div>'+
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
'<div class="row">'+
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
'<button onclick="testEmail()">Send test email to me</button>'+
'<span id="set-msg" class="note" style="margin:0"></span>'+
'</div>';
}
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 = '<div class="note">No notifications yet.</div>'; 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 = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
json.map(n => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
'<td>'+uesc(n.email||n.user_id)+'</td>'+
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(n.subject||'')+'</td>'+
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
// operator-facing readout belongs - behind the same admin gate as this whole
// page (gateByRole() below shows nothing else either). Data comes from
// wp-usage.js, the single implementation; the keys predate the move, so
// everything recorded before it is still here.
function loadUsage(){
const box = document.getElementById('usage-admin');
if(!box) return;
const tools = [
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
];
let html = '';
tools.forEach(([label, key, prefix]) => {
const evs = (WPUsage.load(key).events) || [];
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
if(!evs.length){
html += '<div class="note">No usage recorded in this browser yet.</div>';
return;
}
const byEvent = {}, 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.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = v => v ? wpFormatDateTime(v) : '—';
html += '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
});
box.innerHTML = html;
}
// ── 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