Compare commits

...

12 Commits

Author SHA1 Message Date
C-West8
e51024466b first pass at storing data in DB instead of client only 2026-06-30 16:35:39 -05:00
eefa76e460 Add self-service password change + forgot-password guidance
- Logged-in users can change their own password from a "Password" link
  in the top-right pill (dialog -> POST /api/auth/password, which requires
  the current password).
- Login page gains a "Forgot password?" link explaining that resets are
  admin-assisted (admins reset from the console). No SMTP, so no email
  reset flow yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:20:49 -07:00
5ad3ffa58e Per-project access control + UI/feedback/admin refinements
Access control:
- project_members table; non-admins only see/operate on assigned
  projects (enforced across projects, SOPs, work packages — 403 else),
  admins bypass. Creating a project auto-grants its creator access.
- Admin API to get/set a user's project assignments, plus a checkbox
  assignment dialog in the Admin Console user list.

UI / workflow:
- Login page: drop the "Prime Controls" wordmark next to the logo.
- SOP tool: remove emoji icons from buttons and nav tabs.
- Rename "Step Comments" to "Feedback"; the author auto-populates
  (read-only) from the signed-in user.
- Move usage-log viewing to the Admin Console; add an admin card that
  lists all feedback/comments (who, what, page + step, when).
- Sample project name -> "Micron FMCS Install (sample)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:38:56 -07:00
bdb798efdd Add Portainer deploy guide for the login portal
Standalone instructions for the Portainer admin: set AUTH_SECRET_KEY,
rebuild + redeploy the stack, and bootstrap the first admin account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:52:27 -07:00
151ccea0ac Merge remote main (nginx conf fix) into login portal 2026-06-25 17:00:13 -05:00
1f8c23c9bb Merge feat/secure-login-portal: secure login portal 2026-06-25 16:59:34 -05:00
20afb0e565 Add secure username/password login portal
Gate the suite behind a self-contained login (no external IdP):

- User model with bcrypt-hashed passwords; admin/user roles
- /api/auth endpoints: login, logout, me, change-password, and
  admin-only user management (list/create/delete/reset/enable)
- Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure
  cookie; middleware refuses every /api data route without a session
- login.html + auth-guard.js: login page and per-page guard with a
  top-right "name / Admin / Sign out" pill
- Admin Console now gated on admin role (passphrase gate removed) with
  a User administration card
- manage_users.py CLI to bootstrap the first admin
- Rebuilt help.js into a searchable, multi-topic help center
- Local-dev convenience: app serves html/ so the site + API share one
  origin under uvicorn (inactive in the prod container)
- Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:55:54 -05:00
deaf13c724 fix server in wp-suite.conf so proxy works. 2026-06-17 13:07:49 -05:00
a02f7ec511 Prevent table-creation race: gunicorn --preload
With 2 workers, both ran Base.metadata.create_all() at import on an empty DB,
racing on CREATE TABLE (duplicate pg_type for "projects"). --preload imports
the app once in the master before forking, so tables are created a single time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:03:38 -07:00
c010bc22a0 Update home footer to BTG / Pilot Use Only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:40 -07:00
66da5b708a Fix API DB connection: build URL from POSTGRES_* (auto-encode password)
The api crash-looped because DATABASE_URL had an un-encoded special-char
password (@/!), so SQLAlchemy parsed part of the password as the host
("...@db" → name resolution failure).

db.py now prefers building the connection from POSTGRES_USER/PASSWORD/DB via
SQLAlchemy URL.create(), which encodes the password automatically — any
password works with no manual escaping. DATABASE_URL remains an optional
override (still must be hand-encoded if used). docker-compose now passes the
POSTGRES_* vars to the api container; DEPLOYMENT.md updated (incl. a Portainer
env-vars note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:17 -07:00
1e31aa535e Merge feat/admin-console into main
Adds /admin.html — a passphrase-gated, in-site admin console for diagnostics
and testing: API connectivity check, DB snapshot, browser smoke test, and
demo-data seed/clean. Gate is SHA-256 obfuscation only (default passphrase
"prime-admin"); restrict at the network/proxy for real protection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:09:17 -07:00
26 changed files with 2217 additions and 189 deletions

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ wpsuite.db
# Runtime directories (created by containers) # Runtime directories (created by containers)
logs/ logs/
# Local server logs
*.log

103
DEPLOY-login-portal.md Normal file
View File

@@ -0,0 +1,103 @@
# Deploy: Work Package Suite — login portal update
Instructions for the **Portainer admin** to take the new secure login portal live.
No prior context needed.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
**What changed:** the app now has a username/password login. Going live needs:
1. one new environment variable,
2. a **rebuild** of the stack (not just a restart), and
3. creating the first admin account.
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
> bake the code in at build time (`COPY html/` and `COPY server/` in their
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
> be **rebuilt** from the latest `main`.
---
## 1. Add an environment variable to the stack
In the stack's **Environment variables** section, add:
| Name | Value | Notes |
|------|-------|-------|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
Generate the secret on the host with:
```bash
openssl rand -base64 48
```
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
> per-process key — logins then reset on every restart and break across the 2 gunicorn
> workers. It must be set to a fixed value.
The existing database variables (`POSTGRES_*`) are unchanged.
---
## 2. Pull latest `main`, rebuild, and redeploy
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
`api` images.
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
automatically during the rebuild.
- The `users` table is created automatically on API startup — **no DB migration needed.**
---
## 3. Verify the containers
- Confirm `wp_api` and the webserver container are both **running**.
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
warning — it won't crash — but please confirm it's set.)
---
## 4. Create the first admin account
The login system needs one admin user in the production (Postgres) database. Open the
**`wp_api`** container's **Console** (`/bin/sh`) and run:
```bash
python -m server.manage_users create-admin <username> --name "<Full Name>"
```
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
Non-interactive alternative:
```bash
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
```
Other CLI commands (run the same way): `list`, `create <user> --role user`,
`reset-password <user>`, `disable <user>`, `enable <user>`.
---
## 5. Confirm it works
1. Load the site's normal URL — it should redirect to a **login page**.
2. Sign in with the admin account from step 4.
3. That admin can then add all other users from the in-app **Admin → User
administration** page (top-right **Admin** link), so no further shell access is needed.
---
## Reference — what's in this release
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
- `server/app.py``/api/auth/*` endpoints + middleware that refuses every `/api` data
route without a valid session.
- `server/manage_users.py` — the CLI used in step 4.
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
administration UI.
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).

