Acts on the site comments from 8/3 plus the follow-ups. Foundation work first — four of the comments all needed the project team to resolve to real user accounts. Permissions vs project role (new) - User.role is now the PERMISSIONS role: admin | project_admin | project_user. project_admin may delete work packages, change a SOP after it is complete, and delete a project; project_user may not (archiving a WP is still open to them). Enforced by require_project_admin() server-side; the UI only hides dead ends. - New User.project_role holds the person's JOB FUNCTION on the project. It grants nothing — it feeds the SOP team pickers and notification routing. - Admin console shows both columns and explains the difference. Migration rewrites the legacy role 'user' to 'project_user'. - Deleting a project was previously open to any member and unaudited; it now needs project_admin and writes an audit event. ProjectData.remove no longer drops the project from the local cache when the server refuses. SOP project team from user accounts - PM/APM/CM/QM and additional team members are pickers over the project's members, storing the account id next to the display name. A name from an older SOP with no matching account is kept and flagged rather than dropped. - The WP Creator lists the SOP team first in the Owner picker, and a new package defaults to whoever is creating it. Critical constraints - SOP constraints carry a Critical flag; buildConstraints() now copies the whole definition through to the package (it previously reduced them to names, losing description too), and critical rows are marked in the WP form. The email on reopen-after-release is wave 3. Password reset by email - login.html gains Forgot password and a set-a-new-password view, offered only when the server reports email is actually configured. - Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version, sent immediately rather than through the notifications outbox so a reset link is never persisted. Identical response for unknown accounts; per-account send cooldown; a completed reset clears any login lockout. - Session and reset tokens are no longer interchangeable. BIM kill-switch - New admin Features card with bim_enabled, OFF by default. The SOP creator hides the BIM section and the Creator treats every package as install-only while it is off; a SOP that already has BIM keeps its data untouched. Verified with two throwaway-database test scripts: 44 checks on the permissions matrix and token handling, 22 on the reset flow end-to-end against a local SMTP sink (real message captured, link extracted and used). Front-end files parse-checked in headless Chrome. Not yet exercised in a browser against a real login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
609 lines
38 KiB
JavaScript
609 lines
38 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 user-management API is also
|
||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||
|
||
function reveal(){
|
||
document.getElementById('admin-main').style.display='';
|
||
fillProjectRoleOptions();
|
||
checkHealth();
|
||
loadUsers();
|
||
loadSettings();
|
||
loadNotifications();
|
||
loadComments();
|
||
loadAudit();
|
||
loadUsage();
|
||
}
|
||
function showDenied(){
|
||
document.getElementById('admin-denied').style.display='';
|
||
}
|
||
|
||
// ── api helper ──────────────────────────────────────────────────────────────
|
||
async function api(method, path, body){
|
||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||
try {
|
||
const r = await fetch(path, opt);
|
||
const t = await r.text();
|
||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||
return { status:r.status, json };
|
||
} catch(e){ return { status:0, json:String(e) }; }
|
||
}
|
||
|
||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||
async function checkHealth(){
|
||
const b = document.getElementById('health-banner');
|
||
b.className='banner'; b.textContent='Checking…';
|
||
const { status, json } = await api('GET','/api/health');
|
||
if(status===200 && json && json.ok){
|
||
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
||
} else if(status===404){
|
||
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||
} else if(status===0){
|
||
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
||
} else {
|
||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||
}
|
||
}
|
||
|
||
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||
async function snapshot(){
|
||
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||
const [p,s,w,c] = await Promise.all([
|
||
api('GET','/api/projects'), 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);
|
||
out.innerHTML = `<table class="kv">
|
||
<tr><th>Projects</th><td>${n(p)}</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));
|
||
} 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(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||
const r = await api('GET','/api/projects');
|
||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||
snapshot();
|
||
}
|
||
|
||
// ── user administration ────────────────────────────────────────────────────────
|
||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
async function currentUserId(){
|
||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||
const { status, json } = await api('GET','/api/auth/me');
|
||
return (status===200 && json && json.user) ? json.user.id : null;
|
||
}
|
||
|
||
async function loadUsers(){
|
||
const banner=document.getElementById('users-banner');
|
||
const wrap=document.getElementById('users-table');
|
||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||
const { status, json } = await api('GET','/api/auth/users');
|
||
if(status===403){
|
||
banner.className='banner bad';
|
||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||
wrap.innerHTML=''; return;
|
||
}
|
||
if(status===401){
|
||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||
}
|
||
if(status!==200 || !Array.isArray(json)){
|
||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||
}
|
||
banner.style.display='none';
|
||
const meId = await currentUserId();
|
||
renderUsers(json, meId);
|
||
}
|
||
|
||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||
const PERM_ROLES = ['admin','project_admin','project_user'];
|
||
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
|
||
// Job functions on a project. Descriptive only — no permissions attached.
|
||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||
'Field Technician'];
|
||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||
|
||
function fillProjectRoleOptions(){
|
||
const sel=document.getElementById('nu-project-role'); if(!sel) return;
|
||
sel.innerHTML='<option value="">Project role…</option>'+
|
||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||
}
|
||
|
||
function renderUsers(list, meId){
|
||
const wrap=document.getElementById('users-table');
|
||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||
let rows = list.map(u=>{
|
||
const me = u.id===meId;
|
||
const active = u.is_active;
|
||
const disableBtn = me
|
||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||
const delBtn = me
|
||
? ''
|
||
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
||
// Role can be changed at any time via an inline dropdown. Your own row is
|
||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||
const escUname = uesc(u.username).replace(/'/g,"\\'");
|
||
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
||
// a tag) so an admin can't accidentally demote themselves.
|
||
const role = normRole(u.role);
|
||
const roleCell = me
|
||
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
||
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
||
PERM_ROLES.map(function(r){
|
||
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
||
}).join('')+
|
||
'</select>';
|
||
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
||
const pr = u.project_role || '';
|
||
const projRoleCell =
|
||
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||
PROJECT_ROLES.map(function(r){
|
||
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
|
||
}).join('')+
|
||
// Keep a title that isn't on the list (set via the API or an older record).
|
||
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||
'</select>';
|
||
return '<tr>'+
|
||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||
'<td>'+uesc(u.email||'')+'</td>'+
|
||
'<td>'+roleCell+'</td>'+
|
||
'<td>'+projRoleCell+'</td>'+
|
||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
|
||
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
||
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
||
disableBtn+delBtn+
|
||
'</div></td>'+
|
||
'</tr>';
|
||
}).join('');
|
||
wrap.innerHTML='<table class="users"><thead><tr>'+
|
||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||
'<th title="What this account may do in the app">Permissions</th>'+
|
||
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||
'</tr></thead><tbody>'+rows+'</tbody></table>'+
|
||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||
'<em>Administrator</em>: manages users, settings and every project. '+
|
||
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
||
'change a completed SOP, and delete the project. '+
|
||
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
|
||
'but cannot delete WPs or change the SOP once it\'s complete. '+
|
||
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
|
||
'team pickers and notification routing, and grants nothing on its own.</div>';
|
||
}
|
||
|
||
async function createUser(){
|
||
const msg=document.getElementById('users-create-msg');
|
||
const username=document.getElementById('nu-username').value.trim();
|
||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||
const email=document.getElementById('nu-email').value.trim();
|
||
const role=document.getElementById('nu-role').value;
|
||
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
|
||
const password=document.getElementById('nu-password').value;
|
||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
|
||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
|
||
if(status===200){
|
||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||
loadUsers();
|
||
} else {
|
||
msg.style.color='var(--red)';
|
||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||
}
|
||
}
|
||
|
||
async function resetPw(id, username){
|
||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||
if(pw===null) return;
|
||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||
if(status===200) alert('Password reset for '+username+'.');
|
||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||
}
|
||
|
||
async function toggleActive(id, makeActive){
|
||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||
if(status===200) loadUsers();
|
||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||
}
|
||
|
||
// Change a user's role (user ↔ admin) at any time. The server enforces the same
|
||
// admin-only rule as every other user-management call, and refuses to remove the
|
||
// last admin. On any failure we reload so the dropdown snaps back to the truth.
|
||
async function changeRole(id, role, username){
|
||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||
if(status===200){ loadUsers(); }
|
||
else {
|
||
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||
loadUsers();
|
||
}
|
||
}
|
||
async function changeProjectRole(id, project_role, username){
|
||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||
if(status===200){ loadUsers(); }
|
||
else {
|
||
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||
loadUsers();
|
||
}
|
||
}
|
||
|
||
async function deleteUser(id, username){
|
||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||
if(status===200) loadUsers();
|
||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||
}
|
||
|
||
// ── project access assignment ───────────────────────────────────────────────────
|
||
async function manageProjects(id, username){
|
||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
||
}
|
||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||
function openProjectModal(userId, username, projects, assigned, userObj){
|
||
closeProjectModal();
|
||
const isAdmin = userObj && userObj.role==='admin';
|
||
const items = projects.length ? projects.map(p =>
|
||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
||
const modal = document.createElement('div');
|
||
modal.id = 'proj-modal';
|
||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||
modal.innerHTML =
|
||
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
||
'<div id="proj-list">'+items+'</div>'+
|
||
'</div>'+
|
||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||
'</div>'+
|
||
'</div>';
|
||
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||
document.body.appendChild(modal);
|
||
const saveBtn = document.getElementById('proj-save');
|
||
if(saveBtn) saveBtn.onclick = async () => {
|
||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
||
if(status===200) closeProjectModal();
|
||
else alert('Save failed (HTTP '+status+').');
|
||
};
|
||
}
|
||
|
||
// ── 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 ? new Date(s).toLocaleString() : '—';
|
||
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 ? new Date(s).toLocaleString() : '—';
|
||
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>';
|
||
}
|
||
|
||
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 ? new Date(s).toLocaleString() : '—';
|
||
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) ──────────────────────────
|
||
const USAGE_KEY = 'wp_suite_analytics_v1';
|
||
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
|
||
function loadUsage(){
|
||
const box = document.getElementById('usage-admin');
|
||
const evs = (usageLoad().events) || [];
|
||
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
|
||
const byEvent = {}, byStep = {}, sessions = new Set();
|
||
let first = evs[0].ts, last = evs[0].ts;
|
||
evs.forEach(e => {
|
||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||
if(e.session) sessions.add(e.session);
|
||
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||
});
|
||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||
let 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 += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
|
||
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
|
||
html += '</tbody></table>';
|
||
html += '<h2 style="margin-top:16px">Actions</h2><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>';
|
||
box.innerHTML = html;
|
||
}
|
||
function downloadUsage(){
|
||
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
|
||
// ── access control: admins only ─────────────────────────────────────────────────
|
||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||
// Show the console for admins; otherwise show the "Admins only" notice.
|
||
let _adminGated = false;
|
||
function gateByRole(){
|
||
if(_adminGated) return;
|
||
const u = window.WP_USER;
|
||
if(!u) return; // not resolved yet — wait for wp-auth-ready
|
||
_adminGated = true;
|
||
if(u.role === 'admin') reveal();
|
||
else showDenied();
|
||
}
|
||
document.addEventListener('wp-auth-ready', gateByRole);
|
||
gateByRole(); // in case WP_USER was already set before this ran
|