β 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.
-
API connectivity
@@ -74,6 +83,26 @@
β
+
+
+
User administration
+
Login accounts for the portal. Requires an admin role on your own account.
+
+
+
+
+
Add a user
+
+
+
+
+
+
+
+
+
+
+
Database snapshot
diff --git a/html/admin.js b/html/admin.js
index ae0f6b2..676eabd 100644
--- a/html/admin.js
+++ b/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,'"'); }
+
+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='
No users yet.
'; 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
+ ? ''
+ : '';
+ const delBtn = me
+ ? ''
+ : '';
+ return '
'+
+ '
'+uesc(u.username)+''+(me?'you':'')+'
'+
+ '
'+uesc(u.full_name||'')+'
'+
+ '
'+uesc(u.email||'')+'
'+
+ '
'+uesc(u.role)+'
'+
+ '
'+(active?'active':'disabled')+'
'+
+ '
'+fmt(u.last_login_at)+'
'+
+ '
'+
+ ''+
+ disableBtn+delBtn+
+ '
'+
+ '
';
+ }).join('');
+ wrap.innerHTML='
'+
+ '
Username
Name
Email
Role
Status
Last login
Actions
'+
+ '
'+rows+'
';
+}
+
+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
diff --git a/html/auth-guard.js b/html/auth-guard.js
new file mode 100644
index 0000000..fa2ff0f
--- /dev/null
+++ b/html/auth-guard.js
@@ -0,0 +1,90 @@
+/* Auth guard for the Work Package Suite.
+ Included in the 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
+})();
diff --git a/html/help.js b/html/help.js
index 4597c61..4881bd5 100644
--- a/html/help.js
+++ b/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 = `
-
How the suite works
-
-
Pick or create a Project on the home page β projects are stored centrally and each keeps its own SOP and Work Packages.
-
SOP Configuration β set the project baseline (team, sign-offs, WP types, governance & sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.
-
Work Package Creation β author individual IWPs against the SOP. Use New for a blank one or Duplicate to copy an existing one.
-
Dashboard β track status, hours, and what's gating each package across the project.
-
+ // ββ content βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Each topic: { id, title, body(HTML) }. Order here is the nav order.
+ var TOPICS = [
+ { id: 'overview', title: 'Getting started', body: `
+
Getting started
+
The Work Package Suite turns a project's standard procedure into release-ready Installation Work Packages (IWPs). You work in three stages, always in the same order:
+
+
Pick or create a Project on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.
+
SOP Configuration β 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.
+
Work Package Creation β author individual IWPs against the SOP, clear their constraints, and issue them to the field.
+
Dashboard β track status, hours, due dates, and what's gating each package across the project.
+
+
Moving around
+
From the home page, open SOP Configuration, the Work Package Creator, or the Dashboard. Inside the suite, switch any time using the top tabs: βοΈ SOP Configuration, π Work Package Creation, and π Dashboard. The active project and SOP follow you across all of them.
+
New here? On the home page choose the Sample Project, then click β Load Sample in the suite to see a fully filled-out SOP and an example Work Package.
` },
-
Key concepts
-
-
Constraints & release readiness: a package can't move to Issued until every constraint is Cleared or N/A. If a constraint reopens after release, the package drops to Issue (Hold).
-
Disciplines & Split: a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. Split by Discipline breaks it into instances β WP01A, WP01B, WP01C β each tied to the master.
-
WP size: the SOP sets a typical size band, which sets a max-hours split threshold. The creator warns when a package's estimated hours exceed it so it can be broken down.
-
Material by discipline: on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.
-
+ { id: 'projects', title: 'Projects', body: `
+
Projects
+
A project is the top-level container β every SOP and Work Package belongs to one. Create or select projects on the home page.
+
Project fields
+
+
Project Name (required)
+
Project Number
+
Client
+
Division / Sector
+
Site / Location
+
+
The active project
+
The active project 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 change link next to the active project name to switch.
+
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.
` },
-
Tips
-
-
Load Sample is context-aware β it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.
-
Data is kept per project; switch projects from the home page.
The SOP is the project baseline. Walk the 10 steps with β Back / Next β, or jump using the step indicators. The final step is β SOP Complete β saving it unlocks the Work Package Creator and turns the home-page card green.
+
+
Project Basics β name, number, client, division/sector, site. Inherited by every WP.
+
Project Team Leadership β PM, APM, CM, QM, plus any additional members (+ Add Team Member).
+
Required Sign-Off Roles β Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with + Add Role.
+
Work Package Types β enable the install types this project uses (Conduit Install, Wire Pull, Terminations, β¦). Enabled types populate the WP type picker.
+
Governance & WP Numbering β the WP number format (e.g. WP##-[Sector]-[TYPE]), issuance strategy, the project's disciplines, the discipline strategy, and WP sizing (see Sizing and Disciplines).
Tracking & Commissioning Platforms β e.g. CxAlloy, Procore, ACC.
+
Construction Sequence β the install flow; reorder by dragging (β Ώ), edit labels, add β QC Hold gates or custom steps. These feed the WP "predecessor" picker.
+
Release Gate Constraints β choose which standard AWP constraints apply and add custom ones (see Constraints).
+
Engineering Sources & References β labelled links (Design Drawings, Specs, β¦) that appear as quick-access buttons in the WP Creator's Drawings & Attachments.
+
+
Fields a WP inherits from the SOP show a "from SOP" tag and are locked. You can override a locked field with π Edit, which requires a logged reason.
In the Creator, start a package with + New (blank, auto-numbered) or β§ Duplicate (copies a saved package and increments the number). The WP Number is built automatically from the SOP number format plus your scope fields, the WP type, and a counter β it's read-only.
+
Key fields
+
+
Subject / Title (required) and WP Type (required, from the SOP).
+
Assets β link each controls.dev asset the package covers.
Scope & Work β the sequenced steps the crew performs (per-discipline in multi-discipline mode).
+
Labor β Est. Hrs. β drives the sizing check (see Sizing).
+
Material List β the bill of materials; import from CSV/Excel or add lines manually.
+
Drawings & Attachments β documents and SOP source-folder links.
+
Kitting & Material Movement (MIMO) β kitting status, warehouse owner, move date/location.
+
Constraints β the release gate (see Constraints).
+
Quality / Hold Points, Approvals & Sign-offs, and Closeout (actual hours, as-builts, lessons learned β shown at QC/Closed).
+
+
Saving
+
Save Draft stores the package; β‘ Save & View 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.
Released to the field. Requires all constraints Cleared or N/A.
+
In Progress
Actively being worked.
+
QC
In quality check / inspection.
+
Closed
Completed.
+
Issue (Hold)
A constraint reopened after release β work is paused until it's resolved.
+
+
A package cannot move to Issued while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to Issue (Hold) and the suite makes you log what happened.
+
On a multi-discipline package, each discipline carries its own status and the overall status rolls up to the least-advanced discipline β so a package is never "Closed" while one trade still lags.
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:
+
+
Safety & Permitting
Quality Control / Inspection
IFC Drawings & Specs
+
Schedule
Materials (on site, bagged & tagged)
Prefabrication
+
Work Access & Laydown
Craft Availability
Construction Equipment & Tools
+
Scaffolding / Access Equipment
+
+
Pick which apply (and add custom ones) in SOP Step 9. Each constraint on a package has one of three states:
+
+
State
Effect
+
Open
Not yet cleared β blocks release.
+
Cleared
Requirement met β counts toward release-ready.
+
N/A
Not applicable to this package β counts as cleared.
+
+
The release gate
+
+
A package is release-ready 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.
+
When the last open constraint clears, the suite offers to mark the package Issued.
+
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 Issue (Hold).
Disciplines are trades (Mechanical, Electrical, Tech, β¦) set in SOP Step 5. The discipline strategy controls how packages handle them:
+
+
Let the planner choose per package (recommended) β pick one discipline (flat scope) or several (per-discipline scope + the Split option).
+
One discipline per package β each WP is single-discipline.
+
Multiple disciplines per package β scope is always split by discipline.
+
+
Split by Discipline
+
When a package covers 2+ disciplines, the β Split by Discipline button breaks it into one numbered instance per discipline β WP01A, WP01B, WP01C (or _MECH/_ELEC suffixes, set in the SOP). The original is kept as a master / roll-up; each instance:
+
+
becomes its own single-discipline package, issued independently;
+
receives only the scope steps and materials tagged to that discipline;
+
stays linked back to the master.
+
+
Tag material rows to a discipline before splitting. Untagged rows stay on the master only and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.
In SOP Step 5 you set a typical WP size band, which sets a split threshold (max labor hours):
+
+
Size band
Split threshold
+
Small β 1β2 days (β8β24 hrs)
24 hrs
+
Standard β 3β5 days (β40β80 hrs)
80 hrs
+
Large β 1β2 weeks (β80β160 hrs)
160 hrs
+
Customβ¦
you set it
+
+
In the Creator, the Est. Hrs. field is checked live against the threshold. Within range you see the target band; over it you get an amber warning β "β β¦ exceeds the β¦-hr split threshold β consider breaking this package down" β 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.
The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's π Dashboard tab, or the Creator header.
+
Metric cards (click to filter)
+
+
Total WPs, Release-ready, On hold, Overdue
+
Est. hrs and Actual hrs (summed)
+
+
Breakdowns & gates
+
+
By status and by discipline chips.
+
β Gating constraints β lists every blocked package and exactly which constraints are holding it.
+
+
The table
+
Shows WP #, subject, type, discipline, status, Gates (clear, n open, or master), due date (red if overdue), and hours. Row actions: issue (when release-ready), view, and edit. Filter with the search box and the status / discipline dropdowns.
+
Split masters are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.
β Load Sample 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.
+
Import / Export
+
+
Work Packages β β€ Export (JSON) downloads all saved packages; import restores them.
+
SOP β the Creator can import a SOP .json (via β€ Import SOP) or load the sample SOP.
+
Materials β import a bill of materials from Excel/CSV, or download a template.
+
+
Comments & feedback
+
Leave feedback from the home page, per-step comments in the SOP tool (π¬ Step Comments), or package comments in the Creator's π¬ Comments drawer. Comments are saved and can be exported/imported as .json so reviewers can share them β and, when the API is reachable, they're collected centrally too.
+
Usage logs
+
π Usage Logs / β€ Usage Data shows session and event counts and can export the full log. A dev-mode toggle pauses tracking during demos.
Installation Work Package β the field-level package this tool produces.
+
AWP
Advanced Work Packaging β the methodology behind the constraint set and release gate.
+
SOP
Standard Operating Procedure β the project baseline every WP inherits.
+
Constraint
A readiness item (Open / Cleared / N/A) that gates release.
+
Release-ready
All constraints Cleared or N/A β the package can be Issued.
+
Issued
Released to the field.
+
Issue (Hold)
A released package paused because a constraint reopened.
+
Discipline
A trade (Mechanical, Electrical, Tech, β¦).
+
Split / Master / Instance
Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).
+
Scope
The sequenced steps the crew performs.
+
Sequence
SOP-defined construction phases; a WP can name a predecessor step.
+
Bagged & tagged
Materials on site, kitted, and labelled β part of the Materials constraint.
+
MIMO
Material In / Material Out β kitting and staging logistics.
+
Asset
A controls.dev record (equipment/system) a package is built around.
+
Hold / Witness point
Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.
+
Active project
The currently selected project; all data is scoped to it.
+
` },
+
+ { id: 'faq', title: 'FAQ', body: `
+
Frequently asked questions
+
The Work Package Creator is locked β why?
+
The SOP for the active project isn't complete yet. Finish SOP Configuration and click β SOP Complete on the last step; the Creator unlocks and the home card turns green.
+
Why can't I set a package to Issued?
+
At least one constraint is still Open. Clear or mark N/A every constraint β the release banner turns green β and the suite will offer to issue it.
+
My package's materials didn't all carry over when I split it.
+
Only material rows tagged to a discipline are routed to that instance. Untagged rows stay on the master. Tag them before splitting.
+
Why don't split masters show in the dashboard totals?
+
Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.
+
Will I lose my work if I close the browser?
+
No β drafts auto-save locally per project and reload next time. Use Export (JSON) for a backup or to share with a teammate.
+
Does each project keep its own data?
+
Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.
+
How do I report a problem or suggestion?
+
Use the feedback / comments features (home page, SOP Step Comments, or the Creator's Comments drawer).
';
+
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);
diff --git a/html/index.html b/html/index.html
index d91d24d..5dcc5c7 100644
--- a/html/index.html
+++ b/html/index.html
@@ -4,6 +4,7 @@
Work Package Suite β Prime Controls
+
+
+
+
+
+
+ Prime Controls
+
+
Sign in
+
Work Package Suite
+
+
+
+
+
+
Authorized use only Β· BTG / Pilot
+
+
+
+
+
diff --git a/html/login.js b/html/login.js
new file mode 100644
index 0000000..7dfeccf
--- /dev/null
+++ b/html/login.js
@@ -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';
+ });
+ });
+})();
diff --git a/html/work-package-suite.html b/html/work-package-suite.html
index aa1c214..9ec4568 100644
--- a/html/work-package-suite.html
+++ b/html/work-package-suite.html
@@ -4,6 +4,7 @@
Work Package Suite
+
diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html
index 08e959e..5a2ad01 100644
--- a/html/wp-creation-index.html
+++ b/html/wp-creation-index.html
@@ -4,6 +4,7 @@
Work Package (IWP) β Prime Controls
+
diff --git a/server/.env.example b/server/.env.example
index 15424cc..1b174d1 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -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
diff --git a/server/README.md b/server/README.md
index d6075bc..fbc988b 100644
--- a/server/README.md
+++ b/server/README.md
@@ -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 --role user`, `list`, `reset-password `,
+`disable `, `enable `. 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":""}'
+
+# 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
diff --git a/server/app.py b/server/app.py
index 499de33..4453b66 100644
--- a/server/app.py
+++ b/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")
diff --git a/server/auth.py b/server/auth.py
new file mode 100644
index 0000000..ab445cf
--- /dev/null
+++ b/server/auth.py
@@ -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()
diff --git a/server/manage_users.py b/server/manage_users.py
new file mode 100644
index 0000000..4fa6e68
--- /dev/null
+++ b/server/manage_users.py
@@ -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 ")
+ 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()
diff --git a/server/models.py b/server/models.py
index 5d4ee8c..e4b20fc 100644
--- a/server/models.py
+++ b/server/models.py
@@ -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"
diff --git a/server/requirements.txt b/server/requirements.txt
index bd30783..bc4810b 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -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