View File

@@ -51,16 +51,23 @@ Create a file named `.env` in the **project root** (same folder as
POSTGRES_DB=wpsuite POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password> POSTGRES_PASSWORD=<strong-random-password>
# Must match the POSTGRES_* values above. Host is the compose service name "db".
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
``` ```
That's it — the API now builds its own connection string from these three
values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other
database; if you do set it, you must URL-encode the password yourself, and it's
ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`. Generate a strong password with `openssl rand -base64 32`.
These are the only credentials in the system: the `db` container initialises > **Portainer note:** for a Git-based stack these go in the stack's
Postgres from `POSTGRES_*`, and the `api` container connects with the matching > **Environment variables** section (Portainer doesn't read a local `.env`).
`DATABASE_URL`. Neither value appears in the compose file or in git. > Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
These are the only credentials in the system, and they never appear in the
compose file or in git.
## 3. Point your reverse proxy at the nginx container ## 3. Point your reverse proxy at the nginx container

View File

@@ -4,5 +4,7 @@ COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/ COPY server/ ./server/
EXPOSE 8000 EXPOSE 8000
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \ # --preload imports the app once in the master (so create_all runs a single time)
# before forking workers, preventing a table-creation race on first startup.
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"] "-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]

View File

@@ -19,7 +19,17 @@ services:
build: . build: .
container_name: wp_api container_name: wp_api
environment: environment:
DATABASE_URL: ${DATABASE_URL} # Preferred: the API builds its own connection string from these and
# encodes the password automatically (no manual URL-encoding needed).
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
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 restart: unless-stopped
depends_on: depends_on:
db: db:

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title> <title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<style> <style>
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230; :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; } .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; } .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; } 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> </style>
</head> </head>
<body> <body>
<!-- GATE --> <!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="gate-overlay" id="admin-gate"> <div class="wrap" id="admin-denied" style="display:none">
<div class="gate-box"> <div class="card">
<h2>🔒 Admin Console</h2> <h2>Admins only</h2>
<p>Enter the admin passphrase to continue.</p> <p class="sub" style="margin:0 0 12px">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<input type="password" id="gate-input" placeholder="Passphrase" autocomplete="off" <div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
onkeydown="if(event.key==='Enter') tryUnlock()">
<div class="gate-msg" id="gate-msg"></div>
<button class="primary" style="width:100%" onclick="tryUnlock()">Unlock</button>
</div> </div>
</div> </div>
@@ -62,11 +73,9 @@
<div class="wrap" id="admin-main" style="display:none"> <div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between"> <div class="row" style="justify-content:space-between">
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div> <div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; 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>
<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 --> <!-- CONNECTIVITY -->
<div class="card"> <div class="card">
<h2>API connectivity</h2> <h2>API connectivity</h2>
@@ -74,6 +83,49 @@
<div class="banner" id="health-banner"></div> <div class="banner" id="health-banner"></div>
</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>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="row">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="row">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT --> <!-- DB SNAPSHOT -->
<div class="card"> <div class="card">
<h2>Database snapshot</h2> <h2>Database snapshot</h2>

View File

@@ -1,41 +1,22 @@
/* Admin console for the Work Package Suite. /* Admin console for the Work Package Suite.
Browser-side diagnostics + tests that call the same /api on this host. Browser-side diagnostics + tests that call the same /api on this host.
PASSPHRASE GATE (lightweight / obfuscation only): ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
The gate compares a SHA-256 hash so the passphrase isn't in the source, but a already requires a login (redirecting to login.html otherwise) and publishes
determined user can still bypass client-side JS. For real protection, restrict window.WP_USER; here we show the console only when that user is an admin, and
this host/route at the network or reverse-proxy layer. 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(){ function reveal(){
document.getElementById('admin-gate').style.display='none';
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
checkHealth(); checkHealth();
loadUsers();
loadComments();
loadUsage();
}
function showDenied(){
document.getElementById('admin-denied').style.display='';
} }
function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
// ── api helper ────────────────────────────────────────────────────────────── // ── api helper ──────────────────────────────────────────────────────────────
async function api(method, path, body){ async function api(method, path, body){
@@ -165,6 +146,241 @@ async function cleanDemo(){
snapshot(); snapshot();
} }
// reveal immediately if already unlocked this session // ── user administration ────────────────────────────────────────────────────────
if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); } function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
else { const i=document.getElementById('gate-input'); if(i) i.focus(); }
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 cant 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 cant 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="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML='<table class="users"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th><th>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)));
}
// ── project access assignment ───────────────────────────────────────────────────
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
}
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
function openProjectModal(userId, username, projects, assigned, userObj){
closeProjectModal();
const isAdmin = userObj && userObj.role==='admin';
const items = projects.length ? projects.map(p =>
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
modal.innerHTML =
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
'<div style="padding:14px 18px;overflow:auto;">'+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
'<div id="proj-list">'+items+'</div>'+
'</div>'+
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
'<button onclick="closeProjectModal()">Cancel</button>'+
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
'</div>'+
'</div>';
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
document.body.appendChild(modal);
const saveBtn = document.getElementById('proj-save');
if(saveBtn) saveBtn.onclick = async () => {
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
if(status===200) closeProjectModal();
else alert('Save failed (HTTP '+status+').');
};
}
// ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = [];
async function loadComments(){
const box = document.getElementById('comments-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/comments');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
}
_comments = json;
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
sel.value = cur;
renderComments();
}
function renderComments(){
const box = document.getElementById('comments-admin');
const src = document.getElementById('cmt-filter').value;
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
let rows = _comments.filter(c => (!src || c.source===src) &&
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const where = c => {
const bits = [];
if(c.page) bits.push(uesc(c.page));
if(c.step!=null) bits.push('step '+c.step);
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
return bits.join(' · ') || '—';
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
rows.map(c => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
'<td>'+uesc(c.source||'—')+'</td>'+
'<td style="color:var(--muted)">'+where(c)+'</td>'+
'<td>'+uesc(c.text||'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
function loadUsage(){
const box = document.getElementById('usage-admin');
const evs = (usageLoad().events) || [];
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
const byEvent = {}, byStep = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = s => s ? new Date(s).toLocaleString() : '—';
let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
html += '</tbody></table>';
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
box.innerHTML = html;
}
function downloadUsage(){
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
// ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
// Show the console for admins; otherwise show the "Admins only" notice.
let _adminGated = false;
function gateByRole(){
if(_adminGated) return;
const u = window.WP_USER;
if(!u) return; // not resolved yet — wait for wp-auth-ready
_adminGated = true;
if(u.role === 'admin') reveal();
else showDenied();
}
document.addEventListener('wp-auth-ready', gateByRole);
gateByRole(); // in case WP_USER was already set before this ran

153
html/auth-guard.js Normal file
View File

@@ -0,0 +1,153 @@
/* 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'); });
};
// Change-password dialog (uses POST /api/auth/password, which requires the
// current password). Available from the top-right pill on any page.
window.wpChangePassword = function () {
if (document.getElementById('wp-pw-modal')) return;
var ov = document.createElement('div');
ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 8 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) {
var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
document.getElementById('wp-pw-cancel').onclick = close;
document.getElementById('wp-pw-cur').focus();
document.getElementById('wp-pw-save').onclick = function () {
var cur = document.getElementById('wp-pw-cur').value;
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: n1 })
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
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 pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
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
})();

View File

@@ -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: Included by the home page, the suite, and the embedded creator. It injects:
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers - tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
- a Help modal (workflow + key concepts) opened via window.openHelp() - a searchable, multi-topic Help center modal opened via window.openHelp()
Add a "❔ Help" button anywhere with onclick="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) { (function (global) {
'use strict'; 'use strict';
// ── styles ────────────────────────────────────────────────────────────────
var css = ` var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; .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; 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; } 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; } .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; .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:5vh 16px; overflow:auto; } justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; } .ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px; .ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; } border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
.ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px; font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
border-bottom:1px solid #e3e6ec; font-size:16px; } .ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; } .ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; } .ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; } .ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
.ui-help-body h4:first-child{ margin-top:0; } font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; } .ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-body li{ margin-bottom:5px; } .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-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; } .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'); var style = document.createElement('style');
style.textContent = css; style.textContent = css;
(document.head || document.documentElement).appendChild(style); (document.head || document.documentElement).appendChild(style);
var HELP_HTML = ` // ── content ─────────────────────────────────────────────────────────────────
<h4>How the suite works</h4> // 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> <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>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 (team, sign-offs, WP types, governance &amp; sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.</li> <li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance &amp; 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. Use <strong>New</strong> for a blank one or <strong>Duplicate</strong> to copy an existing one.</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, and what's gating each package across the project.</li> <li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
</ol> </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> { 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> <ul>
<li><strong>Constraints &amp; 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>Project Name</strong> (required)</li>
<li><strong>Disciplines &amp; 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>Project Number</strong></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>Client</strong></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> <li><strong>Division / Sector</strong></li>
<li><strong>Site / Location</strong></li>
</ul> </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> { 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 &amp; 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 &amp; Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
<li><strong>Tracking &amp; 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 &amp; References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings &amp; 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> <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><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li> <li><strong>Assets</strong> — link each controls.dev asset the package covers.</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> <li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li>
</ul>`; <li><strong>Scope &amp; 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 &amp; Attachments</strong> — documents and SOP source-folder links.</li>
<li><strong>Kitting &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings &amp; Specs</li>
<li>Schedule</li><li>Materials (on site, bagged &amp; tagged)</li><li>Prefabrication</li>
<li>Work Access &amp; Laydown</li><li>Craft Availability</li><li>Construction Equipment &amp; 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 &amp; photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
</ul>` },
{ id: 'disciplines', title: 'Disciplines & Split', body: `
<h3>Disciplines &amp; 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 — 12 days (≈824 hrs)</td><td>24 hrs</td></tr>
<tr><td>Standard — 35 days (≈4080 hrs)</td><td>80 hrs</td></tr>
<tr><td>Large — 12 weeks (≈80160 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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() { function buildModal() {
if (document.getElementById('ui-help-overlay')) return; if (document.getElementById('ui-help-overlay')) return;
var overlay = document.createElement('div'); var overlay = document.createElement('div');
overlay.className = 'ui-help-overlay'; overlay.className = 'ui-help-overlay';
overlay.id = '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>' + var nav = TOPICS.map(function (t) {
'<button type="button" onclick="closeHelp()" aria-label="Close help">✕</button></div>' + return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
'<div class="ui-help-body">' + HELP_HTML + '</div></div>'; }).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(); }); overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
document.body.appendChild(overlay); 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'); }; function jumpTo(id) {
global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); }; var sec = document.getElementById('ui-help-sec-' + id);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); }); 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); })(window);

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite — Prime Controls</title> <title>Work Package Suite — Prime Controls</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<style> <style>
@@ -471,7 +472,7 @@
<!-- FOOTER --> <!-- FOOTER -->
<footer class="footer"> <footer class="footer">
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p> <p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
</footer> </footer>
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
@@ -594,7 +595,10 @@
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''} if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`; &nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
reflectSOPStatus(active); // Pull the project's shared SOP from the server into the local cache first,
// so the SOP "Complete / Review" status reflects what other users have done.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active);
} }
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); } function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
@@ -703,22 +707,36 @@
r.readAsText(f); r.readAsText(f);
} }
function loadComments() { function renderComments() {
const saved = localStorage.getItem('wp_suite_index_comments');
if (saved) allComments = JSON.parse(saved);
const list = document.getElementById('comments-list'); const list = document.getElementById('comments-list');
if (allComments.length === 0) { if (allComments.length === 0) {
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>'; list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
} else { } else {
list.innerHTML = allComments.map(c => ` list.innerHTML = allComments.map(c => `
<div class="comment-item"> <div class="comment-item">
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div> <div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'&lt;')}</strong> • ${c.timestamp||''}</div>
<div class="comment-text">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div> <div class="comment-text">${(c.text||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div> </div>
`).join(''); `).join('');
} }
} }
function loadComments() {
// Server is authoritative (so feedback is shared across users); fall back to
// the local cache if the API is unreachable.
const saved = localStorage.getItem('wp_suite_index_comments');
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
renderComments();
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
.then(r => r.ok ? r.json() : null)
.then(rows => {
if (Array.isArray(rows)) {
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
renderComments();
}
})
.catch(() => {});
}
</script> </script>
</body> </body>
</html> </html>

109
html/login.html Normal file
View File

@@ -0,0 +1,109 @@
<!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'">
</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 style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
</p>
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
</div>
<p class="foot">Authorized use only · BTG / Pilot</p>
</main>
<script src="login.js"></script>
</body>
</html>

68
html/login.js Normal file
View File

@@ -0,0 +1,68 @@
/* 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');
}
var forgot = document.getElementById('forgot-link');
if (forgot) {
forgot.addEventListener('click', function (e) {
e.preventDefault();
var m = document.getElementById('forgot-msg');
if (m) m.style.display = 'block';
});
}
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';
});
});
})();

View File

@@ -25,7 +25,7 @@
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); } function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
var SAMPLE_PROJECT = { var SAMPLE_PROJECT = {
name: 'Micron — INC Construction Work Packages', number: '26-67-008', name: 'Micron FMCS Install (sample)', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor', client: 'Micron Technology, Inc.', division: 'Semiconductor',
site: 'Boise, ID — Fab', sample: true site: 'Boise, ID — Fab', sample: true
}; };
@@ -81,6 +81,107 @@
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; } key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
}; };
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
// SOPs and WPs are authoritative on the server (so every user of a project sees
// the same data). To avoid rewriting the two apps, we keep their existing
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
// keys from the API on page load, and the push* helpers write through to the
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
function nsKey(base, id) { return id ? base + '__' + id : base; }
function currentUser() {
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
}
// A saved Work Package is a flat object in the browser; the API splits it into
// promoted columns + a `data` blob. We store the whole flat object in `data`
// for perfect round-tripping, and mirror the few fields the API promotes.
function pkgToServer(p, projectId) {
return {
id: p.id,
project_id: p.projectId || projectId || null,
parent_id: p.instanceOf || null,
number: p.number || '',
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
created_by: p.createdBy || currentUser(),
data: p
};
}
function serverToPkg(row) {
var p = Object.assign({}, row.data || {}); // full flat object lives in data
p.id = row.id;
p.projectId = row.project_id || p.projectId || '';
if (row.number) p.number = row.number;
if (row.subject != null) p.subject = row.subject;
if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id;
return p;
}
// Pull this project's SOP + WPs from the API into the localStorage keys the
// apps read. Resolves even on failure (offline / no API) so boot continues.
ProjectData.pullProject = function (projectId) {
if (!projectId) return Promise.resolve();
var jobs = [];
jobs.push(
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (sopRow) {
if (sopRow && sopRow.data) {
var d = sopRow.data; // { sop, state } as written by pushSOP
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
}
}).catch(function () {})
);
jobs.push(
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (rows) {
if (Array.isArray(rows)) {
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
}
}).catch(function () {})
);
return Promise.all(jobs).then(function () {});
};
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
// deterministic id per project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null);
var body = {
id: 'sop__' + projectId,
project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true,
created_by: currentUser(),
data: { sop: sop, state: state }
};
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Upsert a single Work Package to the API (fire-and-forget from the caller's
// perspective; the local cache is the source of truth for immediate rendering).
ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null);
return fetch(API + '/wps', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve();
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () {}).catch(function () {});
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale // One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
// global state can't leak across projects. (User chose: discard, don't migrate.) // global state can't leak across projects. (User chose: discard, don't migrate.)
try { try {

View File

@@ -148,17 +148,33 @@ window.addEventListener('DOMContentLoaded',()=>{
// Resolve the active project FIRST so per-project storage keys are correct // Resolve the active project FIRST so per-project storage keys are correct
// before we restore this project's SOP. // before we restore this project's SOP.
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
applyProjectContext(params.get('project')); applyProjectContext(params.get('project'));
// Pull the project's shared SOP from the server into the local cache, THEN
// restore it. Falls back to the local cache if offline.
function afterPull(){
restoreSavedSOP(); restoreSavedSOP();
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab'); const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard'); if(params.get('view') === 'dashboard') switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab); else if(tab === 'wp' || tab === 'sop') switchTool(tab);
}
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
} else {
afterPull();
}
track('app_open'); track('app_open');
// Feedback author auto-populates from the signed-in user (auth-guard sets
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
setCommenterName();
document.addEventListener('wp-auth-ready', setCommenterName);
let _fieldTimer; let _fieldTimer;
document.addEventListener('input', e=>{ document.addEventListener('input', e=>{
const t = e.target; const t = e.target;
@@ -836,6 +852,12 @@ function completeSOP(){
localStorage.setItem(SK('wp_suite_sop_complete'), '1'); localStorage.setItem(SK('wp_suite_sop_complete'), '1');
} catch(e){} } catch(e){}
// Share the SOP to the server so every user of this project gets it.
try {
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
} catch(e){}
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case // Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
@@ -853,8 +875,17 @@ function toggleComments(){
if(panel.style.display === 'block') loadStepComments(); if(panel.style.display === 'block') loadStepComments();
} }
function currentUserName(){
const u = window.WP_USER;
return (u && (u.full_name || u.username)) || '';
}
function setCommenterName(){
const el = document.getElementById('commenter-name');
if(el) el.value = currentUserName();
}
function submitComment(){ function submitComment(){
const name = document.getElementById('commenter-name').value || 'Anonymous'; const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
const text = document.getElementById('comment-text').value.trim(); const text = document.getElementById('comment-text').value.trim();
if(!text){ alert('Please enter a comment.'); return; } if(!text){ alert('Please enter a comment.'); return; }

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite</title> <title>Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="work-package-suite-styles.css"> <link rel="stylesheet" href="work-package-suite-styles.css">
@@ -22,10 +23,9 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button> <button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button> <button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button> <button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span> <span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
</div> </div>
</div> </div>
@@ -33,13 +33,13 @@
<!-- MAIN NAVIGATION --> <!-- MAIN NAVIGATION -->
<div class="main-nav"> <div class="main-nav">
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')"> <button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
<span class="tab-icon">⚙️</span> SOP Configuration SOP Configuration
</button> </button>
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')"> <button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
<span class="tab-icon">📋</span> Work Package Creation Work Package Creation
</button> </button>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')"> <button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
<span class="tab-icon">📊</span> Dashboard Dashboard
</button> </button>
</div> </div>
@@ -297,7 +297,7 @@
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button> <button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button> <button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
</div> </div>
</div> </div>
@@ -328,9 +328,9 @@
<!-- SOP NAVIGATION --> <!-- SOP NAVIGATION -->
<div class="step-navigation"> <div class="step-navigation">
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button> <button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button> <button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button> <button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
</div> </div>
</div> </div>
@@ -341,9 +341,9 @@
<div id="tool-wp" class="tool"> <div id="tool-wp" class="tool">
<!-- Shown until the SOP is complete --> <!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;"> <div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<h2>📋 Work Package Creation</h2> <h2>Work Package Creation</h2>
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p> <p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button> <button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
</div> </div>
<!-- The real Work Package Creator, embedded once the SOP is complete --> <!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe> <iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
@@ -356,12 +356,12 @@
<!-- STEP COMMENTS DROPDOWN (toggled from header) --> <!-- STEP COMMENTS DROPDOWN (toggled from header) -->
<div id="comments-panel" class="comments-dropdown" style="display: none;"> <div id="comments-panel" class="comments-dropdown" style="display: none;">
<div class="comments-dropdown-header"> <div class="comments-dropdown-header">
<strong>💬 Step Comments</strong> <strong>Feedback</strong>
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button> <button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button>
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label> <label style="font-weight: 600; font-size: 13px;">Your Name</label>
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;"> <input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label> <label style="font-weight: 600; font-size: 13px;">Feedback</label>
@@ -369,8 +369,8 @@
</div> </div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button> <button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button> <button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button> <button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)"> <input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
</div> </div>
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div> <div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>

View File

@@ -687,6 +687,7 @@ function savePackage(view){
const ix=savedPackages.findIndex(p=>p.id===pkg.id); const ix=savedPackages.findIndex(p=>p.id===pkg.id);
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status}); editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
document.getElementById('loadingOverlay').classList.add('active'); document.getElementById('loadingOverlay').classList.add('active');
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400); setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
} }
@@ -846,8 +847,8 @@ function renderSavedList(){
}).join(''); }).join('');
} }
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); } function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); } function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){ function loadPackageIntoForm(p){
@@ -916,6 +917,7 @@ function duplicateWP(){
savedPackages.push(c); made.push(c); savedPackages.push(c); made.push(c);
} }
editingId=null; saveStore(); renderSavedList(); editingId=null; saveStore(); renderSavedList();
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
toast('Created '+n+' duplicate'+(n>1?'s':'')); toast('Created '+n+' duplicate'+(n>1?'s':''));
track('wp_duplicated',{count:n}); track('wp_duplicated',{count:n});
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.'); alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
@@ -951,12 +953,14 @@ function exportPackages(){
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus() // Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change. // bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
const WPData = { const WPData = {
list(){ return savedPackages.slice(); }, // GET /api/wps list(){ return savedPackages.slice(); }, // hydrated from GET /api/wps on boot
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id} get(id){ return savedPackages.find(p=>p.id===id); },
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); return true; }, // → POST /api/wps/{id}/issue p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status p.status=status; p.updatedAt=new Date().toISOString(); saveStore();
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
}; };
let dashFilter={status:'',discipline:'',q:'',flag:''}; let dashFilter={status:'',discipline:'',q:'',flag:''};
@@ -1144,10 +1148,10 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId }); ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
} }
})(); })();
loadStore(); function bootSOP(){
(function bootSOP(){
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
// and prefer the SOP the Suite just completed (persisted to localStorage). // and prefer the SOP the Suite just completed (persisted to localStorage, which
// we've already hydrated from the server for this project).
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if(params.get('embedded')) document.body.classList.add('embedded'); if(params.get('embedded')) document.body.classList.add('embedded');
try { try {
@@ -1162,11 +1166,22 @@ loadStore();
// state so it's clear the project's SOP must be completed first. // state so it's clear the project's SOP must be completed first.
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); } if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
else { loadSampleSOP(); } else { loadSampleSOP(); }
})(); }
function bootData(){
loadStore(); // reads the localStorage cache (hydrated from the server below)
bootSOP();
setRadio('status','Draft'); setRadio('status','Draft');
renderSavedList(); renderSavedList();
cmtInit(); cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). // Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })(); const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
track('app_open'); track('app_open');
}
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
// Pull this project's shared SOP + Work Packages from the server first, then boot
// off the refreshed cache. Falls back to whatever is cached locally if offline.
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData);
} else {
bootData();
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Package (IWP) — Prime Controls</title> <title>Work Package (IWP) — Prime Controls</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-creation-styles.css"> <link rel="stylesheet" href="wp-creation-styles.css">

View File

@@ -4,7 +4,7 @@
server { server {
listen 80; listen 80;
server_name _; server_name wp.controls.dev;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

View File

@@ -10,3 +10,13 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# Only needed for CROSS-ORIGIN local development (comma-separated). In # Only needed for CROSS-ORIGIN local development (comma-separated). In
# production the site is same-origin via NGINX, so leave this unset. # production the site is same-origin via NGINX, so leave this unset.
# CORS_ORIGINS=http://localhost:5500 # 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

View File

@@ -13,7 +13,14 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose | | 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`) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries | | GET | `/api/sops` | list SOP summaries |
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP | | 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 ## Local dev
```bash ```bash
@@ -237,14 +291,23 @@ docker compose down -v
## Quick test ## Quick test
```bash `/api/health` is open; data routes now require a session, so log in first and
curl -X POST http://127.0.0.1:8000/api/comments \ reuse the cookie jar:
-H 'Content-Type: application/json' \
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
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): Or via the nginx proxy (replace with your hostname):
```bash ```bash

View File

@@ -12,14 +12,16 @@ import os
import uuid import uuid
from typing import Any, Optional 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.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete from sqlalchemy import select, delete
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, get_db 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.) # Create tables on startup. (For schema changes later, switch to Alembic.)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -28,18 +30,80 @@ app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url=
# Same-origin in production (NGINX), so CORS is normally unnecessary. For # Same-origin in production (NGINX), so CORS is normally unnecessary. For
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..." # 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] _origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
if _origins: if _origins:
app.add_middleware( app.add_middleware(
CORSMiddleware, allow_origins=_origins, CORSMiddleware, allow_origins=_origins, allow_credentials=True,
allow_methods=["*"], allow_headers=["*"], 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: def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}" return f"{prefix}_{uuid.uuid4().hex[:12]}"
# ── Per-project access control ─────────────────────────────────────────────────
# A non-admin user may only touch projects they're a member of (project_members).
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
# gated. List endpoints are scoped to accessible projects; single-resource and
# mutating endpoints raise 403 on no access.
def accessible_project_ids(db: Session, user: "models.User"):
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
if user.role == "admin":
return None
rows = db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
).all()
return set(rows)
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin" or project_id is None:
return
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user.id)
& (models.ProjectMember.project_id == project_id)
)
)
if not ok:
raise HTTPException(status_code=403, detail="You don't have access to this project")
def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user)
if ids is None:
return stmt
return stmt.where(column.in_(ids))
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
"""Add a (user, project) membership if it isn't already there."""
exists = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id)
& (models.ProjectMember.project_id == project_id)
)
)
if not exists:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
# ── Request bodies ─────────────────────────────────────────────────────────── # ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel): class ProjectIn(BaseModel):
id: Optional[str] = None id: Optional[str] = None
@@ -100,10 +164,180 @@ def health():
return {"ok": True} 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
class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list)
@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}
@app.get("/api/auth/users/{user_id}/projects")
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the full project list for the
assignment UI. (Admins implicitly access every project regardless.)"""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
return {
"user": u.to_dict(),
"assigned": list(assigned),
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
}
@app.put("/api/auth/users/{user_id}/projects")
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
for pid in valid:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
db.commit()
return {"assigned": sorted(valid)}
# ── Projects ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects") @app.post("/api/projects")
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)): def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, body.id) if body.id else None proj = db.get(models.Project, body.id) if body.id else None
is_new = proj is None
if not is_new:
require_project_access(db, user, proj.id)
if proj is None: if proj is None:
proj = models.Project(id=body.id or gen_id("proj")) proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj) db.add(proj)
@@ -116,29 +350,36 @@ def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
proj.created_by = body.created_by or proj.created_by proj.created_by = body.created_by or proj.created_by
proj.data = body.data proj.data = body.data
db.commit() db.commit()
# A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin":
grant_project_access(db, user.id, proj.id)
db.commit()
db.refresh(proj) db.refresh(proj)
return proj.to_dict() return proj.to_dict()
@app.get("/api/projects") @app.get("/api/projects")
def list_projects(db: Session = Depends(get_db)): def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all() stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
rows = db.scalars(stmt).all()
return [p.summary() for p in rows] return [p.summary() for p in rows]
@app.get("/api/projects/{project_id}") @app.get("/api/projects/{project_id}")
def get_project(project_id: str, db: Session = Depends(get_db)): def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
return proj.to_dict() return proj.to_dict()
@app.delete("/api/projects/{project_id}") @app.delete("/api/projects/{project_id}")
def delete_project(project_id: str, db: Session = Depends(get_db)): def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
db.delete(proj) db.delete(proj)
db.commit() db.commit()
return {"deleted": project_id} return {"deleted": project_id}
@@ -146,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
# ── SOPs ───────────────────────────────────────────────────────────────────── # ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops") @app.post("/api/sops")
def upsert_sop(body: SopIn, db: Session = Depends(get_db)): def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
sop = db.get(models.Sop, body.id) if body.id else None sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None:
require_project_access(db, user, sop.project_id)
if sop is None: if sop is None:
sop = models.Sop(id=body.id or gen_id("sop")) sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop) db.add(sop)
@@ -163,21 +407,25 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
@app.get("/api/sops") @app.get("/api/sops")
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def list_sops(project_id: Optional[str] = Query(None), full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Sop) stmt = select(models.Sop)
if project_id: if project_id:
stmt = stmt.where(models.Sop.project_id == project_id) stmt = stmt.where(models.Sop.project_id == project_id)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all() rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
return [s.summary() for s in rows] # full=true includes the data JSON (the whole SOP document) for hydration;
# the default summary view stays lean for listing.
return [(s.to_dict() if full else s.summary()) for s in rows]
@app.get("/api/sops/latest") @app.get("/api/sops/latest")
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Sop) stmt = select(models.Sop)
if complete is not None: if complete is not None:
stmt = stmt.where(models.Sop.complete == complete) stmt = stmt.where(models.Sop.complete == complete)
if project_id: if project_id:
stmt = stmt.where(models.Sop.project_id == project_id) stmt = stmt.where(models.Sop.project_id == project_id)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first() sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
if not sop: if not sop:
raise HTTPException(status_code=404, detail="No SOP found") raise HTTPException(status_code=404, detail="No SOP found")
@@ -185,18 +433,20 @@ def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Quer
@app.get("/api/sops/{sop_id}") @app.get("/api/sops/{sop_id}")
def get_sop(sop_id: str, db: Session = Depends(get_db)): def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
return sop.to_dict() return sop.to_dict()
@app.delete("/api/sops/{sop_id}") @app.delete("/api/sops/{sop_id}")
def delete_sop(sop_id: str, db: Session = Depends(get_db)): def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
db.delete(sop) db.delete(sop)
db.commit() db.commit()
return {"deleted": sop_id} return {"deleted": sop_id}
@@ -204,8 +454,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
# ── Work Packages ──────────────────────────────────────────────────────────── # ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps") @app.post("/api/wps")
def upsert_wp(body: WpIn, db: Session = Depends(get_db)): def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
wp = db.get(models.WorkPackage, body.id) if body.id else None wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None:
require_project_access(db, user, wp.project_id)
if wp is None: if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp")) wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp) db.add(wp)
@@ -229,6 +482,8 @@ def list_wps(
sop_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
full: bool = Query(False),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
stmt = select(models.WorkPackage) stmt = select(models.WorkPackage)
@@ -240,12 +495,15 @@ def list_wps(
stmt = stmt.where(models.WorkPackage.parent_id == parent_id) stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
if status: if status:
stmt = stmt.where(models.WorkPackage.status == status) stmt = stmt.where(models.WorkPackage.status == status)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all() rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
return [w.summary() for w in rows] # full=true includes the data JSON (full package document) so the creator can
# rehydrate everything in one request; default stays lean for listing.
return [(w.to_dict() if full else w.summary()) for w in rows]
@app.get("/api/wps/metrics") @app.get("/api/wps/metrics")
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Aggregates for the dashboard. Masters (data.split == true) are excluded """Aggregates for the dashboard. Masters (data.split == true) are excluded
from counts so a split package's hours aren't double-counted with its from counts so a split package's hours aren't double-counted with its
instances.""" instances."""
@@ -254,6 +512,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
stmt = stmt.where(models.WorkPackage.project_id == project_id) stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id: if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id) stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt).all() rows = db.scalars(stmt).all()
by_status: dict[str, int] = {} by_status: dict[str, int] = {}
@@ -287,30 +546,33 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
@app.get("/api/wps/{wp_id}") @app.get("/api/wps/{wp_id}")
def get_wp(wp_id: str, db: Session = Depends(get_db)): def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
return wp.to_dict() return wp.to_dict()
@app.delete("/api/wps/{wp_id}") @app.delete("/api/wps/{wp_id}")
def delete_wp(wp_id: str, db: Session = Depends(get_db)): def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
db.delete(wp) db.delete(wp)
db.commit() db.commit()
return {"deleted": wp_id} return {"deleted": wp_id}
@app.post("/api/wps/{wp_id}/issue") @app.post("/api/wps/{wp_id}/issue")
def issue_wp(wp_id: str, db: Session = Depends(get_db)): def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Release a Work Package to the field. Refuses if any constraint is still """Release a Work Package to the field. Refuses if any constraint is still
open (the AWP release gate).""" open (the AWP release gate)."""
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
constraints = (wp.data or {}).get("constraints") or [] constraints = (wp.data or {}).get("constraints") or []
open_names = [c.get("name") for c in constraints if c.get("status") == "open"] open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
if open_names: if open_names:
@@ -323,10 +585,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
@app.post("/api/wps/{wp_id}/status") @app.post("/api/wps/{wp_id}/status")
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)): def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
wp.status = body.status wp.status = body.status
if body.status == "Issued" and wp.issued_at is None: if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()
@@ -385,3 +648,16 @@ def list_comments(
stmt = stmt.where(models.Comment.step == step) stmt = stmt.where(models.Comment.step == step)
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all() rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
return [c.to_dict() for c in rows] 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
View 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()

View File

@@ -1,28 +1,49 @@
"""Database engine and session setup. """Database engine and session setup.
The connection string comes from the DATABASE_URL environment variable, e.g. Connection precedence:
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite 1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is
built with SQLAlchemy's URL.create(), which encodes the password for you,
so passwords with special characters (@ ! # : / …) need NO manual escaping.
Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432.
2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly
(you must URL-encode any special characters in the password yourself).
3. Neither set → a local SQLite file, so the API runs anywhere without Postgres.
If unset, it falls back to a local SQLite file so the API can be run and tested The schema is identical either way (SQLAlchemy handles dialect differences).
on any machine without Postgres. The schema is identical either way (SQLAlchemy
handles the dialect differences).
""" """
import os import os
from sqlalchemy import create_engine from sqlalchemy import create_engine, URL
from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy.orm import sessionmaker, DeclarativeBase
# Load a local .env if present (dev convenience). In production the DATABASE_URL # Load a local .env if present (dev convenience).
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
except Exception: except Exception:
pass pass
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it. def _resolve_url():
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} user = os.getenv("POSTGRES_USER")
pw = os.getenv("POSTGRES_PASSWORD")
dbname = os.getenv("POSTGRES_DB")
if user and pw and dbname:
# Build from components — password is encoded automatically.
return URL.create(
"postgresql+psycopg",
username=user, password=pw,
host=os.getenv("POSTGRES_HOST", "db"),
port=int(os.getenv("POSTGRES_PORT", "5432")),
database=dbname,
)
return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db"
DATABASE_URL = _resolve_url()
# SQLite needs this flag from FastAPI's threadpool; Postgres ignores it.
_is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
connect_args = {"check_same_thread": False} if _is_sqlite else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True) engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)

144
server/manage_users.py Normal file
View 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()

View File

@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .db import Base from .db import Base
@@ -111,6 +111,49 @@ class WorkPackage(Base):
return {**self.summary(), "data": self.data or {}} 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 ProjectMember(Base):
"""Which users may access which projects. A user sees/operates on a project
only if a row links them to it (admins bypass this entirely). One row per
(user, project) pair."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Comment(Base): class Comment(Base):
__tablename__ = "comments" __tablename__ = "comments"

View File

@@ -5,3 +5,5 @@ sqlalchemy>=2.0
psycopg[binary]>=3.1 psycopg[binary]>=3.1
pydantic>=2.6 pydantic>=2.6
python-dotenv>=1.0 python-dotenv>=1.0
bcrypt>=4.1 # password hashing
PyJWT>=2.8 # signed session tokens