Gate the suite behind a self-contained login (no external IdP): - User model with bcrypt-hashed passwords; admin/user roles - /api/auth endpoints: login, logout, me, change-password, and admin-only user management (list/create/delete/reset/enable) - Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure cookie; middleware refuses every /api data route without a session - login.html + auth-guard.js: login page and per-page guard with a top-right "name / Admin / Sign out" pill - Admin Console now gated on admin role (passphrase gate removed) with a User administration card - manage_users.py CLI to bootstrap the first admin - Rebuilt help.js into a searchable, multi-topic help center - Local-dev convenience: app serves html/ so the site + API share one origin under uvicorn (inactive in the prod container) - Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
18 KiB
JavaScript
265 lines
18 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='';
|
||
checkHealth();
|
||
loadUsers();
|
||
}
|
||
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);
|
||
}
|
||
|
||
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>';
|
||
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><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></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="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>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
|
||
'</tr></thead><tbody>'+rows+'</tbody></table>';
|
||
}
|
||
|
||
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 password=document.getElementById('nu-password').value;
|
||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
|
||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,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)));
|
||
}
|
||
|
||
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)));
|
||
}
|
||
|
||
// ── 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
|