Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
138
html/admin.js
138
html/admin.js
@@ -11,7 +11,10 @@ function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadSettings();
|
||||
loadNotifications();
|
||||
loadComments();
|
||||
loadAudit();
|
||||
loadUsage();
|
||||
}
|
||||
function showDenied(){
|
||||
@@ -189,11 +192,20 @@ function renderUsers(list, meId){
|
||||
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,"\\'");
|
||||
const roleCell = me
|
||||
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>'
|
||||
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this user’s role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
||||
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+
|
||||
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</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><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
|
||||
'<td>'+roleCell+'</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">'+
|
||||
@@ -244,6 +256,18 @@ async function toggleActive(id, makeActive){
|
||||
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 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);
|
||||
@@ -335,6 +359,118 @@ function renderComments(){
|
||||
'</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();
|
||||
}
|
||||
function renderSettings(){
|
||||
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:[]}; } }
|
||||
|
||||
Reference in New Issue
Block a user