S1 counted 79 native dialogs app-wide and its tasks removed 58; the audit found the rest on surfaces no S1 task named: admin.js (6), users.js (10), the launcher's inline script (5). All 21 now go through wp-dialog.js - the T7.9 kit extracted as a self-injecting shared component: markup and styles land on first use, styles are theme tokens only with its own wp-dlg-* class names (the consoles' existing .modal styles are untouched), 44px targets on coarse pointers, and the whole file is guarded so the creator's inline copy - which owns the same-id markup in its HTML - still wins on its own page. The kit's toast comes along (S10 role rules), since none of the three pages had one. Conversion follows the T7.9 precedent: confirms -> wpConfirmDialog with named ok-labels, the password prompt -> wpPromptDialog whose validate() finally enforces min-12 AT the input (it was label-text-only before, server-enforced), API failures with detail -> wpAlertDialog, small info/validation messages -> the announced toast. New probe console_dialogs_check (17): counts pinned at 0, kit guarded and loaded by all three pages, and the users console driven live with natives poisoned - reset a password end to end (short refused inline, good one accepted by the server and announced), cancel a delete and prove nothing died. Items: BL-024 (closed), S1 completed to zero app-wide, C1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
715 lines
43 KiB
JavaScript
715 lines
43 KiB
JavaScript
/* 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();
|
||
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 — 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(!(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 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='<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 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();
|
||
}
|
||
|
||
// 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>';
|
||
}
|
||
|
||
// ── 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 & 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
|