Merge feat/secure-login-portal: secure login portal
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ wpsuite.db
|
||||
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
# Local server logs
|
||||
*.log
|
||||
|
||||
@@ -27,6 +27,9 @@ services:
|
||||
POSTGRES_HOST: db
|
||||
# Optional full-URL override (must be URL-encoded if used).
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
# Signs login session cookies. MUST be set (see server/.env.example).
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
|
||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Console — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<style>
|
||||
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
|
||||
@@ -42,19 +43,29 @@
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
||||
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
|
||||
border-radius:6px; background:#fff; color:var(--text); }
|
||||
.urow input{ flex:1; min-width:130px; }
|
||||
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
|
||||
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
|
||||
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
table.users tr:last-child td{ border-bottom:none; }
|
||||
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
|
||||
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
button.mini{ padding:4px 9px; font-size:12px; }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- GATE -->
|
||||
<div class="gate-overlay" id="admin-gate">
|
||||
<div class="gate-box">
|
||||
<h2>🔒 Admin Console</h2>
|
||||
<p>Enter the admin passphrase to continue.</p>
|
||||
<input type="password" id="gate-input" placeholder="Passphrase" autocomplete="off"
|
||||
onkeydown="if(event.key==='Enter') tryUnlock()">
|
||||
<div class="gate-msg" id="gate-msg"></div>
|
||||
<button class="primary" style="width:100%" onclick="tryUnlock()">Unlock</button>
|
||||
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||
<div class="wrap" id="admin-denied" style="display:none">
|
||||
<div class="card">
|
||||
<h2>Admins only</h2>
|
||||
<p class="sub" style="margin:0 0 12px">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
||||
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,11 +73,9 @@
|
||||
<div class="wrap" id="admin-main" style="display:none">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a> <button onclick="lock()">Lock</button></div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||
</div>
|
||||
|
||||
<div class="secwarn">⚠ This page is gated client-side only — that stops casual access, not a determined user. For real protection, restrict this host/route at the network or reverse-proxy layer.</div>
|
||||
|
||||
<!-- CONNECTIVITY -->
|
||||
<div class="card">
|
||||
<h2>API connectivity</h2>
|
||||
@@ -74,6 +83,26 @@
|
||||
<div class="banner" id="health-banner">—</div>
|
||||
</div>
|
||||
|
||||
<!-- USER ADMINISTRATION -->
|
||||
<div class="card">
|
||||
<h2>User administration</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table" style="margin-top:12px"></div>
|
||||
|
||||
<h2 style="margin-top:22px">Add a user</h2>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
</div>
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
</div>
|
||||
|
||||
<!-- DB SNAPSHOT -->
|
||||
<div class="card">
|
||||
<h2>Database snapshot</h2>
|
||||
|
||||
160
html/admin.js
160
html/admin.js
@@ -1,41 +1,20 @@
|
||||
/* Admin console for the Work Package Suite.
|
||||
Browser-side diagnostics + tests that call the same /api on this host.
|
||||
|
||||
PASSPHRASE GATE (lightweight / obfuscation only):
|
||||
The gate compares a SHA-256 hash so the passphrase isn't in the source, but a
|
||||
determined user can still bypass client-side JS. For real protection, restrict
|
||||
this host/route at the network or reverse-proxy layer.
|
||||
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. */
|
||||
|
||||
Default passphrase: "prime-admin"
|
||||
To change it: compute a new hash and replace ADMIN_PASSPHRASE_SHA256 below —
|
||||
python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "your-new-passphrase"
|
||||
or in a browser console:
|
||||
crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-new-passphrase'))
|
||||
.then(b=>console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')));
|
||||
*/
|
||||
const ADMIN_PASSPHRASE_SHA256 = 'ae1fb92c43fccbad26f05434a194f574ec98a2197e0ff4080f84e6e26a8dd00f';
|
||||
|
||||
// ── gate ──────────────────────────────────────────────────────────────────────
|
||||
async function sha256hex(s){
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
||||
return [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
async function tryUnlock(){
|
||||
const v = document.getElementById('gate-input').value || '';
|
||||
const msg = document.getElementById('gate-msg');
|
||||
if(!v){ msg.textContent='Enter the passphrase.'; return; }
|
||||
let h;
|
||||
try { h = await sha256hex(v); }
|
||||
catch(e){ msg.textContent='This page must be served over HTTPS (or localhost) to unlock.'; return; }
|
||||
if(h === ADMIN_PASSPHRASE_SHA256){ sessionStorage.setItem('wp_admin_ok','1'); reveal(); }
|
||||
else { msg.textContent='Incorrect passphrase.'; }
|
||||
}
|
||||
function reveal(){
|
||||
document.getElementById('admin-gate').style.display='none';
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
}
|
||||
function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
@@ -165,6 +144,121 @@ async function cleanDemo(){
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// reveal immediately if already unlocked this session
|
||||
if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); }
|
||||
else { const i=document.getElementById('gate-input'); if(i) i.focus(); }
|
||||
// ── 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
|
||||
|
||||
90
html/auth-guard.js
Normal file
90
html/auth-guard.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/* Auth guard for the Work Package Suite.
|
||||
Included in the <head> of every protected page (before other scripts). It
|
||||
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
||||
the user to the login page. The real protection is server-side (the API
|
||||
refuses data requests without a session) — this guard is for UX so people
|
||||
land on the login screen instead of an empty app.
|
||||
|
||||
It also exposes:
|
||||
window.WP_USER the logged-in user object (set once verified)
|
||||
window.wpLogout() clears the session and returns to the login page
|
||||
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
|
||||
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
||||
// before a redirect. A safety timer reveals it even if the check hangs.
|
||||
var root = document.documentElement;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
||||
(document.head || root).appendChild(style);
|
||||
root.className += ' wp-auth-pending';
|
||||
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
||||
var safety = setTimeout(reveal, 4000);
|
||||
|
||||
function goToLogin() {
|
||||
clearTimeout(safety);
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
var url = 'login.html?next=' + next;
|
||||
// If we're inside the WP-creator iframe, redirect the whole window.
|
||||
var w = inIframe ? window.top : window;
|
||||
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
||||
}
|
||||
|
||||
window.wpLogout = function () {
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.catch(function () {})
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
function addLogoutPill(user) {
|
||||
if (inIframe) return; // the parent page already shows it
|
||||
if (document.getElementById('wp-logout-pill')) return;
|
||||
var pill = document.createElement('div');
|
||||
pill.id = 'wp-logout-pill';
|
||||
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
||||
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
|
||||
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
|
||||
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
|
||||
|
||||
var who = document.createElement('span');
|
||||
who.textContent = user.full_name || user.username;
|
||||
pill.appendChild(who);
|
||||
|
||||
// Admins get a link to the Admin Console (hidden when already on it).
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (user.role === 'admin' && !onAdmin) {
|
||||
var adm = document.createElement('a');
|
||||
adm.href = 'admin.html'; adm.textContent = 'Admin';
|
||||
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||
pill.appendChild(sep()); pill.appendChild(adm);
|
||||
}
|
||||
|
||||
var out = document.createElement('a');
|
||||
out.href = '#'; out.textContent = 'Sign out';
|
||||
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
|
||||
pill.appendChild(sep()); pill.appendChild(out);
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
|
||||
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||
return r.json().then(function (data) {
|
||||
clearTimeout(safety);
|
||||
window.WP_USER = data && data.user;
|
||||
reveal();
|
||||
if (window.WP_USER) {
|
||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||
if (document.body) addLogoutPill(window.WP_USER);
|
||||
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () { goToLogin(); }); // API unreachable → send to login
|
||||
})();
|
||||
484
html/help.js
484
html/help.js
@@ -1,11 +1,18 @@
|
||||
/* Shared Help + tooltip module for the Work Package Suite.
|
||||
/* Shared Help center + tooltip module for the Work Package Suite.
|
||||
Included by the home page, the suite, and the embedded creator. It injects:
|
||||
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||
- a Help modal (workflow + key concepts) opened via window.openHelp()
|
||||
Add a "❔ Help" button anywhere with onclick="openHelp()". */
|
||||
- a searchable, multi-topic Help center modal opened via window.openHelp()
|
||||
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||
|
||||
API (unchanged + extended):
|
||||
openHelp() open the help center
|
||||
openHelp('topicId') open and jump to a topic (e.g. openHelp('constraints'))
|
||||
closeHelp() close it
|
||||
Add a Help button anywhere with onclick="openHelp()". */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────
|
||||
var css = `
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
|
||||
@@ -18,63 +25,450 @@
|
||||
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
||||
|
||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:flex-start;
|
||||
justify-content:center; z-index:10000; padding:5vh 16px; overflow:auto; }
|
||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||
.ui-help-overlay.open{ display:flex; }
|
||||
.ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px;
|
||||
box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||
.ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px;
|
||||
border-bottom:1px solid #e3e6ec; font-size:16px; }
|
||||
.ui-help-head button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; }
|
||||
.ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; }
|
||||
.ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; }
|
||||
.ui-help-body h4:first-child{ margin-top:0; }
|
||||
.ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; }
|
||||
.ui-help-body li{ margin-bottom:5px; }
|
||||
.ui-help-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
`;
|
||||
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
|
||||
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
|
||||
font-size:13px; outline:none; background:#f7f8fa; }
|
||||
.ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
|
||||
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
|
||||
cursor:pointer; margin-bottom:1px; }
|
||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||
.ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
|
||||
.ui-help-nav a.nohit{ display:none; }
|
||||
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||
.ui-help-sec{ margin-bottom:30px; }
|
||||
.ui-help-sec.hide{ display:none; }
|
||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
|
||||
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
|
||||
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||
.ui-help-content li{ margin-bottom:5px; }
|
||||
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||
.ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
|
||||
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
|
||||
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
|
||||
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
|
||||
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
|
||||
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
|
||||
box-shadow:0 2px 10px rgba(20,30,50,.28); }
|
||||
.ui-help-fab:hover{ background:#1d4ed8; }
|
||||
@media (max-width:760px){
|
||||
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
|
||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||
.ui-help-head{ flex-wrap:wrap; }
|
||||
}`;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
var HELP_HTML = `
|
||||
<h4>How the suite works</h4>
|
||||
<ol>
|
||||
<li><strong>Pick or create a Project</strong> on the home page — projects are stored centrally and each keeps its own SOP and Work Packages.</li>
|
||||
<li><strong>SOP Configuration</strong> — set the project baseline (team, sign-offs, WP types, governance & sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.</li>
|
||||
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP. Use <strong>New</strong> for a blank one or <strong>Duplicate</strong> to copy an existing one.</li>
|
||||
<li><strong>Dashboard</strong> — track status, hours, and what's gating each package across the project.</li>
|
||||
</ol>
|
||||
// ── content ─────────────────────────────────────────────────────────────────
|
||||
// Each topic: { id, title, body(HTML) }. Order here is the nav order.
|
||||
var TOPICS = [
|
||||
{ id: 'overview', title: 'Getting started', body: `
|
||||
<h3>Getting started</h3>
|
||||
<p>The Work Package Suite turns a project's standard procedure into release-ready <strong>Installation Work Packages (IWPs)</strong>. You work in three stages, always in the same order:</p>
|
||||
<ol>
|
||||
<li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
|
||||
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance & sizing, quality, platforms, sequence, constraints, sources). Every Work Package inherits these defaults. The Creator stays locked until the SOP is marked complete.</li>
|
||||
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP, clear their constraints, and issue them to the field.</li>
|
||||
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||
</ol>
|
||||
<h4>Moving around</h4>
|
||||
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
|
||||
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
|
||||
|
||||
<h4>Key concepts</h4>
|
||||
<ul>
|
||||
<li><strong>Constraints & release readiness:</strong> a package can't move to <em>Issued</em> until every constraint is <em>Cleared</em> or <em>N/A</em>. If a constraint reopens after release, the package drops to <em>Issue (Hold)</em>.</li>
|
||||
<li><strong>Disciplines & Split:</strong> a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. <strong>Split by Discipline</strong> breaks it into instances — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> — each tied to the master.</li>
|
||||
<li><strong>WP size:</strong> the SOP sets a typical size band, which sets a max-hours <em>split threshold</em>. The creator warns when a package's estimated hours exceed it so it can be broken down.</li>
|
||||
<li><strong>Material by discipline:</strong> on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.</li>
|
||||
</ul>
|
||||
{ id: 'projects', title: 'Projects', body: `
|
||||
<h3>Projects</h3>
|
||||
<p>A <strong>project</strong> is the top-level container — every SOP and Work Package belongs to one. Create or select projects on the home page.</p>
|
||||
<h4>Project fields</h4>
|
||||
<ul>
|
||||
<li><strong>Project Name</strong> (required)</li>
|
||||
<li><strong>Project Number</strong></li>
|
||||
<li><strong>Client</strong></li>
|
||||
<li><strong>Division / Sector</strong></li>
|
||||
<li><strong>Site / Location</strong></li>
|
||||
</ul>
|
||||
<h4>The active project</h4>
|
||||
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
|
||||
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
|
||||
|
||||
<h4>Tips</h4>
|
||||
<ul>
|
||||
<li><strong>Load Sample</strong> is context-aware — it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.</li>
|
||||
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li>
|
||||
<li>Hover the <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icons for inline hints.</li>
|
||||
</ul>`;
|
||||
{ id: 'sop', title: 'SOP Configuration', body: `
|
||||
<h3>SOP Configuration (10 steps)</h3>
|
||||
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP Complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||
<ol>
|
||||
<li><strong>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
|
||||
<li><strong>Project Team Leadership</strong> — PM, APM, CM, QM, plus any additional members (<em>+ Add Team Member</em>).</li>
|
||||
<li><strong>Required Sign-Off Roles</strong> — Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with <em>+ Add Role</em>.</li>
|
||||
<li><strong>Work Package Types</strong> — enable the install types this project uses (Conduit Install, Wire Pull, Terminations, …). Enabled types populate the WP type picker.</li>
|
||||
<li><strong>Governance & WP Numbering</strong> — the WP <strong>number format</strong> (e.g. <code>WP##-[Sector]-[TYPE]</code>), issuance strategy, the project's <strong>disciplines</strong>, the <strong>discipline strategy</strong>, and <strong>WP sizing</strong> (see <a data-help-jump="sizing">Sizing</a> and <a data-help-jump="disciplines">Disciplines</a>).</li>
|
||||
<li><strong>Quality & Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
|
||||
<li><strong>Tracking & Commissioning Platforms</strong> — e.g. CxAlloy, Procore, ACC.</li>
|
||||
<li><strong>Construction Sequence</strong> — the install flow; reorder by dragging (⠿), edit labels, add <em>◆ QC Hold</em> gates or custom steps. These feed the WP "predecessor" picker.</li>
|
||||
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||
</ol>
|
||||
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
|
||||
|
||||
{ id: 'wps', title: 'Work Packages', body: `
|
||||
<h3>Creating Work Packages</h3>
|
||||
<p>In the Creator, start a package with <strong>+ New</strong> (blank, auto-numbered) or <strong>⧉ Duplicate</strong> (copies a saved package and increments the number). The <strong>WP Number</strong> is built automatically from the SOP number format plus your scope fields, the WP type, and a counter — it's read-only.</p>
|
||||
<h4>Key fields</h4>
|
||||
<ul>
|
||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||
<li><strong>Material List</strong> — the bill of materials; import from CSV/Excel or add lines manually.</li>
|
||||
<li><strong>Drawings & Attachments</strong> — documents and SOP source-folder links.</li>
|
||||
<li><strong>Kitting & Material Movement (MIMO)</strong> — kitting status, warehouse owner, move date/location.</li>
|
||||
<li><strong>Constraints</strong> — the release gate (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||
</ul>
|
||||
<h4>Saving</h4>
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||
|
||||
{ id: 'statuses', title: 'Statuses', body: `
|
||||
<h3>Work Package statuses</h3>
|
||||
<table>
|
||||
<tr><th>Status</th><th>Meaning</th></tr>
|
||||
<tr><td><span class="ui-help-pill pill-draft">Draft</span></td><td>Work in progress; not yet released.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-sched">Scheduled</span></td><td>Planned and scheduled; upcoming.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-issued">Issued</span></td><td>Released to the field. Requires <em>all constraints Cleared or N/A</em>.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-prog">In Progress</span></td><td>Actively being worked.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-qc">QC</span></td><td>In quality check / inspection.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-closed">Closed</span></td><td>Completed.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-hold">Issue (Hold)</span></td><td>A constraint reopened after release — work is paused until it's resolved.</td></tr>
|
||||
</table>
|
||||
<div class="ui-help-callout">A package <strong>cannot move to Issued</strong> while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to <strong>Issue (Hold)</strong> and the suite makes you log what happened.</div>
|
||||
<p>On a multi-discipline package, each discipline carries its own status and the overall status <strong>rolls up to the least-advanced discipline</strong> — so a package is never "Closed" while one trade still lags.</p>` },
|
||||
|
||||
{ id: 'constraints', title: 'Constraints & release', body: `
|
||||
<h3>Constraints & release readiness</h3>
|
||||
<p>Constraints are the readiness checklist that gates a package's release to the field. They follow Advanced Work Packaging (AWP Vol II §2.3.2). The standard set:</p>
|
||||
<ol>
|
||||
<li>Safety & Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings & Specs</li>
|
||||
<li>Schedule</li><li>Materials (on site, bagged & tagged)</li><li>Prefabrication</li>
|
||||
<li>Work Access & Laydown</li><li>Craft Availability</li><li>Construction Equipment & Tools</li>
|
||||
<li>Scaffolding / Access Equipment</li>
|
||||
</ol>
|
||||
<p>Pick which apply (and add custom ones) in <strong>SOP Step 9</strong>. Each constraint on a package has one of three states:</p>
|
||||
<table>
|
||||
<tr><th>State</th><th>Effect</th></tr>
|
||||
<tr><td><strong>Open</strong></td><td>Not yet cleared — <em>blocks release</em>.</td></tr>
|
||||
<tr><td><strong>Cleared</strong></td><td>Requirement met — counts toward release-ready.</td></tr>
|
||||
<tr><td><strong>N/A</strong></td><td>Not applicable to this package — counts as cleared.</td></tr>
|
||||
</table>
|
||||
<h4>The release gate</h4>
|
||||
<ul>
|
||||
<li>A package is <strong>release-ready</strong> when every constraint is Cleared or N/A. The sticky banner shows green when ready, amber when constraints are still open, and red when on hold.</li>
|
||||
<li>When the last open constraint clears, the suite offers to mark the package <strong>Issued</strong>.</li>
|
||||
<li>If a constraint reopens after the package is Issued, you log the hold (what reopened, details, optional doc link & photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'disciplines', title: 'Disciplines & Split', body: `
|
||||
<h3>Disciplines & Split by Discipline</h3>
|
||||
<p>Disciplines are trades (Mechanical, Electrical, Tech, …) set in <strong>SOP Step 5</strong>. The <strong>discipline strategy</strong> controls how packages handle them:</p>
|
||||
<ul>
|
||||
<li><strong>Let the planner choose per package</strong> (recommended) — pick one discipline (flat scope) or several (per-discipline scope + the <em>Split</em> option).</li>
|
||||
<li><strong>One discipline per package</strong> — each WP is single-discipline.</li>
|
||||
<li><strong>Multiple disciplines per package</strong> — scope is always split by discipline.</li>
|
||||
</ul>
|
||||
<h4>Split by Discipline</h4>
|
||||
<p>When a package covers 2+ disciplines, the <strong>⎘ Split by Discipline</strong> button breaks it into one numbered instance per discipline — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> (or <code>_MECH</code>/<code>_ELEC</code> suffixes, set in the SOP). The original is kept as a <strong>master / roll-up</strong>; each instance:</p>
|
||||
<ul>
|
||||
<li>becomes its own single-discipline package, issued independently;</li>
|
||||
<li>receives only the <strong>scope steps</strong> and <strong>materials tagged to that discipline</strong>;</li>
|
||||
<li>stays linked back to the master.</li>
|
||||
</ul>
|
||||
<div class="ui-help-callout">Tag material rows to a discipline <em>before</em> splitting. <strong>Untagged rows stay on the master only</strong> and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.</div>` },
|
||||
|
||||
{ id: 'sizing', title: 'Sizing', body: `
|
||||
<h3>Work Package sizing</h3>
|
||||
<p>In <strong>SOP Step 5</strong> you set a typical WP <strong>size band</strong>, which sets a <strong>split threshold</strong> (max labor hours):</p>
|
||||
<table>
|
||||
<tr><th>Size band</th><th>Split threshold</th></tr>
|
||||
<tr><td>Small — 1–2 days (≈8–24 hrs)</td><td>24 hrs</td></tr>
|
||||
<tr><td>Standard — 3–5 days (≈40–80 hrs)</td><td>80 hrs</td></tr>
|
||||
<tr><td>Large — 1–2 weeks (≈80–160 hrs)</td><td>160 hrs</td></tr>
|
||||
<tr><td>Custom…</td><td>you set it</td></tr>
|
||||
</table>
|
||||
<p>In the Creator, the <strong>Est. Hrs.</strong> field is checked live against the threshold. Within range you see the target band; over it you get an amber warning — <em>"⚠ … exceeds the …-hr split threshold — consider breaking this package down"</em> — and a nudge to split by discipline where that applies. It's a guide, not a hard block: you can proceed if it's intentional.</p>` },
|
||||
|
||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||
<h3>Dashboard & metrics</h3>
|
||||
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
|
||||
<h4>Metric cards (click to filter)</h4>
|
||||
<ul>
|
||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||
<li><strong>Est. hrs</strong> and <strong>Actual hrs</strong> (summed)</li>
|
||||
</ul>
|
||||
<h4>Breakdowns & gates</h4>
|
||||
<ul>
|
||||
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
||||
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||
</ul>
|
||||
<h4>The table</h4>
|
||||
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
|
||||
<div class="ui-help-callout">Split <strong>masters</strong> are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.</div>` },
|
||||
|
||||
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||
<h3>Samples, import / export & comments</h3>
|
||||
<h4>Load Sample</h4>
|
||||
<p><strong>⭐ Load Sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||
<h4>Import / Export</h4>
|
||||
<ul>
|
||||
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
||||
<li><strong>SOP</strong> — the Creator can import a SOP <code>.json</code> (via <em>⤒ Import SOP</em>) or load the sample SOP.</li>
|
||||
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||
</ul>
|
||||
<h4>Comments & feedback</h4>
|
||||
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||
<h4>Usage logs</h4>
|
||||
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||
|
||||
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||
<h3>Tips & keyboard shortcuts</h3>
|
||||
<ul>
|
||||
<li><strong>Enter</strong> in a sequence, constraint, or material input adds/saves that row.</li>
|
||||
<li><strong>Esc</strong> closes any modal — this help center, comments, the constraint library, and the hold-log dialog.</li>
|
||||
<li>Hover any <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icon for an inline hint.</li>
|
||||
<li>Data is kept <strong>per project</strong> — switch projects from the home page.</li>
|
||||
<li>Your work <strong>auto-saves</strong> to the browser as you type; <em>Save & View</em> produces the print-ready output.</li>
|
||||
<li>Click a metric card or status chip on the <strong>Dashboard</strong> to filter the table.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'glossary', title: 'Glossary', body: `
|
||||
<h3>Glossary</h3>
|
||||
<table>
|
||||
<tr><th>Term</th><th>Meaning</th></tr>
|
||||
<tr><td><strong>IWP</strong></td><td>Installation Work Package — the field-level package this tool produces.</td></tr>
|
||||
<tr><td><strong>AWP</strong></td><td>Advanced Work Packaging — the methodology behind the constraint set and release gate.</td></tr>
|
||||
<tr><td><strong>SOP</strong></td><td>Standard Operating Procedure — the project baseline every WP inherits.</td></tr>
|
||||
<tr><td><strong>Constraint</strong></td><td>A readiness item (Open / Cleared / N/A) that gates release.</td></tr>
|
||||
<tr><td><strong>Release-ready</strong></td><td>All constraints Cleared or N/A — the package can be Issued.</td></tr>
|
||||
<tr><td><strong>Issued</strong></td><td>Released to the field.</td></tr>
|
||||
<tr><td><strong>Issue (Hold)</strong></td><td>A released package paused because a constraint reopened.</td></tr>
|
||||
<tr><td><strong>Discipline</strong></td><td>A trade (Mechanical, Electrical, Tech, …).</td></tr>
|
||||
<tr><td><strong>Split / Master / Instance</strong></td><td>Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).</td></tr>
|
||||
<tr><td><strong>Scope</strong></td><td>The sequenced steps the crew performs.</td></tr>
|
||||
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||
</table>` },
|
||||
|
||||
{ id: 'faq', title: 'FAQ', body: `
|
||||
<h3>Frequently asked questions</h3>
|
||||
<h4>The Work Package Creator is locked — why?</h4>
|
||||
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP Complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||
<h4>Why can't I set a package to Issued?</h4>
|
||||
<p>At least one constraint is still <strong>Open</strong>. Clear or mark N/A every constraint — the release banner turns green — and the suite will offer to issue it.</p>
|
||||
<h4>My package's materials didn't all carry over when I split it.</h4>
|
||||
<p>Only material rows <strong>tagged to a discipline</strong> are routed to that instance. Untagged rows stay on the master. Tag them before splitting.</p>
|
||||
<h4>Why don't split masters show in the dashboard totals?</h4>
|
||||
<p>Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.</p>
|
||||
<h4>Will I lose my work if I close the browser?</h4>
|
||||
<p>No — drafts auto-save locally per project and reload next time. Use <em>Export (JSON)</em> for a backup or to share with a teammate.</p>
|
||||
<h4>Does each project keep its own data?</h4>
|
||||
<p>Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.</p>
|
||||
<h4>How do I report a problem or suggestion?</h4>
|
||||
<p>Use the feedback / comments features (home page, SOP <em>Step Comments</em>, or the Creator's <em>Comments</em> drawer).</p>` }
|
||||
];
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
function buildModal() {
|
||||
if (document.getElementById('ui-help-overlay')) return;
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'ui-help-overlay';
|
||||
overlay.id = 'ui-help-overlay';
|
||||
overlay.innerHTML = '<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help">' +
|
||||
'<div class="ui-help-head"><strong>❔ Help — Work Package Suite</strong>' +
|
||||
'<button type="button" onclick="closeHelp()" aria-label="Close help">✕</button></div>' +
|
||||
'<div class="ui-help-body">' + HELP_HTML + '</div></div>';
|
||||
|
||||
var nav = TOPICS.map(function (t) {
|
||||
return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
|
||||
}).join('');
|
||||
|
||||
var sections = TOPICS.map(function (t) {
|
||||
return '<section class="ui-help-sec" id="ui-help-sec-' + t.id + '">' + t.body + '</section>';
|
||||
}).join('');
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help center">' +
|
||||
'<div class="ui-help-head">' +
|
||||
'<span class="ui-help-title">Help — Work Package Suite</span>' +
|
||||
'<span class="ui-help-search"><input id="ui-help-q" type="search" placeholder="Search help…" aria-label="Search help"></span>' +
|
||||
'<button type="button" class="ui-help-x" onclick="closeHelp()" aria-label="Close help">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="ui-help-wrap">' +
|
||||
'<nav class="ui-help-nav" id="ui-help-nav">' + nav + '</nav>' +
|
||||
'<div class="ui-help-content" id="ui-help-content">' +
|
||||
'<p class="ui-help-noresult" id="ui-help-noresult">No matches. Try another word.</p>' +
|
||||
sections +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// nav clicks + in-content cross-links jump to a section
|
||||
overlay.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-help-target],[data-help-jump]');
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
jumpTo(t.getAttribute('data-help-target') || t.getAttribute('data-help-jump'));
|
||||
});
|
||||
|
||||
// search
|
||||
var q = overlay.querySelector('#ui-help-q');
|
||||
q.addEventListener('input', function () { runSearch(q.value); });
|
||||
|
||||
// highlight nav as you scroll
|
||||
var content = overlay.querySelector('#ui-help-content');
|
||||
content.addEventListener('scroll', syncActiveNav, { passive: true });
|
||||
}
|
||||
|
||||
global.openHelp = function () { buildModal(); document.getElementById('ui-help-overlay').classList.add('open'); };
|
||||
global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); };
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); });
|
||||
function jumpTo(id) {
|
||||
var sec = document.getElementById('ui-help-sec-' + id);
|
||||
if (!sec) return;
|
||||
// Clear any active search filter so the target is visible.
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (q && q.value) { q.value = ''; runSearch(''); }
|
||||
sec.scrollIntoView({ block: 'start' });
|
||||
setActiveNav(id);
|
||||
}
|
||||
|
||||
function setActiveNav(id) {
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
if (!nav) return;
|
||||
nav.querySelectorAll('a').forEach(function (a) {
|
||||
a.classList.toggle('active', a.getAttribute('data-help-target') === id);
|
||||
});
|
||||
}
|
||||
|
||||
function syncActiveNav() {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
if (!content) return;
|
||||
var top = content.scrollTop, best = null, bestDist = Infinity;
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
if (!sec || sec.classList.contains('hide')) return;
|
||||
var d = Math.abs(sec.offsetTop - top);
|
||||
if (sec.offsetTop - top <= 40 && d < bestDist) { bestDist = d; best = t.id; }
|
||||
});
|
||||
if (best) setActiveNav(best);
|
||||
}
|
||||
|
||||
// ── search: filter sections + highlight matches ───────────────────────────
|
||||
function clearMarks(root) {
|
||||
root.querySelectorAll('mark').forEach(function (m) {
|
||||
var txt = document.createTextNode(m.textContent);
|
||||
m.parentNode.replaceChild(txt, m);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
function markMatches(el, query) {
|
||||
var lower = query.toLowerCase();
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||
var p = node.parentNode.nodeName;
|
||||
if (p === 'MARK' || p === 'STYLE' || p === 'SCRIPT') return NodeFilter.FILTER_REJECT;
|
||||
return node.nodeValue.toLowerCase().indexOf(lower) >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
var nodes = [], n;
|
||||
while ((n = walker.nextNode())) nodes.push(n);
|
||||
nodes.forEach(function (node) {
|
||||
var val = node.nodeValue, low = val.toLowerCase(), frag = document.createDocumentFragment(), i = 0, idx;
|
||||
while ((idx = low.indexOf(lower, i)) >= 0) {
|
||||
if (idx > i) frag.appendChild(document.createTextNode(val.slice(i, idx)));
|
||||
var mk = document.createElement('mark');
|
||||
mk.textContent = val.slice(idx, idx + query.length);
|
||||
frag.appendChild(mk);
|
||||
i = idx + query.length;
|
||||
}
|
||||
if (i < val.length) frag.appendChild(document.createTextNode(val.slice(i)));
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch(query) {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
var noresult = document.getElementById('ui-help-noresult');
|
||||
if (!content) return;
|
||||
query = (query || '').trim();
|
||||
var hits = 0;
|
||||
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
var navItem = nav.querySelector('[data-help-target="' + t.id + '"]');
|
||||
clearMarks(sec);
|
||||
var match = !query || sec.textContent.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
sec.classList.toggle('hide', !match);
|
||||
if (navItem) navItem.classList.toggle('nohit', !!query && !match);
|
||||
if (match) {
|
||||
hits++;
|
||||
if (query) markMatches(sec, query);
|
||||
}
|
||||
});
|
||||
|
||||
noresult.style.display = (query && hits === 0) ? 'block' : 'none';
|
||||
if (query) { content.scrollTop = 0; }
|
||||
else { syncActiveNav(); }
|
||||
}
|
||||
|
||||
// ── public API ──────────────────────────────────────────────────────────────
|
||||
global.openHelp = function (topicId) {
|
||||
buildModal();
|
||||
document.getElementById('ui-help-overlay').classList.add('open');
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (topicId && typeof topicId === 'string') jumpTo(topicId);
|
||||
else { setActiveNav(TOPICS[0].id); if (q) setTimeout(function () { q.focus(); }, 30); }
|
||||
};
|
||||
global.closeHelp = function () {
|
||||
var o = document.getElementById('ui-help-overlay');
|
||||
if (o) o.classList.remove('open');
|
||||
};
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') global.closeHelp();
|
||||
});
|
||||
|
||||
// ── floating launcher on pages without their own Help button ────────────────
|
||||
function maybeAddFab() {
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
|
||||
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
|
||||
if (document.getElementById('ui-help-fab')) return;
|
||||
var b = document.createElement('button');
|
||||
b.id = 'ui-help-fab'; b.className = 'ui-help-fab'; b.type = 'button';
|
||||
b.title = 'Help'; b.setAttribute('aria-label', 'Open help'); b.textContent = '?';
|
||||
b.addEventListener('click', function () { global.openHelp(); });
|
||||
document.body.appendChild(b);
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAddFab);
|
||||
else maybeAddFab();
|
||||
})(window);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
|
||||
103
html/login.html
Normal file
103
html/login.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in — Work Package Suite</title>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--cds-background);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
box-shadow: 0 2px 6px var(--cds-shadow);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.brand img { height: 36px; width: auto; }
|
||||
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
background: var(--cds-field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--cds-border-strong);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--cds-text-on-color);
|
||||
background: var(--cds-button-primary);
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.error {
|
||||
display: none;
|
||||
background: #fff1f1;
|
||||
border-left: 3px solid var(--cds-support-error);
|
||||
color: var(--cds-text-error);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="brand">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||
<span class="name">Prime Controls</span>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
|
||||
<form id="login-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button id="submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||
</main>
|
||||
|
||||
<script src="login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
html/login.js
Normal file
59
html/login.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
|
||||
session cookie (not readable here — that's the point) and we redirect to the
|
||||
page the user was trying to reach, or the home page. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('login-form');
|
||||
var errorBox = document.getElementById('error');
|
||||
var submitBtn = document.getElementById('submit');
|
||||
|
||||
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||
// open-redirect.)
|
||||
function nextTarget() {
|
||||
try {
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||
} catch (e) {}
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.add('show');
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
errorBox.classList.remove('show');
|
||||
var username = document.getElementById('username').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok) { location.replace(nextTarget()); return null; }
|
||||
return r.json().catch(function () { return null; }).then(function (j) {
|
||||
if (r.status === 401) showError('Invalid username or password.');
|
||||
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
|
||||
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Work Package (IWP) — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||
|
||||
@@ -10,3 +10,13 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||
# production the site is same-origin via NGINX, so leave this unset.
|
||||
# CORS_ORIGINS=http://localhost:5500
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||
# the API falls back to a random per-process key, so logins reset on every
|
||||
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
@@ -13,7 +13,14 @@ browser → NGINX ──serves──> static site (index.html, …)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check |
|
||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||
| POST | `/api/auth/logout` | clear the session cookie |
|
||||
| GET | `/api/auth/me` | the logged-in user |
|
||||
| POST | `/api/auth/password` | change your own password |
|
||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||
@@ -33,6 +40,53 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
||||
|
||||
---
|
||||
|
||||
## Login portal (user accounts)
|
||||
|
||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||
request is validated by checking the cookie's signature and expiry.
|
||||
|
||||
**The real security boundary is the API:** every `/api/` data route is refused
|
||||
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||
to `login.html` when there's no session — that's for UX, not protection.
|
||||
|
||||
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||
`admin` (may manage users) and `user`.
|
||||
|
||||
### Set the signing secret
|
||||
|
||||
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||
without it the API uses a random per-process key, so logins reset on restart.
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
### Create the first admin
|
||||
|
||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||
shell (run from the **project root**, like uvicorn):
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
# prompts for a password (min 8 chars)
|
||||
```
|
||||
|
||||
In Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
```
|
||||
|
||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
|
||||
---
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
@@ -237,14 +291,23 @@ docker compose down -v
|
||||
|
||||
## Quick test
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
||||
`/api/health` is open; data routes now require a session, so log in first and
|
||||
reuse the cookie jar:
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||
|
||||
# Sign in, saving the session cookie to a jar
|
||||
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"<password>"}'
|
||||
|
||||
# Reuse the cookie on protected routes
|
||||
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
|
||||
168
server/app.py
168
server/app.py
@@ -12,14 +12,16 @@ import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models
|
||||
from . import models, auth
|
||||
|
||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -28,14 +30,28 @@ app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url=
|
||||
|
||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||
# allow_credentials is required so the browser sends the session cookie.
|
||||
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||
if _origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins,
|
||||
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||
# docs are exempt (see auth._needs_auth). This is the real security boundary —
|
||||
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
|
||||
# is always allowed so the browser can negotiate before sending credentials.
|
||||
@app.middleware("http")
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
||||
if not auth.is_request_authenticated(request):
|
||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
@@ -100,6 +116,139 @@ def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class NewUserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminPasswordIn(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
||||
user = auth.find_user(db, body.username)
|
||||
# Always run a hash comparison to avoid leaking which usernames exist via
|
||||
# response timing; verify_password tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
if not user or not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
user.last_login_at = models.utcnow()
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout(response: Response):
|
||||
auth.clear_session_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
"""Who is logged in. The frontend guard calls this on every page load."""
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration (admin only) ────────────────────────────────────────────
|
||||
@app.get("/api/auth/users")
|
||||
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
||||
return [u.to_dict() for u in rows]
|
||||
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
if auth.find_user(db, body.username):
|
||||
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
||||
u = models.User(
|
||||
id=gen_id("user"),
|
||||
username=body.username.strip(),
|
||||
email=body.email.strip(),
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id and not body.is_active:
|
||||
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||
u.is_active = body.is_active
|
||||
db.commit()
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/auth/users/{user_id}")
|
||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
@@ -385,3 +534,16 @@ def list_comments(
|
||||
stmt = stmt.where(models.Comment.step == step)
|
||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||
return [c.to_dict() for c in rows]
|
||||
|
||||
|
||||
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
||||
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
||||
# this mount stays inactive there. Locally (plain uvicorn, no NGINX) it lets you
|
||||
# open the whole suite at http://localhost:8000/ with the API on the SAME origin,
|
||||
# so the session cookie just works (no CORS, no Secure-cookie headache).
|
||||
#
|
||||
# Mounted LAST so the /api/* routes above always match first.
|
||||
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
||||
if os.path.isdir(_html_dir):
|
||||
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
|
||||
|
||||
186
server/auth.py
Normal file
186
server/auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
Security model:
|
||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||
route is refused with 401 unless a valid session cookie is present.
|
||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||
set; if it is missing we fall back to a random per-process key (which logs a
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Roles: 'admin' (may manage users) and 'user'.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
"/api/health",
|
||||
"/api/docs",
|
||||
"/api/openapi.json",
|
||||
"/api/docs/oauth2-redirect",
|
||||
"/api/redoc",
|
||||
}
|
||||
|
||||
|
||||
def _load_secret() -> str:
|
||||
s = os.getenv("AUTH_SECRET_KEY")
|
||||
if s:
|
||||
return s
|
||||
# No secret configured: generate an ephemeral one so the app still runs in
|
||||
# dev. Sessions won't survive a restart, and this is unsafe across multiple
|
||||
# workers — production must set AUTH_SECRET_KEY.
|
||||
log.warning(
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
|
||||
"Logins will reset on restart and break across multiple workers. "
|
||||
"Set AUTH_SECRET_KEY in the environment for production."
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Return the token claims if the signature and expiry are valid, else None."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
|
||||
xfp = request.headers.get("x-forwarded-proto", "")
|
||||
if xfp:
|
||||
return xfp.split(",")[0].strip().lower() == "https"
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, request: Request, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_HOURS * 3600,
|
||||
httponly=True,
|
||||
secure=_is_https(request),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
# ── request gate (used as middleware in app.py) ─────────────────────────────────
|
||||
def _needs_auth(path: str) -> bool:
|
||||
if not path.startswith("/api/"):
|
||||
return False # static assets are served by NGINX, not this app
|
||||
if path in _EXEMPT_EXACT:
|
||||
return False
|
||||
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
|
||||
|
||||
|
||||
def is_request_authenticated(request: Request) -> Optional[dict]:
|
||||
"""Validate the session cookie on a raw request. Returns claims or None.
|
||||
Used by the middleware gate, which has no dependency-injection context."""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return decode_token(token)
|
||||
|
||||
|
||||
# ── dependencies (used inside route handlers) ───────────────────────────────────
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
|
||||
"""Resolve the logged-in user from the session cookie, or raise 401.
|
||||
|
||||
Unlike the middleware gate (which only checks the token signature), this also
|
||||
confirms the account still exists and is active — so disabling a user takes
|
||||
effect on their next request."""
|
||||
claims = is_request_authenticated(request)
|
||||
if not claims:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||
"""Look up by username, case-insensitively (also matches on email)."""
|
||||
uname = (username or "").strip().lower()
|
||||
if not uname:
|
||||
return None
|
||||
return db.scalars(
|
||||
select(models.User).where(
|
||||
(func.lower(models.User.username) == uname)
|
||||
| (func.lower(models.User.email) == uname)
|
||||
)
|
||||
).first()
|
||||
144
server/manage_users.py
Normal file
144
server/manage_users.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
maintenance from a shell on the server.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
python -m server.manage_users disable bob
|
||||
python -m server.manage_users enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None) -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
if len(pw) < 8:
|
||||
sys.exit("Password must be at least 8 characters.")
|
||||
return pw
|
||||
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
|
||||
|
||||
def cmd_list(args) -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
db.commit()
|
||||
print(f"Password reset for {u.username}.")
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{username}'.")
|
||||
u.is_active = active
|
||||
db.commit()
|
||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Ensure the users table exists even on a fresh database.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
_set_active(args.username, True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -111,6 +111,32 @@ class WorkPackage(Base):
|
||||
return {**self.summary(), "data": self.data or {}}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with; `role` is
|
||||
either 'admin' (can manage users) or 'user'."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
return {
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ sqlalchemy>=2.0
|
||||
psycopg[binary]>=3.1
|
||||
pydantic>=2.6
|
||||
python-dotenv>=1.0
|
||||
bcrypt>=4.1 # password hashing
|
||||
PyJWT>=2.8 # signed session tokens
|
||||
|
||||
Reference in New Issue
Block a user