Compare commits

..

17 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
ca8c36a889 Add in-site Admin Console (gated diagnostics + tests)
html/admin.html + admin.js — a passphrase-gated console served at /admin.html:
- API connectivity check (clearly flags the /api/ 404 if the proxy isn't routing).
- Database snapshot (project/SOP/WP/comment counts via the API).
- End-to-end smoke test in the browser (mirrors smoketest.py: issue gate,
  status, metrics, comments) with self-cleanup.
- Demo data: seed a DEMO project + clean DEMO-/SMOKE- projects.

Gate is SHA-256-based (default passphrase "prime-admin"; documented how to
change) — obfuscation only, not real auth; restrict at the network/proxy for
real protection. Not linked from the main nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:06:04 -07:00
39230adf07 Merge fix/round1-feedback into main
Round-1 test feedback + verification tooling:
- Constraints: fix custom constraints never appearing; free-text add + remove.
- Sources: column headers; preset (hard-coded) data types, Add Source for extras.
- Issuance strategy: tooltip + worked examples.
- Remove "comment submitted" popups; keep commenter name.
- WP: offer to issue when the last constraint clears; collapsible sections.
- server/smoketest.py (end-to-end API/SQL check) and server/seed_demo.py
  (loadable demo project); DEPLOYMENT.md documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:50:04 -07:00
2bdb65e580 Add seed_demo.py loadable demo project + document both test scripts
server/seed_demo.py seeds a realistic DEMO project (complete SOP + a spread of
Work Packages: issued, gated, multi-discipline master with split instances,
overdue, over-threshold draft) via the API. --clean removes it. DEPLOYMENT.md
documents both smoketest.py and seed_demo.py, including the localStorage caveat
(seeded project shows in the UI picker; seeded SOP/WPs are SQL-only until Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:11 -07:00
e3ef3b0023 Round-1 test feedback + smoke-test script
- API smoke test (server/smoketest.py): stdlib end-to-end check of health,
  projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics,
  comments, and cascade delete. Referenced from DEPLOYMENT.md.

SOP config:
- Constraints: fix custom constraints never appearing — renderStandardConstraints
  no longer clobbers state.constraints; customs render in their own list with
  remove buttons; modal gains a free-text "Add" field.
- Sources: add column headers (Data Type / Location-Platform / URL / Notes);
  preset data types are now fixed labels, "Add Source" creates an editable
  custom row.
- Issuance strategy: add a tooltip + worked examples for each option.
- Remove the "Comment submitted" acknowledgement popup (home + suite); keep the
  commenter name between comments.

WP creator:
- Clearing the last open constraint now offers to mark the package Issued and
  scrolls to the status control.
- Form sections are collapsible (click a section heading to fold it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:04:46 -07:00
c64b5c8b49 Merge feat/gui-polish into main
Integrates the full feature set built on top of the Docker/Postgres deployment:
- Discipline strategy, per-discipline scope/status, Split by Discipline (WP01A/B/C),
  material-by-discipline.
- WP Dashboard (metrics, gating, filters) + backend issue/status/metrics endpoints.
- Multi-project support: projects entity + CRUD, project picker home page,
  per-project data isolation.
- WP sizing presets, Duplicate WP, custom WP types, menu/UX cleanup.
- GUI polish: Help section, tooltips, sticky save bar + section nav, status pills.
- Rewritten DEPLOYMENT.md for the SQL-backed Docker stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:36:39 -07:00
29 changed files with 2980 additions and 167 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
@@ -106,6 +113,49 @@ create a project**. Create one, complete an SOP, and confirm a row appears:
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;" docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
``` ```
### Automated smoke test
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
cleanup). Stdlib only — no pip/jq.
```bash
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly):
docker compose exec api python /app/server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI.
```
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
working. It cleans up after itself (the test project and its SOP/WPs are
deleted via cascade); a single tagged test comment remains (there's no comment
delete endpoint).
### Loadable demo project
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
a spread of Work Packages: issued, gated, a multi-discipline master with split
instances, an overdue one, an over-threshold draft) so there's data to look at.
```bash
python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
```
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> the home-page project picker right away (this is the visible proof that the
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> written to SQL too, but the current front end still reads SOPs/WPs from the
> browser, so they won't render in the Creator/Dashboard until the Phase 2
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;"
> ```
--- ---
## What is stored in SQL today ## What is stored in SQL today

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:

159
html/admin.html Normal file
View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<style>
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:#1e54bb; color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:6px; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
</style>
</head>
<body>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
<h2>Admins only</h2>
<p class="sub" style="margin:0 0 12px">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
</div>
</div>
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
<!-- CONNECTIVITY -->
<div class="card">
<h2>API connectivity</h2>
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="banner" id="health-banner"></div>
</div>
<!-- USER ADMINISTRATION -->
<div class="card">
<h2>User administration</h2>
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
<div id="users-banner"></div>
<div id="users-table" style="margin-top:12px"></div>
<h2 style="margin-top:22px">Add a user</h2>
<div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
<button class="primary" onclick="createUser()">Create user</button>
</div>
<div id="users-create-msg" class="note"></div>
</div>
<!-- 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 -->
<div class="card">
<h2>Database snapshot</h2>
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div>
<!-- SMOKE TEST -->
<div class="card">
<h2>End-to-end smoke test</h2>
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre>
</div>
<!-- DEMO DATA -->
<div class="card">
<h2>Demo data</h2>
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="row">
<button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div>
<pre class="out" id="demo-out">Ready.</pre>
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
</div>
</div>
<script src="admin.js"></script>
</body>
</html>

386
html/admin.js Normal file
View File

@@ -0,0 +1,386 @@
/* Admin console for the Work Package Suite.
Browser-side diagnostics + tests that call the same /api on this host.
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
already requires a login (redirecting to login.html otherwise) and publishes
window.WP_USER; here we show the console only when that user is an admin, and
show an "Admins only" notice otherwise. Every user-management API is also
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
function reveal(){
document.getElementById('admin-main').style.display='';
checkHealth();
loadUsers();
loadComments();
loadUsage();
}
function showDenied(){
document.getElementById('admin-denied').style.display='';
}
// ── api helper ──────────────────────────────────────────────────────────────
async function api(method, path, body){
const opt = { method, headers:{ 'Accept':'application/json' } };
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
try {
const r = await fetch(path, opt);
const t = await r.text();
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
return { status:r.status, json };
} catch(e){ return { status:0, json:String(e) }; }
}
// ── connectivity ──────────────────────────────────────────────────────────────
async function checkHealth(){
const b = document.getElementById('health-banner');
b.className='banner'; b.textContent='Checking…';
const { status, json } = await api('GET','/api/health');
if(status===200 && json && json.ok){
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
} else if(status===404){
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
} else if(status===0){
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
} else {
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
}
}
// ── db snapshot ───────────────────────────────────────────────────────────────
async function snapshot(){
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
const [p,s,w,c] = await Promise.all([
api('GET','/api/projects'), api('GET','/api/sops'),
api('GET','/api/wps'), api('GET','/api/comments')]);
if(p.status!==200){
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
}
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
out.innerHTML = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
}
// ── smoke test ────────────────────────────────────────────────────────────────
function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
async function runSmokeTest(){
const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('<span class="p">PASS</span> '+name); }
else { fail++; smLog('<span class="f">FAIL</span> '+name+(detail?' ('+detail+')':'')); } return cond; };
try {
let r = await api('GET','/api/health');
if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
}
r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
} catch(e){ chk('unexpected error', false, String(e)); }
finally {
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
finishSmoke(pass,fail);
}
}
function finishSmoke(pass,fail){
const total=pass+fail;
smLog('\n'+pass+'/'+total+' checks passed.');
smLog(fail ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
}
// ── demo data ─────────────────────────────────────────────────────────────────
function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
.map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
async function seedDemo(){
const o=document.getElementById('demo-out'); o.innerHTML='';
let r = await api('GET','/api/health');
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
const pid=r.json.id; demoLog('Project created: '+r.json.name);
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 35 days (≈4080 hrs)',sizeHoursMax:'80'}}});
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
const masterId='wp_demo_master_chiller';
const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
const kidIds=[];
for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
r = await api('GET','/api/wps/metrics?project_id='+pid);
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
snapshot();
}
async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
const r = await api('GET','/api/projects');
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; }
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
demoLog('\n✅ Removed '+targets.length+' project(s).');
snapshot();
}
// ── user administration ────────────────────────────────────────────────────────
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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.
<ol> var TOPICS = [
<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> { id: 'overview', title: 'Getting started', body: `
<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> <h3>Getting started</h3>
<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> <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>
<li><strong>Dashboard</strong> — track status, hours, and what's gating each package across the project.</li> <ol>
</ol> <li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance &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, clear their constraints, and issue them to the field.</li>
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
</ol>
<h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
<h4>Key concepts</h4> { id: 'projects', title: 'Projects', body: `
<ul> <h3>Projects</h3>
<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> <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>
<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> <h4>Project fields</h4>
<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> <ul>
<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>Project Name</strong> (required)</li>
</ul> <li><strong>Project Number</strong></li>
<li><strong>Client</strong></li>
<li><strong>Division / Sector</strong></li>
<li><strong>Site / Location</strong></li>
</ul>
<h4>The active project</h4>
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
<h4>Tips</h4> { id: 'sop', title: 'SOP Configuration', body: `
<ul> <h3>SOP Configuration (10 steps)</h3>
<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> <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>
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li> <ol>
<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>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
</ul>`; <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>
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li>
<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(); }
@@ -664,9 +668,7 @@
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment }); if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
document.getElementById('comment-text').value = ''; document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadComments(); loadComments();
alert('Thank you! Feedback submitted.');
} }
function exportFeedback() { function exportFeedback() {
@@ -705,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'));
restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. // Pull the project's shared SOP from the server into the local cache, THEN
const tab = params.get('tab'); // restore it. Falls back to the local cache if offline.
if(params.get('view') === 'dashboard') switchTool('dashboard'); function afterPull(){
else if(tab === 'wp' || tab === 'sop') switchTool(tab); restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard');
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;
@@ -477,18 +493,45 @@ function removeRole(i){
renderOptionalRoles(); renderOptionalRoles();
} }
// Seed the standard 10 once; after that, render reflects state.constraints
// (checkbox = whether each standard one is active) and never clobbers customs.
let _constraintsSeeded = false;
function renderStandardConstraints(){ function renderStandardConstraints(){
const container = document.getElementById('standard-constraints'); const container = document.getElementById('standard-constraints');
if(!_constraintsSeeded){
if(!state.constraints || !state.constraints.length){
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
}
_constraintsSeeded = true;
}
const active = name => state.constraints.some(c=>c.name===name);
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>` container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;"> <div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;"> <input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
<div style="flex:1;"> <div style="flex:1;">
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label> <label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div> <div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div> </div>
</div> </div>
`).join(''); `).join('');
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c})); renderCustomConstraints();
}
// Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return;
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
el.innerHTML = customs.length ? customs.map(c=>`
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<strong>${escAttr(c.name)}</strong>
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
function removeCustomConstraint(name){
state.constraints = state.constraints.filter(c=>c.name!==name);
renderCustomConstraints();
} }
function toggleConstraint(name){ function toggleConstraint(name){
@@ -514,13 +557,24 @@ function closeConstraintModal(){
} }
function addCustomConstraint(name){ function addCustomConstraint(name){
if(!state.constraints.find(c=>c.name===name)){ if(name && !state.constraints.find(c=>c.name===name)){
state.constraints.push({name,description:''}); state.constraints.push({name,description:''});
} }
closeConstraintModal(); closeConstraintModal();
renderStandardConstraints(); renderStandardConstraints();
} }
// Free-text custom constraint from the modal's input.
function addCustomConstraintText(){
const inp = document.getElementById('custom-constraint-input');
const name = (inp && inp.value || '').trim();
if(!name){ if(inp) inp.focus(); return; }
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
state.constraints.push({name, description:''});
if(inp) inp.value='';
renderStandardConstraints();
}
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning']; const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
let seqDragIndex = null; let seqDragIndex = null;
@@ -600,20 +654,30 @@ const DEFAULT_SOURCES = [
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function renderSources(){ function renderSources(){
const container = document.getElementById('sources-list'); const container = document.getElementById('sources-list');
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph})); if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
container.innerHTML = state.sources.map((s,i)=>` const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> <div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> </div>`;
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> container.innerHTML = header + state.sources.map((s,i)=>{
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button> // Preset data types are fixed labels; custom rows (Add Source) get an editable name.
</div> const dataType = s.preset
`).join(''); ? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
${dataType}
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
</div>`;
}).join('');
} }
function addSource(){ function addSource(){
state.sources.push({label:'',system:'',notes:'',link:''}); // Added rows are custom — the user types their own data type here.
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
renderSources(); renderSources();
} }
@@ -788,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
@@ -805,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; }
@@ -823,9 +902,7 @@ function submitComment(){
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment}); if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
document.getElementById('comment-text').value = ''; document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadStepComments(); loadStepComments();
alert('✓ Comment submitted!');
} }
function exportComments(){ function exportComments(){

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>
@@ -172,14 +172,23 @@
<small>Use ## for counter, [Sector] [TYPE] as variables</small> <small>Use ## for counter, [Sector] [TYPE] as variables</small>
</div> </div>
<div class="field"> <div class="field">
<label>Issuance Strategy</label> <label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
<select id="gov_issuance" multiple size="3"> <select id="gov_issuance" multiple size="4">
<option selected>By Sector / Area</option> <option selected>By Sector / Area</option>
<option>By Discipline</option> <option>By Discipline</option>
<option>By Phase / Sequence</option> <option>By Phase / Sequence</option>
<option>By Resource Availability</option> <option>By Resource Availability</option>
</select> </select>
<small>Hold Ctrl to select multiple</small> <small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
<strong>Examples:</strong>
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
</ul>
</div>
</div> </div>
</div> </div>
@@ -288,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>
@@ -319,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>
@@ -332,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>
@@ -347,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>
@@ -360,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>
@@ -374,7 +383,12 @@
<h3>Add Custom Constraint</h3> <h3>Add Custom Constraint</h3>
<button class="modal-close" onclick="closeConstraintModal()"></button> <button class="modal-close" onclick="closeConstraintModal()"></button>
</div> </div>
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div> <div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
<button class="nav-btn" onclick="closeConstraintModal()">Done</button> <button class="nav-btn" onclick="closeConstraintModal()">Done</button>
</div> </div>
</div> </div>

View File

@@ -529,6 +529,20 @@ function setConstraint(i,val){
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){ if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
prevStatus=getRadio('status'); holdContext={index:i, before}; prevStatus=getRadio('status'); holdContext={index:i, before};
openHoldModal(pkgConstraints[i].name, true); openHoldModal(pkgConstraints[i].name, true);
return;
}
// Clearing the LAST open constraint makes the package release-ready — offer to
// issue it and scroll up to the status control so the change is visible.
if(before==='open' && val!=='open' && readiness().open===0){
const st=getRadio('status');
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'});
}
const sg=document.getElementById('status-group');
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
}
} }
} }
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; } function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
@@ -673,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);
} }
@@ -771,7 +786,24 @@ function setFormChrome(on){
if(nav) nav.style.display = on ? '' : 'none'; if(nav) nav.style.display = on ? '' : 'none';
if(save) save.style.display = on ? 'flex' : 'none'; if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on); document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); } if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
}
// Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){
document.querySelectorAll('.main > .card').forEach(card=>{
if(card.id==='saved-card') return;
const head=card.querySelector('.section-header, .sub-heading');
if(!head || head.dataset.collapsible) return;
head.dataset.collapsible='1';
head.style.cursor='pointer';
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
head.insertBefore(chev, head.firstChild);
head.addEventListener('click', e=>{
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
const collapsed=card.classList.toggle('collapsed');
chev.textContent = collapsed ? '▸' : '▾';
});
});
} }
function buildSectionNav(){ function buildSectionNav(){
const nav=document.getElementById('section-nav'); if(!nav) return; const nav=document.getElementById('section-nav'); if(!nav) return;
@@ -815,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){
@@ -885,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.');
@@ -920,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:''};
@@ -1113,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 {
@@ -1131,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(); }
})(); }
setRadio('status','Draft'); function bootData(){
renderSavedList(); loadStore(); // reads the localStorage cache (hydrated from the server below)
cmtInit(); bootSOP();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). setRadio('status','Draft');
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })(); renderSavedList();
cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
track('app_open');
}
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); }); window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
track('app_open'); // 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

@@ -564,6 +564,11 @@
.so-date { font-size:13px; font-variant-numeric:tabular-nums; } .so-date { font-size:13px; font-variant-numeric:tabular-nums; }
.so-ovr { margin-left:8px; font-size:11px; } .so-ovr { margin-left:8px; font-size:11px; }
/* Collapsible form sections */
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
.card.collapsed .section-desc { display:none; }
/* Section nav (jump chips) */ /* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px; .section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px); padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);

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

175
server/seed_demo.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Seed a realistic DEMO project into the Work Package Suite database via the API.
Creates one project, a complete SOP, and a spread of Work Packages that exercise
the features and dashboard: an issued package, a gated (open-constraint) package,
a multi-discipline master with its split instances (A/B/C), an overdue package,
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
and to have data to inspect.
USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
IMPORTANT — what shows where:
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
project picker immediately (proves the projects → SQL path in the UI).
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
front end still reads SOPs/WPs from the browser (localStorage), so they will
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
wiring. Verify them at the SQL/API layer instead:
python3 server/smoketest.py <url> # automated end-to-end check
docker compose exec db psql -U wpsuite -d wpsuite \
-c "select number,subject,status from work_packages order by number;"
"""
import argparse
import json
import ssl
import sys
import urllib.error
import urllib.request
BASE = ""
CTX = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
def call(method, path, body=None):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = raw
return status, parsed
def constraints(open_names=()):
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
return [{"name": n, "status": ("open" if n in open_names else "cleared"),
"comment": ("awaiting delivery" if n in open_names else "")} for n in base]
def main():
global BASE, CTX
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (default: http://localhost:8000)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
# health gate
try:
st, _ = call("GET", "/api/health")
except urllib.error.URLError as e:
print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
st, projects = call("GET", "/api/projects")
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
if args.clean:
for p in demos:
call("DELETE", f"/api/projects/{p['id']}")
print(f"Removed {len(demos)} DEMO project(s).")
return 0
if demos:
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
# 1) Project
st, proj = call("POST", "/api/projects", {
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
"client": "Micron Technology, Inc.", "division": "Semiconductor",
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
pid = proj["id"]
print(f"Project: {proj['name']} ({pid})")
# 2) SOP (complete)
st, sop = call("POST", "/api/sops", {
"project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True,
"created_by": "seed_demo",
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
"disciplines": ["Mechanical", "Electrical", "Tech"],
"discMode": "choice", "instanceSuffix": "letter",
"woSize": "Standard — 35 days (≈4080 hrs)", "sizeHoursMax": "80"}}})
sid = sop["id"]
print(f"SOP: complete ({sid})")
# 3) Work packages
def wp(number, subject, typ, status, data, parent_id=None):
body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject,
"type": typ, "status": status, "created_by": "seed_demo", "data": data}
if parent_id:
body["parent_id"] = parent_id
st, w = call("POST", "/api/wps", body)
print(f" WP {number:<16} {status:<12} {subject}")
return w
# a) issued, all clear
wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued",
{"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": constraints(), "due": "2026-06-30"})
# b) gated — one open constraint, still Scheduled
wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled",
{"disciplines": ["Electrical"], "hours": "60", "actualHrs": "",
"constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"})
# c) multi-discipline master + split instances (master excluded from metrics)
master_id = "wp_demo_master_chiller"
instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"),
("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"),
("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")]
child_ids = []
for num, disc, label, typ, status in instances:
cid = f"wp_demo_{label.lower()}"
child_ids.append(cid)
body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid,
"number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status,
"created_by": "seed_demo",
"data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label,
"parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "",
"constraints": constraints(), "due": "2026-07-10"}}
call("POST", "/api/wps", body)
print(f" WP {num:<16} {status:<12} (instance {label})")
wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled",
{"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids,
"hours": "150", "constraints": constraints(), "due": "2026-07-10"})
call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id,
"number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)",
"type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo",
"data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True,
"children": child_ids, "hours": "150", "constraints": constraints(),
"due": "2026-07-10"}})
# d) overdue, in progress
wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress",
{"disciplines": ["Tech"], "hours": "30", "actualHrs": "20",
"constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue
# e) over-threshold draft (hours > 80)
wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft",
{"disciplines": ["Electrical"], "hours": "120", "actualHrs": "",
"constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"})
# metrics readback
st, m = call("GET", f"/api/wps/metrics?project_id={pid}")
print(f"\nMetrics (masters excluded): {m}")
print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.")
print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.")
print("Remove later with: python3 server/seed_demo.py <url> --clean")
return 0
if __name__ == "__main__":
sys.exit(main())

196
server/smoketest.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
Exercises the real HTTP endpoints the way the front end does, proving that
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
USAGE
# Against the deployed site (through the NGINX proxy):
python3 server/smoketest.py https://wp-suite.company.local
# Self-signed / internal TLS cert? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly):
docker compose exec api python /app/server/smoketest.py http://localhost:8000
# Leave the demo project in the database so you can open it in the UI:
python3 server/smoketest.py https://wp-suite.company.local --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit code 0 = all checks passed, 1 = one or more failed.
"""
import argparse
import json
import ssl
import sys
import urllib.error
import urllib.request
# ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], []
def _c(s, code): # color if a TTY
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
def check(name, cond, detail=""):
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
return cond
BASE = ""
CTX = None
def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, method=method,
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = raw
return status, parsed
def main():
global BASE, CTX
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (default: http://localhost:8000)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
project_id = None
try:
# 1) Health — API is up and reachable through the proxy.
try:
st, body = call("GET", "/api/health")
except urllib.error.URLError as e:
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
" Is the stack up (docker compose ps) and the URL correct?\n")
return 1
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}")
# 2) Create a project (writes to the projects table).
st, proj = call("POST", "/api/projects", {
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
"client": "Internal QA", "division": "Controls", "site": "Test Host",
"created_by": "smoketest",
})
project_id = proj.get("id") if isinstance(proj, dict) else None
check("create project", st == 200 and bool(project_id), f"status={st}")
# 3) Read it back + confirm it's in the list (SQL round-trip).
st, got = call("GET", f"/api/projects/{project_id}")
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
st, lst = call("GET", "/api/projects")
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
# 4) Create a SOP linked to the project.
st, sop = call("POST", "/api/sops", {
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
"complete": True, "created_by": "smoketest",
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
})
sop_id = sop.get("id") if isinstance(sop, dict) else None
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
f"status={st}")
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
# 5) Create a Work Package with one OPEN constraint (not release-ready).
st, wp = call("POST", "/api/wps", {
"project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
"status": "Scheduled", "created_by": "smoketest",
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
})
wp_id = wp.get("id") if isinstance(wp, dict) else None
check("create work package", st == 200 and bool(wp_id), f"status={st}")
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
"status": "Scheduled",
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
})
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
f"status={st}")
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
# 8) Status transition endpoint.
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
f"status={st} metrics={m}")
# 10) Comment / feedback write + read.
st, c = call("POST", "/api/feedback", {
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
f"status={st}")
# 11) WPs filter by project.
st, wps = call("GET", f"/api/wps?project_id={project_id}")
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
finally:
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
if project_id and not args.keep:
st, _ = call("DELETE", f"/api/projects/{project_id}")
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
st, after = call("GET", f"/api/wps?project_id={project_id}")
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
f"status={st} remaining={after}")
elif project_id and args.keep:
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
# ── summary ────────────────────────────────────────────────────────────────
total = len(_PASS) + len(_FAIL)
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
if _FAIL:
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
for f in _FAIL:
print(" - " + f)
print("\nResult: " + _c("FAIL", "31") + "\n")
return 1
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())