Compare commits

..

1 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
70 changed files with 999 additions and 11013 deletions

11
.gitignore vendored
View File

@@ -15,16 +15,5 @@ wpsuite.db
# Runtime directories (created by containers) # Runtime directories (created by containers)
logs/ logs/
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
/backups/*
!/backups/.gitkeep
# Local server logs # Local server logs
*.log *.log
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
_*.txt
cookies.txt
# Claude Code local workspace (agent memory, session data)
.claude/

View File

@@ -1,290 +0,0 @@
# Deploy runbook — WP Suite
**For:** IT / whoever administers the Docker host and Portainer
**From:** n.siegfried@prime-controls.com
**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure,
but the deploy now carries a second database migration and a new admin screen. If
you already have the earlier copy, work from this one instead.
**Expected duration:** 1015 minutes, including the backup
**Expected downtime:** under a minute, while containers are recreated
---
## Fill these in before handing this over
| Thing | Value |
|---|---|
| Docker host (SSH target) | `________________` |
| Stack name in Portainer | `________________` |
| Site URL | `https://________________` |
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
Container names are fixed by the compose file and are the same on every host:
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
---
## What this deploy changes
Front-end and nginx changes, a new admin screen, plus pending database migrations
that run automatically. Three things make it more than a routine restart:
1. **The nginx config and the entire `html/` directory are baked into the
container image at build time.** A plain restart deploys nothing — the stack
must be re-pulled and re-built.
2. **A pending migration rewrites existing rows** in the `users.role` column
(`b41c7ae90d52`, values `user``project_user`). That is why step 1 is a backup
and not optional. If a previous deploy already applied it, it will not run again —
step 0 tells you which of these you are actually about to run.
3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on
projects, and two default-membership fields on users). This one is additive and
has database defaults for existing rows, so it does not rewrite anything.
For the people using the app, the visible changes are: projects can now be
**archived** from the Admin Console (they disappear from the pickers and go
read-only, and can be brought back), certain users can be set to join **every new
project automatically**, and the Admin Console has been rebuilt so the user table
fits on screen.
Migrations run themselves when the `wp_api` container starts. There is nothing
to type and **no new environment variables** — do not change the stack's
environment variables.
---
## Step 0 — Record the current state (needed for rollback)
SSH to the Docker host and run:
```bash
docker exec wp_api alembic -c server/alembic.ini current
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
docker inspect wp_api --format 'api image: {{.Image}}'
```
**Copy the output into your ticket.** Also note the Git commit the Portainer
stack is currently on (Portainer → the stack → the Git reference / last-updated
commit). Without these, rollback is guesswork.
The first command prints the migration the database is currently on. Use it to see
which migrations this deploy will actually run:
| `alembic current` shows | What will run | What that means |
|---|---|---|
| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. |
| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. |
| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. |
Take the backup either way.
---
## Step 1 — Back up the database
On the Docker host:
```bash
docker exec wp_db_backup /scripts/db-backup.sh
```
This triggers the stack's existing backup sidecar once, on demand. Expected
output ends with a line like:
```
[db-backup] wrote 1.4M /backups/wpsuite-20260804-141233Z.sql.gz.enc
```
Confirm the file is on the host (substitute the stack directory):
```bash
ls -lt <stack-dir>/backups | head -3
```
**Record that filename.** Do not continue until you have seen the `wrote …`
line and the file in that listing.
- A `.sql.gz.enc` extension means backups are encrypted — expected and correct.
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
means backups are unencrypted. Not a blocker for this deploy; report it back.
- **No SSH access?** Portainer → **Containers**`wp_db_backup`**Console**
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
dump lands on the host, because `/backups` is a bind mount.
---
## Step 2 — Redeploy the stack in Portainer
1. Portainer → **Stacks** → select the stack.
2. **Pull and redeploy** — with re-pull / re-build **enabled**.
3. Wait for it to report success.
A plain "restart" or "stop/start" will **not** deploy this change. See "What
this deploy changes" above.
---
## Step 3 — Confirm the containers came up
```bash
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
```
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
applied its migrations cleanly:
```bash
docker logs wp_api --tail 40
```
You are looking for Alembic `Running upgrade …` lines followed by gunicorn
starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The
API deliberately refuses to start if a migration fails, so a restarting `wp_api`
container means the migration failed — go to Rollback.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `a7c31f9e5b02 (head)`.
Then verify nginx's own view of its config:
```bash
docker exec nginx_webserver nginx -t
```
Expected: `syntax is ok` / `test is successful`.
---
## Step 4 — Confirm the response headers
```bash
curl -sI https://<site-url>/work-package-suite.html | grep -Ei 'cache-control|content-security-policy'
```
Add `-k` if the site uses an internal or self-signed certificate.
**Both lines must come back.** Expected, approximately:
```
cache-control: no-cache, must-revalidate
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...
```
If the `content-security-policy` line is **missing** while `cache-control` is
present, the deploy is bad — go to Rollback and send me the nginx log. (This is
the specific regression this deploy fixes; the two headers must coexist.)
Also confirm the API is reachable through the proxy:
```bash
curl -s https://<site-url>/api/health # → {"ok": true}
```
---
## Step 5 — Hard-reload once in a browser
Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app
uses a service worker; a normal reload can serve the previous version and make a
good deploy look broken.
Sanity checks — all four should take under a minute:
1. Log in. The home page offers to select or create a project.
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
home page). The table should read as **one line per user** — if rows are three lines
tall and the table spills outside its white card, you are still on the old cached
files: hard-reload again.
> Changed since this runbook was written: user accounts moved out of the Admin
> Console into `users.html` when the **Project Super User** role was added, so that
> a project admin can create accounts on their own job. If you are deploying a build
> from before that change, read this step as "Admin Console → the user table".
3. Open **Admin Console** (admin account required). Two new cards are present and
load: **Projects**, and **Default members on new projects**. Both should list rows,
not an error.
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived**
it should reappear marked `archived`. Click **Unarchive** to put it back. That
round trip proves the new migration and the new endpoint are both live.
**Deploy complete.** Please report back: the step 0 output (including which
migrations ran), the backup filename, and the two header lines from step 4.
---
## Rollback
Pick the case that matches.
### Case A — nginx won't start, or the CSP header is missing
The database is untouched by this, so this is a code-only rollback. In Portainer,
redeploy the stack pinned to the **previous Git commit** recorded in step 0
(Portainer → the stack → change the Git reference to that commit → Pull and
redeploy). Then re-run step 3 and step 4.
**Before you do:** grab the log, because it is what I need to fix this.
```bash
docker logs nginx_webserver --tail 100
```
Send me that output. If the container is in a restart loop the log still works.
### Case B — `wp_api` is restarting / a migration failed
```bash
docker logs wp_api --tail 100
```
Send me that output. **Do not restore the database and do not roll the API back
without contacting me first.** Which migration got as far as committing decides what
is safe, and they are not the same:
- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling
the API back to the previous image is safe on its own — the old code simply
ignores the extra columns. Nothing needs converting.
- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed,
rolling the API back without converting those values back **will break logins**.
That conversion is a one-line command, but it has to match what actually ran.
The `alembic current` output from step 0, plus the `Running upgrade …` lines in the
log above, are exactly what tells us which case you are in — please include both.
Reach me at n.siegfried@prime-controls.com.
### Case C — restoring the backup (only if I ask for it)
Destructive: this drops and recreates the current schema and data. For an
encrypted dump, on the Docker host, in the `backups` directory:
```bash
export BACKUP_ENC_PASSPHRASE='<the passphrase — from the stack env vars>'
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
-in wpsuite-<timestamp>.sql.gz.enc \
| gunzip \
| docker exec -i wp_db psql -U wpsuite -d wpsuite
unset BACKUP_ENC_PASSPHRASE
```
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
not `wpsuite`.
---
## Notes
- Do not add or change environment variables for this deploy.
- Do not run `docker compose down -v` — the `-v` flag deletes the `pgdata`
volume and with it the entire database.
- `docker compose …` commands are avoided throughout this runbook on purpose:
for a Portainer-managed Git stack the compose project lives under Portainer's
own data directory, so `docker compose` from an SSH session usually can't find
it. The `docker exec <container-name>` form used here works from any directory.
- Full background documentation: `DEPLOYMENT.md` in the repository.

View File

@@ -51,31 +51,9 @@ 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>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# is OFF by default and enabled from the Admin console; the host/port/from-address
# are configured there, but the password is only ever read from this variable
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
``` ```
The API builds its own DB connection string from the `POSTGRES_*` 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 values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL` characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other is **optional** and only needed if you want to point the API at some other
@@ -86,8 +64,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's > **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`). > **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` / > Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the These are the only credentials in the system, and they never appear in the
compose file or in git. compose file or in git.
@@ -100,14 +77,6 @@ chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api` container on that network. The container already proxies `/api/` to the `api`
service internally — no extra app config needed. service internally — no extra app config needed.
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
> reached over HTTPS.
## 4. Bring it up ## 4. Bring it up
From the project root: From the project root:
@@ -146,64 +115,20 @@ docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from proje
### Automated smoke test ### Automated smoke test
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in `server/smoketest.py` exercises the whole stack end-to-end (health → project
project → SOP → Work Package → the AWP issue gate → status → metrics → comments → SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq. cleanup). Stdlib only — no pip/jq.
It **signs in first**, because every `/api/` route except `/api/health` requires a
session. Credentials come from the environment so a password stays out of shell
history, and the account must be an **admin**: the run creates a project and deletes
it again, and archiving or deleting one takes Project Admin on it. The script checks
the signed-in role up front and warns if it is too low rather than letting you find
out in the cleanup step.
```bash ```bash
export WP_SMOKE_USER=<admin-account>
export WP_SMOKE_PASSWORD='…'
# Through the proxy (use --insecure for a self-signed internal cert): # Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly). Pass the vars through: # Or from inside the api container (hits FastAPI directly):
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \ docker compose exec api python /app/server/smoketest.py http://localhost:8000
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. # Add --keep to leave a demo project in the DB so you can open it in the UI.
# --user / --password override the environment if you'd rather be explicit.
``` ```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
could not start (host unreachable, or credentials missing or rejected). The last is
kept separate on purpose — "I could not test this" is a different answer from "this is
broken", and automation should not treat them alike.
### Front-end browser check
`tests/browser_check.py` is the other half: the smoke test proves the API works, this
proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools
Protocol and asserts what only a browser can settle — that each page boots without a
JavaScript error, that the role-dependent renderings are right, and that the layout
rules the console pages depend on are actually in effect.
Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects,
an admin, a Project Super User, a plain member, and accounts positioned to exercise
in-scope / out-of-scope / invisible), starts its own server on a free port, and tears
all of it down. **Your real database is never touched.** Stdlib only.
```bash
python tests/browser_check.py # everything, ~71 checks
python tests/browser_check.py --keep-server # leave it up to poke at by hand
WP_BROWSER=/path/to/chrome python tests/browser_check.py
```
Same exit codes as the smoke test, including **2** for "no browser found" — a missing
browser is not a failing app.
Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css`
or `html/admin.js`. It is the check that would have caught a rule lost while
`console.css` was being extracted out of `admin.html`, which is a silent, whole-page
regression that no server-side test can see.
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all 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 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 deleted via cascade); a single tagged test comment remains (there's no comment
@@ -220,10 +145,12 @@ 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 python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
``` ```
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work > **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> Packages** are all API/SQL-backed, so they appear in the home-page project > the home-page project picker right away (this is the visible proof that the
> picker and render in the Creator/Dashboard as soon as any user opens the > projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> project. Inspect them at the SQL layer with `smoketest.py` or: > 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 > ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \ > docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;" > -c "select number, subject, status from work_packages order by number;"
@@ -233,58 +160,42 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
## What is stored in SQL today ## What is stored in SQL today
The API + Postgres are the system of record. Everything below is server-stored Be aware of the current persistence split — the API + Postgres are fully
and shared across every user who opens the project: deployed, and:
| Data | Stored in PostgreSQL today? | | Data | Stored in PostgreSQL today? |
|------|------------------------------| |------|------------------------------|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. | | **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. | | **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. | | **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). | | **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
Saves go through a **durable client-side sync outbox**: edits are written to the So a fresh deployment gives you **shared, server-stored projects and comments
API immediately, and if the device is offline they queue and retry when it immediately**. Moving SOPs and Work Packages off the browser and onto the API
reconnects (4xx rejections are dropped rather than retried forever). The browser (so they're shared across users too) is a front-end change only — the database
cache is only an offline fallback that reconciles through that outbox — so two and endpoints are already in place.
users on the same project see the same server-stored SOP and Work Packages.
## Data model (PostgreSQL) ## Data model (PostgreSQL)
| Table | Holds | Key columns | | Table | Holds | Key columns |
|-------|-------|-------------| |-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` | | `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) | | `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) | | `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
The complete client document is stored verbatim in each row's `data` JSON The complete client document is stored verbatim in each row's `data` JSON
column; frequently-listed fields are promoted to real columns for filtering. column; frequently-listed fields are promoted to real columns for filtering.
### Endpoints (summary) ### Endpoints (summary)
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`, Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
`POST /api/projects/{id}/archive` ·
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` · SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`, Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`, `POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
`GET /api/wps/metrics` · Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` · List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
`exclude`** — anything that needs to see archived rows (the admin console, the demo
cleanup) must ask for them. Full reference and request shapes: `/api/docs` and
[`server/README.md`](server/README.md).
--- ---
@@ -298,224 +209,27 @@ docker compose up -d --build api # backend change (server/)
## Backups & retention ## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and The whole dataset is in the `pgdata` volume — back it up on a schedule:
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
stack — no cron to set up.
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env` ```bash
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many # Backup (run from project root)
to keep). docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
the passphrase somewhere other than this host; if you lose it the backups can't
be restored.
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
- **Restore (destructive — overwrites current data):**
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
`internal` network on purpose, so offsite must be pushed from the host.
- **Test restores quarterly:** load the latest dump into a throwaway database and
confirm it applies. An untested backup is not a backup.
## Field devices & data at rest # Restore
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
```
The field view (PWA) caches a project's Work Packages/SOP in the browser's ## Schema migrations (important)
localStorage so it works offline — i.e. **customer IP sits on the device**.
localStorage is not encrypted and is not a security boundary. Signing out clears
the cached project data, but for any tablet/phone that opens customer-IP projects:
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is Tables are auto-created on API startup (`Base.metadata.create_all`). This
encrypted by default) and a device passcode. creates **missing tables**, but it does **not** alter existing ones. The
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep multi-project work added the `projects` table and new columns
the browser profile per-user on shared devices. (`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
- Users should **sign out** when handing off a shared device (clears the cache).
## Email notifications (optional) - On a **fresh** database these appear automatically — nothing to do.
- On a database that **already has data** from an older schema, add the new
Work-package **owner assignment** works out of the box (in-app only). Optional columns with a migration (introduce **Alembic**) or apply them manually with
**email** on assignment is **OFF by default** and is turned on from the **Admin `ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
console → Notifications & email** card, where an admin sets the SMTP host / port /
TLS / From address and flips the master toggle.
- The **SMTP password is never stored in the database.** It is read only from the
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
still recorded in-app (status `skipped`); nothing is sent.
- Notification emails carry only a **WP number and a deep link** — never the work
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set on the **User Directory** page (`users.html`) — not the Admin Console,
which no longer manages accounts.
| Role | May do |
|---|---|
| `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before roles existed
carried the role `user`, which the migration rewrites to `project_user`.
### Project Super User — what bounds it
The role exists so a project admin can staff their own job without an app admin.
Its limits are what make it safe to hand out, and all of them are server-side
(`managed_project_ids`, `manage_user_problem`, `grantable_roles` in `server/app.py`):
* **Scope comes from projects, not the job title.** A super user administers the users
of the projects they hold the role on — via their account role, or via
`ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
changing permissions or deleting are global acts, so they are refused when the
target is also on a project the caller does not administer. The directory shows
those rows read-only with the reason. An app admin has to make the change.
* **No admin or super-user targets, and none granted.** A super user may hand out
`project_admin` / `project_user` only, and may not touch an admin's or another
super user's account — so the role cannot become a route to app-wide control.
* **Saving project access never reaches outside scope.** `PUT
/api/auth/users/{id}/projects` rebuilds only the caller's own slice; memberships on
projects they don't administer are left untouched.
* **App settings, feature flags and the default-member rule stay admin-only.**
No migration is needed for the new role — `users.role` is already `String(20)` and
`project_super_user` fits. Grant it from the User Directory (Permissions column), or
per project from **Project access → Project Super User here**.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
1. **the user's own preference** — *Language & time* in the top-right menu
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
2. **the app default** — Admin console → Features → *Localization defaults*
(`default_locale` / `default_timezone`)
3. **the browser**, as before
Timezone names are validated against the server's own `zoneinfo` database, and the
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
Calendar dates (a due date, a kitting date) are formatted from their parts and are
**never** shifted by a timezone — only real instants (MIMO windows, history,
notifications) are converted. Use the shared helpers (`wpFormatDate`,
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
`toLocaleString()`, or a page will quietly ignore the preference.
## Top-bar chrome (project switcher + search)
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
global search into whichever top bar a page has — the dark `.wp-appbar` or the
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages, archived projects, and
anything belonging to an archived project. LIKE wildcards in the query are escaped,
so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
pending migrations automatically**.
- The **baseline** migration is idempotent: on a fresh database it creates every
table; on a database whose tables already exist (made by the old `create_all`)
it adopts the schema as-is — no manual `alembic stamp` needed.
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
migrations-only.
- **To change the schema:** edit `server/models.py`, then generate and review a
migration before committing:
```bash
# from the project root (against your dev SQLite or a staging DB)
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
```
The next `docker compose up -d --build api` applies it in production on startup.
## Local trial without Postgres ## Local trial without Postgres
@@ -523,110 +237,3 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md) is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/` § *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable). (it falls back to browser storage when the API isn't reachable).
## Per-project permissions
`users.role` is the account's **default** permissions role. A membership row can
override it **per project** (`project_members.role`), so someone can be Project
Admin on one job and a plain Project User on another. Empty means "inherit the
account's role", which is how every pre-existing membership behaves.
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
so deleting a work package, changing a completed SOP and deleting a project are all
judged **on that project**. An app `admin` is admin everywhere and bypasses
membership entirely.
Set it in **Admin console → User administration → Project access** (its own column,
showing how many projects each account can reach). The dialog ticks project access
and picks the role on each; `/api/auth/users/{id}/projects` takes
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
project-scoped roles. Changes are audit-logged as `project_access_changed`.
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.
### Default members on new projects
Memberships are also created automatically. **Admin console → Default members on
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
the PM who runs them all, the QC lead — with the role they should hold there
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
inherit the account's own).
- It applies **only to projects created after the flag is set**. Nothing is
back-filled onto existing jobs; use **Project access** for those.
- App admins are skipped (they already reach every project) and the flag is cleared
if an account is promoted to admin. Inactive accounts are skipped.
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
covers every route into project creation — the home page, the sample project, the
demo seeder. An update never re-runs it.
- If the creator is themselves a flagged member, the membership created for them as
creator carries their `auto_add_role`, so they aren't silently downgraded on the
one job they started.
- Audit-logged once per project as `project_access_granted` with
`detail.reason = "auto_add_projects"`.
## Archiving a project
A finished job is archived rather than deleted: `projects.archived_at`, set from
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
Project Admin **on that project**, same bar as deleting it).
An archived project is **hidden and frozen**:
- It leaves the home picker, the app-bar switcher and global search, because
`GET /api/projects` defaults to `archived=exclude`.
- It is still readable by id, so a deep link renders it — with a read-only banner
from `wp-chrome.js` — and the admin console still lists it under
`?archived=all`.
- Every write that lands on it is refused with **409** by
`require_project_writable()`: saving a project, SOP or work package, deleting
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
Moving a work package *into* or *out of* an archived project is refused too.
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
instead of retrying them forever.
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
project must accept, and archive-then-delete is a normal sequence.
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
asserts the whole round trip.
## Asset freshness (why the app can't run half-updated)
A page must never run against a stylesheet or script from a previous deploy. Three
things enforce that, and all three are needed:
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
roughly 10% of each file's age, so the least recently changed file gets the longest
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
make each revalidation a cheap 304.
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
default cache mode and consults the browser HTTP cache, so "network-first" alone
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
page the cache could still serve, and cache keys drop the query string so in-app
links (`?project=…&tab=…`) still resolve offline.
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
it), and the work-package panel injects a floor of positioning rules from
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
dumped loose into the form — that a missing rule turned into a broken page rather
than a plain one.
If you change the shell file list in `sw.js`, bump `CACHE`.
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
> at http level, applied with a single server-level `add_header`. Do **not** move it
> into a `location` block: nginx does not inherit `add_header` into a block that
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
> files. After deploying, confirm both are present on one response:
>
> ```bash
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
> ```

View File

@@ -4,8 +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
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is # --preload imports the app once in the master (so create_all runs a single time)
# safe on both fresh and existing databases (the baseline migration adopts an # before forking workers, preventing a table-creation race on first startup.
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
# for correct signal handling; --preload imports the app once before forking. "-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]

View File

@@ -1,163 +0,0 @@
# Known issues — Work Package Suite
Defects and limitations we know about and have decided not to fix yet. An entry
here is a commitment to a decision, not a bug tracker: it says what is wrong, what
it costs, why it is still open, and what closing it takes.
Anything genuinely urgent does not belong here — it belongs in the next deploy.
Close an entry by deleting it in the same commit that fixes it.
| # | Issue | Severity | Raised | Status |
|---|-------|----------|--------|--------|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
---
## 1. XSS via SOP discipline names in the WP creator
**Files:** `html/wp-creation-app.js` lines 684, 723, 727, 729 · escaping helper at
line 63
**Predates:** the 2026-08-05 archive/admin-console work. Not introduced by it.
### What is wrong
Discipline names are rendered into inline event handlers escaped with `esc()`,
which maps `'` to `&#39;`. That is correct for text and wrong here. The browser
decodes entities in an attribute value **before** the JavaScript parser sees it, so
`&#39;` becomes a bare `'` inside the handler's string literal and closes it early.
```js
// html/wp-creation-app.js:684 — esc() is not sufficient for a handler argument
onchange="toggleDiscipline('${esc(d)}',this.checked)"
```
Escaping for an inline handler has to happen in this order: **backslash, then
quote** (for the JS string literal), **then HTML** (for the attribute carrying it).
`esc()` only does the last part.
### How it is reached
1. `gov_disciplines` (`html/work-package-suite.html:219`) is a free-text field. Its
value is comma-split with no validation at `work-package-suite-app.js:1224`.
2. It is saved into `sops.data` and syncs to the server via `ProjectData.pushSOP`.
3. Every other member of that project pulls it with `pullProject()` and renders it
in the WP creator — so this is **stored** and **cross-user**, and it fires on
page load rather than needing the victim to click anything.
Any **project_user** on the job can set it while the SOP is a draft (after the SOP
is marked complete it takes project_admin). The victim is anyone who opens the WP
creator for that project, which includes administrators.
### What it costs
**The likely cost is a broken screen, not an attack.** A discipline named
`Owner's Equipment` — an ordinary thing to type — produces a syntax error in the
handler, so the discipline pill and its scope-step buttons silently stop
responding. No error message, nothing a field user can diagnose.
**The security ceiling is project_user → admin.** The session cookie is HttpOnly so
the token cannot be read, but the injected code does not need it: it runs in the
victim's page and can call any API the victim can, including
`POST /api/auth/users/{id}/role`.
**The `project_super_user` role (added 2026-08-05) widens the set of victims whose
session is worth stealing, without raising the ceiling.** Previously only an app
admin's session could create accounts or change permissions; now a super user's can
too, within the projects they administer. The ceiling is unchanged — it was already
`admin` — but the odds of landing on a session that can mint an account go up, and a
super user is likelier than an admin to be reading a WP creator on a live job. It is
one more reason the accidental-breakage case is not the only one that matters.
Two controls that look like they would contain this do not:
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
`script-src 'self' 'unsafe-inline'`, and `'unsafe-inline'` is what permits inline
event handlers in the first place.
- **The CSRF gate does not mitigate it.** `_csrf_ok` (`server/app.py:67`) only
requires a same-origin `Origin`, and code running inside our own page is
same-origin.
### Why it is still open
The suite is internal, behind a login, on the corporate network, with a small set
of named employee accounts and no anonymous input path. Exploiting it means an
employee deliberately attacking colleagues, and the audit log carries their name on
the SOP edit. The accidental-breakage case is far more likely to be met than the
malicious one.
**Re-rate this as High and fix it immediately if any of these become true:** the
suite is exposed outside the corporate network, accounts are issued to
subcontractors or clients, or self-registration is added.
Note that the second of those got easier to reach without anyone deciding to: a
Project Super User can now issue accounts on their own job without an app admin
involved, so "accounts are issued to subcontractors" can become true by ordinary
delegated use rather than by a policy change. Worth checking the directory
occasionally against who is actually on staff.
### What closing it takes
Small — roughly half an hour. The helper already exists; it was added to the SOP
builder on 2026-08-05 for the same bug in custom constraint names:
```js
// html/work-package-suite-app.js:1120
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
```
1. Add the same helper to `html/wp-creation-app.js` alongside `esc()`.
2. Use it at lines 684, 723, 727 and 729 in place of `esc(d)`.
3. Sweep the other inline handlers in that file for the same pattern. The remaining
ones interpolate server-generated ids that `check_id()` already constrains to a
safe charset, or hardcoded enum values, so they are not currently reachable —
converting them anyway keeps the pattern from coming back.
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
clicks and the name must display intact.
The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved
out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) —
same ordering, same reasoning, worth reading before starting.
---
## 2. Archived projects: the two big apps don't grey out their own controls
**Files:** `html/wp-creation-app.js`, `html/work-package-suite-app.js`
**Raised:** 2026-08-05, with the project-archiving work.
### What is wrong
Archiving a project freezes it server-side — every write returns 409 (see
`require_project_writable` in `server/app.py`, and the *Archiving a project*
section of `DEPLOYMENT.md`). The front end tells the user, but does not stop them:
`wp-chrome.js` shows a read-only banner and sets `data-wp-archived="1"` on the
document element, and nothing reads that attribute yet. So on an archived project
the WP creator and the SOP builder still present working Save and Issue buttons.
### What it costs
Low, and it fails safe — the server refuses the write, so nothing is corrupted and
no data is lost. The cost is wasted effort and a confusing moment: someone deep-
linked to an archived job can fill in a form and only learn it was refused when the
sync indicator reports the change did not save.
Reaching an archived project at all takes a deep link or a stale tab, since it is
gone from every picker, switcher and search — which is why this is a rough edge
rather than a defect.
### Why it is still open
Gating every control in two large single-page apps is materially bigger than the
archive feature itself, and the server is the real enforcement boundary either way.
The banner plus the sync indicator were judged enough for a first release.
### What closing it takes
`data-wp-archived` is already on the document element for exactly this purpose.
Either add `[data-wp-archived]` rules in `wp-chrome.css` that disable and dim the
save/issue controls, or add a boot check in each app that disables them and shows a
read-only notice inline. Decide separately how the embedded creator
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
app bar — and therefore the banner — is deliberately skipped.

View File

View File

@@ -27,14 +27,9 @@ services:
POSTGRES_HOST: db POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used). # Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset, # Signs login session cookies. MUST be set (see server/.env.example).
# and the API refuses to start in production without it (see server/auth.py). AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -60,35 +55,6 @@ services:
networks: networks:
- internal - internal
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
# the host (sync that folder offsite from the host — this container has no
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
backup:
build:
context: .
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
container_name: wp_db_backup
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PGHOST: db
BACKUP_DIR: /backups
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
# Required once the DB holds customer IP. Keep the passphrase off this host.
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
volumes:
- ./scripts:/scripts:ro
- ./backups:/backups
restart: unless-stopped
depends_on:
db:
condition: service_healthy
networks:
- internal
volumes: volumes:
pgdata: pgdata:
nginx_logs: nginx_logs:

View File

@@ -5,204 +5,147 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title> <title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script> <script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="console.css">
<link rel="stylesheet" href="wp-sidenav.css">
<style> <style>
/* Page-specific only — the tokens, cards, controls, tables, banners and modal :root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
live in console.css, shared with the User Directory. What stays here is what --muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
only this page has: the per-card scroll boxes admin.js paints tables into, the --red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
column exceptions for those tables, and the admins-only notice. *{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
These are addressed by ID because admin.js emits the tables without per-cell .wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
classes. */ h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
/* These start life as empty divs that admin.js fills on demand, so they only earn .card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
their gap once they are actually saying something. */ .card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); } 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); }
/* Every container admin.js paints a table into is a scrollport of its own, so a button:hover{ border-color:var(--accent); color:var(--accent); }
sticky header always has something to stick to rather than sliding up behind button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
the app bar. Same rule as console.css's .tscroll. */ button.primary:hover{ background:#1e54bb; color:#fff; }
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ button.danger{ border-color:var(--red); color:var(--red); }
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; } button.danger:hover{ background:var(--red-bg); }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so .row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
one table never ends up with two scrollbars. */ .banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll), .banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){ .banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
overflow:visible; max-height:none; } 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; }
/* Comment text and audit detail are the two columns you are actually here to pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
read, so they wrap inside a sane width instead of truncating. */ table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; } table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; } table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */ .gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
#admin-denied .card{ max-width:560px; } .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 input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; .gate-box h2{ margin:0 0 4px; font-size:17px; }
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); } .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; }
@media (max-width:900px){ .gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ .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; }
max-height:none; } a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
} .urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:6px; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
</style> </style>
</head> </head>
<body> <body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
</a>
</header>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) --> <!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none"> <div class="wrap" id="admin-denied" style="display:none">
<div class="card"> <div class="card">
<h2>Admins only</h2> <h2>Admins only</h2>
<p class="sub">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p> <p class="sub" style="margin:0 0 12px">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div> <div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
</div> </div>
</div> </div>
<!-- CONSOLE --> <!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none"> <div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)"> <div class="row" style="justify-content:space-between">
<div><h1>Admin Console</h1><div class="sub" style="margin:0">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div> <div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div> <div class="row"><a class="home" href="index.html">← Site</a></div>
</div> </div>
<!-- CONNECTIVITY --> <!-- CONNECTIVITY -->
<div class="card"> <div class="card">
<h2>API connectivity</h2> <h2>API connectivity</h2>
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div> <div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="banner" id="health-banner"></div> <div class="banner" id="health-banner"></div>
</div> </div>
<!-- USER ADMINISTRATION — moved out to its own page --> <!-- USER ADMINISTRATION -->
<div class="card"> <div class="card">
<h2>User accounts</h2> <h2>User administration</h2>
<div class="sub">Login accounts, permissions and project access now live on the <div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<strong>User Directory</strong> page. They moved because user administration is no longer <div class="row"><button onclick="loadUsers()">Refresh users</button></div>
admin-only: a <strong>Project Super User</strong> creates and manages the accounts on the <div id="users-banner"></div>
projects they administer, and they must never be sent through this console to do it.</div> <div id="users-table" style="margin-top:12px"></div>
<div class="toolbar"><a class="home" href="users.html"><button class="primary">Open the User Directory →</button></a></div>
</div>
<!-- PROJECTS (ARCHIVE / UNARCHIVE) --> <h2 style="margin-top:22px">Add a user</h2>
<div class="card"> <div class="urow">
<h2>Projects</h2> <input id="nu-username" placeholder="Username *" autocomplete="off">
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it <input id="nu-fullname" placeholder="Full name" autocomplete="off">
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is. <input id="nu-email" placeholder="Email" autocomplete="off">
Unarchive here to bring it back; the project returns unchanged.</div> <select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
<div class="toolbar"> <input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
<button onclick="loadProjects()">Refresh projects</button> <button class="primary" onclick="createUser()">Create user</button>
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
</div> </div>
<div id="projects-banner"></div> <div id="users-create-msg" class="note"></div>
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
</div>
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
<div class="card">
<h2>Default members on new projects</h2>
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
with the role chosen here. It does not touch projects that already exist — for those, use
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
Administrators are listed with nothing to set: they already reach every project. This card stays
in the console because it is a rule about <em>every</em> future project, including the ones a
Project Super User has no part in — so only an admin sets it.</div>
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
<div id="defmem-banner"></div>
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
</div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div> </div>
<!-- ALL FEEDBACK / COMMENTS --> <!-- ALL FEEDBACK / COMMENTS -->
<div class="card"> <div class="card">
<h2>All feedback &amp; comments</h2> <h2>All feedback &amp; comments</h2>
<div class="sub">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="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="toolbar"> <div class="row">
<button onclick="loadComments()">Refresh comments</button> <button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select> <select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()"> <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>
<div id="comments-admin" class="note">Click refresh to load.</div> <div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<h2>Activity log</h2>
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="toolbar">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
<option value="wp">Work packages</option>
<option value="sop">SOPs</option>
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
</div>
<div id="audit-admin" class="note">Click refresh to load.</div>
</div> </div>
<!-- USAGE LOGS --> <!-- USAGE LOGS -->
<div class="card"> <div class="card">
<h2>Usage logs</h2> <h2>Usage logs</h2>
<div class="sub">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="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="toolbar"> <div class="row">
<button onclick="loadUsage()">Refresh</button> <button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button> <button onclick="downloadUsage()">Download JSON</button>
</div> </div>
<div id="usage-admin" class="note">Click refresh to load.</div> <div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div> </div>
<!-- DB SNAPSHOT --> <!-- DB SNAPSHOT -->
<div class="card"> <div class="card">
<h2>Database snapshot</h2> <h2>Database snapshot</h2>
<div class="toolbar"><button onclick="snapshot()">Refresh counts</button></div> <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 id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div> </div>
<!-- SMOKE TEST --> <!-- SMOKE TEST -->
<div class="card"> <div class="card">
<h2>End-to-end smoke test</h2> <h2>End-to-end smoke test</h2>
<div class="sub">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div> <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="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div> <div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre> <pre class="out" id="smoke-out">Ready.</pre>
</div> </div>
<!-- DEMO DATA --> <!-- DEMO DATA -->
<div class="card"> <div class="card">
<h2>Demo data</h2> <h2>Demo data</h2>
<div class="sub">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="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="toolbar"> <div class="row">
<button class="primary" onclick="seedDemo()">Seed demo project</button> <button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button> <button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div> </div>
@@ -211,9 +154,6 @@
</div> </div>
</div> </div>
<script src="console-util.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body> </body>
</html> </html>

View File

@@ -4,32 +4,32 @@
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js 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 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 window.WP_USER; here we show the console only when that user is an admin, and
show an "Admins only" notice otherwise. Every API this page calls is also 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. enforced as admin-only server-side, so this is a real gate, not obfuscation. */
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
User role arrived: administering users is no longer an admin-only act, so the
page that does it can't be behind an admins-only gate. What stays here is what
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
project archiving, and the default-member rule for future projects.
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
function reveal(){ function reveal(){
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
checkHealth(); checkHealth();
loadProjects(); loadUsers();
loadDefaultMembers();
loadSettings();
loadNotifications();
loadComments(); loadComments();
loadAudit();
loadUsage(); loadUsage();
} }
function showDenied(){ function showDenied(){
document.getElementById('admin-denied').style.display=''; 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 ────────────────────────────────────────────────────────────── // ── connectivity ──────────────────────────────────────────────────────────────
async function checkHealth(){ async function checkHealth(){
const b = document.getElementById('health-banner'); const b = document.getElementById('health-banner');
@@ -49,18 +49,15 @@ async function checkHealth(){
// ── db snapshot ─────────────────────────────────────────────────────────────── // ── db snapshot ───────────────────────────────────────────────────────────────
async function snapshot(){ async function snapshot(){
const out = document.getElementById('snapshot-out'); out.textContent='Loading…'; const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
// archived=all: /api/projects now hides archived projects by default, and a row
// count that silently drops them is not a snapshot of the database.
const [p,s,w,c] = await Promise.all([ const [p,s,w,c] = await Promise.all([
api('GET','/api/projects?archived=all'), api('GET','/api/sops'), api('GET','/api/projects'), api('GET','/api/sops'),
api('GET','/api/wps'), api('GET','/api/comments')]); api('GET','/api/wps'), api('GET','/api/comments')]);
if(p.status!==200){ if(p.status!==200){
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return; 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); const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0;
out.innerHTML = `<table class="kv"> out.innerHTML = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr> <tr><th>Projects</th><td>${n(p)}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr> <tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr> <tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`; <tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
@@ -94,14 +91,6 @@ async function runSmokeTest(){
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('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('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)); r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
// Archive round-trip: out of the default list, still there with archived=all,
// frozen against writes, and all three undone by unarchiving.
r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status);
r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid));
r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid));
r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}});
chk('write to an archived project refused (409)', r.status===409, 'status '+r.status);
r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status);
} catch(e){ chk('unexpected error', false, String(e)); } } catch(e){ chk('unexpected error', false, String(e)); }
finally { finally {
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); } if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
@@ -148,9 +137,7 @@ async function seedDemo(){
async function cleanDemo(){ async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return; 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 o=document.getElementById('demo-out'); o.innerHTML='';
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from const r = await api('GET','/api/projects');
// this button — the default list hides it and nothing else here can delete it.
const r = await api('GET','/api/projects?archived=all');
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; } if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||''))); const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; } if(!targets.length){ demoLog('Nothing to remove.'); return; }
@@ -159,132 +146,23 @@ async function cleanDemo(){
snapshot(); snapshot();
} }
// ── projects: archive / unarchive ─────────────────────────────────────────────── // ── user administration ────────────────────────────────────────────────────────
// Archiving is the answer to "this job is over but I can't throw the data away". function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// An archived project disappears from every picker, switcher and search in the
// suite and is frozen read-only; nothing is deleted. That makes this card the ONLY
// place an archived project is still visible, so it asks for archived=all and does
// the hiding itself — otherwise an admin could never find one to unarchive.
let _adminProjects = [];
async function loadProjects(){ async function currentUserId(){
const banner=document.getElementById('projects-banner'); if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
const wrap=document.getElementById('projects-table'); const { status, json } = await api('GET','/api/auth/me');
if(!banner || !wrap) return; return (status===200 && json && json.user) ? json.user.id : null;
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/projects?archived=all');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant archive or delete projects here.';
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 projects (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_adminProjects = json;
renderProjects();
} }
function renderProjects(){ async function loadUsers(){
const wrap=document.getElementById('projects-table'); const banner=document.getElementById('users-banner');
if(!wrap) return; const wrap=document.getElementById('users-table');
const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked;
const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase();
const total = _adminProjects.length;
if(!total){ wrap.innerHTML='<div class="note">No projects yet.</div>'; return; }
const list = _adminProjects.filter(p => {
if(!showArchived && p.archived) return false;
if(!q) return true;
return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0;
});
const count = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
if(!list.length){
wrap.innerHTML = count + '<div class="note">Nothing matches'+
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
return;
}
const fmt = s => s ? wpFormatDateTime(s) : '—';
const rows = list.map(p => {
// Project names are free text written by whoever created the job — jsq(), not
// uesc(), is what makes them safe to bind into the handlers below.
const pid = jsq(p.id);
const pname = jsq(p.name||'(unnamed)');
const arch = !!p.archived;
return '<tr>'+
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
'<td>'+(arch
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
: '<span class="tag on">active</span>')+'</td>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
(arch?'Unarchive':'Archive')+'</button>'+
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML = count +
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
'work package on it for good. Archive first if there is any doubt.</div>';
}
// Both directions are explained in full before anything happens: archiving makes a
// project vanish for everyone else in the company, and there is no undo prompt on
// the other side of that.
async function archiveProject(id, name, archived){
const ask = archived
? 'Archive “'+name+'”?\n\n'+
'• It disappears from every project picker, switcher and search across the suite.\n'+
'• It becomes read-only — nobody can add or change its SOP or work packages.\n'+
'• Nothing is deleted. Unarchive here at any time to bring it back.'
: 'Unarchive “'+name+'”?\n\n'+
'It becomes visible in the pickers again and can be edited as normal.';
if(!confirm(ask)) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
// global shared with the other scripts the page loads, and "deleteProject" is broad
// enough to collide with one of them later. The -Admin suffix also says which of the
// two project deletions this is — the console's, not a project member's.
async function deleteProjectAdmin(id, name){
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'(database cascade). This cannot be undone.\n\n'+
'If you only want it out of the way, cancel and use Archive instead.')) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// ── default members on new projects ─────────────────────────────────────────────
// A rule about the FUTURE: flagged users are auto-added to every project created
// from now on. It is not a bulk assignment — existing projects are untouched, which
// is what the note under the table is there to say.
let _defMemUsers = [];
async function loadDefaultMembers(){
const banner=document.getElementById('defmem-banner');
const wrap=document.getElementById('defmem-table');
if(!banner || !wrap) return;
banner.className='banner'; banner.textContent='Loading…'; banner.style.display=''; banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/auth/users'); const { status, json } = await api('GET','/api/auth/users');
if(status===403){ if(status===403){
banner.className='banner bad'; banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant change who is added to new projects.'; 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; wrap.innerHTML=''; return;
} }
if(status===401){ if(status===401){
@@ -294,90 +172,126 @@ async function loadDefaultMembers(){
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return; banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
} }
banner.style.display='none'; banner.style.display='none';
_defMemUsers = json; const meId = await currentUserId();
renderDefaultMembers(); renderUsers(json, meId);
} }
// The role only matters while the tick is on. The select sits in a sibling <td>, so function renderUsers(list, meId){
// the lookup is scoped to the row. const wrap=document.getElementById('users-table');
function defMemToggled(cb){ if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const row = cb.closest('tr'); const fmt = s => s ? new Date(s).toLocaleString() : '—';
const sel = row && row.querySelector('select'); let rows = list.map(u=>{
if(sel) sel.disabled = !cb.checked; const me = u.id===meId;
} const active = u.is_active;
const disableBtn = me
function renderDefaultMembers(){ ? '<button class="mini" disabled title="You cant disable yourself">—</button>'
const wrap=document.getElementById('defmem-table'); : '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
if(!wrap) return; const delBtn = me
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; } ? ''
const rows = _defMemUsers.map(u => { : '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
const uid = jsq(u.id); return '<tr>'+
const uname = jsq(u.username); '<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
const role = normRole(u.role); '<td>'+uesc(u.full_name||'')+'</td>'+
const who = '<td><strong>'+uesc(u.username)+'</strong>'+ '<td>'+uesc(u.email||'')+'</td>'+
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+ '<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>'; '<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
// Admins reach every project already, so there is nothing to add them to. '<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
if(role === 'admin'){ '<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
return '<tr>'+who+ '<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+ '<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+ disableBtn+delBtn+
' <span class="note">Administrators already reach every project.</span></td>'+ '</div></td>'+
'</tr>';
}
const on = !!u.auto_add_projects;
const cur = u.auto_add_role || '';
// Every project-scoped role is offered, super user included: this card is
// admin-only, and "the QA lead runs the users on every new job" is exactly the
// sort of standing rule it exists to express.
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
uesc(PERM_LABELS[role]||role)+')</option>']
.concat(PROJECT_SCOPED_ROLES.map(r =>
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
return '<tr>'+who+
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
'<td><label class="chk">'+
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
' title="Add this user to every project created from now on"'+
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
'</label></td>'+
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
' title="The role this user gets on those projects"'+
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
'</tr>'; '</tr>';
}).join(''); }).join('');
wrap.innerHTML = wrap.innerHTML='<table class="users"><thead><tr>'+
'<div class="tscroll"><table class="grid"><thead><tr>'+ '<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
'<th>User</th><th>Email</th>'+ '</tr></thead><tbody>'+rows+'</tbody></table>';
'<th title="What this account may do in the app">Account permissions</th>'+
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
'<th title="Their role on those projects">Role on those projects</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
'Directory</a> to add someone to a project that already exists.</div>';
} }
// Saves on every tick and every dropdown change — there is no Save button, so a async function createUser(){
// failure must not leave a control showing something the server never accepted. const msg=document.getElementById('users-create-msg');
// On success we swap in the row the server returned (it clears the role whenever const username=document.getElementById('nu-username').value.trim();
// the flag is off); on failure we reload so the controls snap back to the truth. const full_name=document.getElementById('nu-fullname').value.trim();
async function setAutoAdd(id, username){ const email=document.getElementById('nu-email').value.trim();
const cb = document.getElementById('defmem-cb-'+id); const role=document.getElementById('nu-role').value;
if(!cb) return; const password=document.getElementById('nu-password').value;
const sel = document.getElementById('defmem-role-'+id); if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
const auto_add = !!cb.checked; if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add', msg.style.color='var(--muted)'; msg.textContent='Creating…';
{ auto_add, role: auto_add ? ((sel && sel.value) || '') : '' }); const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password});
if(status===200 && json && json.id){ if(status===200){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u); msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
renderDefaultMembers(); ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
loadUsers();
} else { } else {
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))); msg.style.color='var(--red)';
loadDefaultMembers(); 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 ───────────────────────────────────────────────────── // ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = []; let _comments = [];
async function loadComments(){ async function loadComments(){
@@ -402,7 +316,7 @@ function renderComments(){
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0)); (!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; } 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||''))); rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? wpFormatDateTime(s) : '—'; const fmt = s => s ? new Date(s).toLocaleString() : '—';
const where = c => { const where = c => {
const bits = []; const bits = [];
if(c.page) bits.push(uesc(c.page)); if(c.page) bits.push(uesc(c.page));
@@ -421,233 +335,6 @@ function renderComments(){
'</tr>').join('')+'</tbody></table>'; '</tr>').join('')+'</tbody></table>';
} }
// ── activity log (audit trail) ──────────────────────────────────────────────────
let _audit = [];
async function loadAudit(){
const box = document.getElementById('audit-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/audit?limit=500');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
}
_audit = json;
renderAudit();
}
function renderAudit(){
const box = document.getElementById('audit-admin');
const type = document.getElementById('audit-type').value;
const q = (document.getElementById('audit-search').value||'').toLowerCase();
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const det = e => {
const d = e.detail || {};
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
rows.map(e => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(e.entity_type||'')+'</td>'+
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
'<td style="color:var(--muted)">'+det(e)+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── notifications / email settings ──────────────────────────────────────────────
let _settings = {};
async function loadSettings(){
const box = document.getElementById('settings-box');
const { status, json } = await api('GET','/api/settings');
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings();
}
// Feature flags live in the same settings record but get their own card — they're
// not email, and they change what every project sees.
function renderFeatures(){
const s = _settings, box = document.getElementById('features-box');
if(!box) return;
const bim = !!s.bim_enabled;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
// Localization defaults. A user's own "Language & time" preference wins over
// these; these decide what everyone else sees instead of the browser's guess.
'<h2 style="margin-top:22px">Localization defaults</h2>'+
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
'set their own preference. Each user can override this from <strong>Language &amp; time</strong> in the '+
'top-right menu.</div>'+
'<div class="urow">'+
'<select id="set-locale" style="min-width:220px"></select>'+
'<select id="set-tz" style="min-width:240px"></select>'+
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
'</div>'+
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
fillLocalization();
}
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
// preference offer the same choices.
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
'Europe/Dublin','Europe/London','UTC'];
function fillLocalization(){
const s = _settings;
const loc = document.getElementById('set-locale');
const tz = document.getElementById('set-tz');
if(!loc || !tz) return;
const curL = s.default_locale || '', curZ = s.default_timezone || '';
loc.innerHTML = L10N_LOCALES.map(p =>
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
let browserZone = '';
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
const preview = () => {
const el = document.getElementById('l10n-preview'); if(!el) return;
let out;
try {
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
} catch(e){ out = 'not supported by this browser'; }
el.textContent = 'Preview — right now reads: ' + out;
};
loc.onchange = preview; tz.onchange = preview; preview();
// Offer the server's full zone list once it arrives (it validates against the
// same list, so anything offered here will be accepted).
api('GET','/api/timezones').then(({status,json}) => {
if(status!==200 || !Array.isArray(json) || !json.length) return;
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
if(!rest.length) return;
const g = document.createElement('optgroup'); g.label = 'All time zones';
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
tz.appendChild(g);
if(curZ) tz.value = curZ;
});
}
async function saveLocalization(){
const msg = document.getElementById('l10n-msg');
const patch = {
default_locale: document.getElementById('set-locale').value,
default_timezone: document.getElementById('set-tz').value,
};
msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){
_settings = json; renderSettings();
const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
async function saveFeatures(){
const el = document.getElementById('set-bim');
const msg = document.getElementById('features-msg');
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
if(status===200){
_settings = json; renderFeatures();
const m = document.getElementById('features-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else if(msg){
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
}
}
function renderSettings(){
renderFeatures();
const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
'</div>'+
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
'<div class="row">'+
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
'<button onclick="testEmail()">Send test email to me</button>'+
'<span id="set-msg" class="note" style="margin:0"></span>'+
'</div>';
}
async function saveSettings(){
const v = id => document.getElementById(id);
const patch = {
email_enabled: v('set-enabled').checked,
smtp_host: v('set-host').value.trim(),
smtp_port: parseInt(v('set-port').value, 10) || 587,
smtp_use_tls: v('set-tls').checked,
from_addr: v('set-from').value.trim(),
from_name: v('set-fromname').value.trim(),
smtp_username: v('set-user').value.trim(),
app_base_url: v('set-baseurl').value.trim(),
};
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
}
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
json.map(n => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
'<td>'+uesc(n.email||n.user_id)+'</td>'+
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(n.subject||'')+'</td>'+
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ────────────────────────── // ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1'; const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } } function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
@@ -663,7 +350,7 @@ function loadUsage(){
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1; 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; if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
}); });
const fmt = s => s ? wpFormatDateTime(s) : '—'; const fmt = s => s ? new Date(s).toLocaleString() : '—';
let html = '<table class="kv">'+ let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+ '<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+ '<tr><th>Events</th><td>'+evs.length+'</td></tr>'+

View File

@@ -14,12 +14,6 @@
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
// Register the PWA service worker (caches the app shell for offline use). Only
// from the top window; the API and writes are never cached (see sw.js).
if (!inIframe && 'serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
}
// Hide the page until we know the user is allowed, to avoid a flash of the app // 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. // before a redirect. A safety timer reveals it even if the check hangs.
var root = document.documentElement; var root = document.documentElement;
@@ -40,19 +34,6 @@
} }
window.wpLogout = function () { window.wpLogout = function () {
try {
// Clear the auth cache AND all cached project data (customer IP) from this
// device on sign-out — important on shared/field tablets. The outbox
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
// (localStorage is not a security boundary; field devices still need
// full-disk encryption / MDM — see DEPLOYMENT.md.)
localStorage.removeItem('wp_auth_cache');
Object.keys(localStorage).forEach(function (k) {
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
localStorage.removeItem(k);
}
});
} catch (e) {}
fetch('/api/auth/logout', { method: 'POST' }) fetch('/api/auth/logout', { method: 'POST' })
.catch(function () {}) .catch(function () {})
.then(function () { window.location.replace('login.html'); }); .then(function () { window.location.replace('login.html'); });
@@ -75,7 +56,7 @@
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' + '<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>' + '<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' + '<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' + '<label style="' + lbl + '">New password (at least 8 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' + '<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' + '<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' + '<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
@@ -100,7 +81,7 @@
var n1 = document.getElementById('wp-pw-new').value; var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value; var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; } if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', 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; } if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', { fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -115,161 +96,58 @@
}; };
}; };
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.
// 'user' is the legacy value for what is now 'project_user'.
window.wpRole = function () {
var r = (window.WP_USER && window.WP_USER.role) || '';
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
// A Project Super User is a Project Admin with user administration on top, so it
// counts here too (server: auth.is_project_admin).
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// Whether this account can administer USER accounts. The account role is only half
// the answer — the role can also be held on a single project — so anything that
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
// cheap hint used to decide whether to bother offering a control.
window.wpMayManageUsers = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_super_user';
};
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
window.WP_FLAGS = null;
var _flagsPromise = null;
window.wpFlags = function () {
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
if (_flagsPromise) return _flagsPromise;
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : {}; })
.catch(function () { return {}; }) // offline: fall through to defaults
.then(function (f) {
window.WP_FLAGS = f || {};
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
return window.WP_FLAGS;
});
return _flagsPromise;
};
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
// stale cache errs toward hiding the unfinished tooling rather than showing it.
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
function isDarkBg(el) {
try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
// bar it sits in (light links on a dark bar, blue links on a light bar).
function buildUserMenu(user, dark) {
var wrap = document.createElement('div');
wrap.id = 'wp-usermenu';
var linkColor = dark ? '#ffffff' : '#0f62fe';
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
function link(text, onClick, href) {
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
return a;
}
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
// The directory is readable by everyone — it's how you find who is on your job —
// so it is offered to everyone, not just the people who can edit accounts.
if (!/(^|\/)users\.html$/.test(location.pathname)) {
wrap.appendChild(sep()); wrap.appendChild(link('Users', null, 'users.html'));
}
// Always offered; wp-format.js may still be parsing when the menu is built, so
// the check happens at click time rather than once, up front.
wrap.appendChild(sep());
wrap.appendChild(link('Language & time', function () {
if (typeof window.wpPreferences === 'function') window.wpPreferences();
}));
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;
}
function addLogoutPill(user) { function addLogoutPill(user) {
if (inIframe) return; // the parent page already shows it if (inIframe) return; // the parent page already shows it
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return; if (document.getElementById('wp-logout-pill')) return;
// Preferred: drop the menu INTO the top bar so it never floats over the
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
// the older .header bars alike.
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (host) {
var menu = buildUserMenu(user, isDarkBg(host));
// The older .header bars already right-align their own toolbar (via flex:1
// or a button's margin-left:auto). A second auto-margin would split the free
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
host.appendChild(menu);
return;
}
// Fallback for any page with no header bar: a floating pill (as before).
var pill = document.createElement('div'); var pill = document.createElement('div');
pill.id = 'wp-logout-pill'; pill.id = 'wp-logout-pill';
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' + pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' + '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;'; 'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
pill.appendChild(buildUserMenu(user, false)); 'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
document.body.appendChild(pill); function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
}
function proceed(user) { var who = document.createElement('span');
clearTimeout(safety); who.textContent = user.full_name || user.username;
window.WP_USER = user; pill.appendChild(who);
reveal();
if (window.WP_USER) { // Admins get a link to the Admin Console (hidden when already on it).
window.wpFlags(); // start the feature-flag fetch; pages await it as needed var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} if (user.role === 'admin' && !onAdmin) {
if (document.body) addLogoutPill(window.WP_USER); var adm = document.createElement('a');
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); }); 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' } }) fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
.then(function (r) { .then(function (r) {
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; } if (r.status === 401 || r.status === 403) { goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) { return r.json().then(function (data) {
var user = data && data.user; clearTimeout(safety);
// Remember the last good auth so the PWA can open offline. The server is window.WP_USER = data && data.user;
// still the real gate; offline writes queue in the outbox until reconnect. reveal();
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {} if (window.WP_USER) {
proceed(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 () { .catch(function () { goToLogin(); }); // API unreachable → send to login
// Offline / API unreachable: fall back to a recent cached auth if present,
// so the app (and the field view) still open without a network.
try {
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
} catch (e) {}
goToLogin();
});
})(); })();

View File

@@ -1,89 +0,0 @@
/* Shared helpers for the suite's admin pages (Admin Console, User Directory).
These used to live in admin.js. They are here because the User Directory needs
the same escaping and the same role vocabulary, and a second copy of either is a
liability: a divergent jsq() is an XSS, and a divergent role list quietly offers
a permission the server will refuse.
Loaded as plain globals (no modules) to match the rest of the suite. */
// ── api ──────────────────────────────────────────────────────────────────────
// Never throws: returns {status, json} with status 0 when the request itself
// failed, so every caller can branch on one shape.
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) }; }
}
// The message to show for a failed call, preferring the server's own words.
function apiError(status, json, fallback){
if(json && json.detail) return json.detail;
if(status === 0) return 'Could not reach the server.';
if(status === 401) return 'Not signed in. Reload and log in again.';
return (fallback || 'Request failed') + ' (HTTP ' + status + ').';
}
// ── escaping ─────────────────────────────────────────────────────────────────
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
// for the JS string literal it lands in, and again for the HTML attribute carrying
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
// survives, and the browser decodes the entities before the JS parser runs.
//
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
// value containing a backslash: the \ we add is itself escaped by the stored one,
// the quote closes the literal, and everything after it runs as code. Project names,
// full names and usernames are free text that a signed-in user can write, so that is
// a real path from a project_user to whatever an admin's session can do. Use jsq()
// for EVERY value that lands inside an inline handler.
function jsq(v){
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
}
// ── role vocabulary (mirrors server/auth.py) ─────────────────────────────────
// Permissions roles: what an account may DO. Ordered most- to least-privileged,
// same as auth.ROLES, because that is the order the dropdowns render in.
const PERM_ROLES = ['admin','project_super_user','project_admin','project_user'];
const PERM_LABELS = {
admin:'Administrator',
project_super_user:'Project Super User',
project_admin:'Project Admin',
project_user:'Project User',
};
// One-line description of each, used in the legends and dropdown titles.
const PERM_HELP = {
admin:'Manages users, app settings and every project.',
project_super_user:'On their assigned projects: everything a Project Admin can do, '+
'plus creating and managing that project\'s user accounts.',
project_admin:'On their assigned projects: may delete work packages, change a completed SOP, '+
'and delete the project.',
project_user:'Creates and edits work packages and authors the SOP, but cannot delete WPs '+
'or change the SOP once it is complete.',
};
// Roles that can be held on a SINGLE project (ProjectMember.role); '' inherits the
// account's own. 'admin' is app-wide by definition and never appears here.
const PROJECT_SCOPED_ROLES = ['project_super_user','project_admin','project_user'];
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function roleLabel(r){ const n = normRole(r); return PERM_LABELS[n] || n; }
// Which pill a role wears. Admin and super user each get their own colour because
// "can reach every project" and "can create users here" are the two facts you scan
// this column for.
function roleTagClass(r){
const n = normRole(r);
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
}

View File

@@ -1,191 +0,0 @@
/* Shared styling for the suite's dense admin pages — the Admin Console and the
User Directory. Both are mostly tables and toolbars, which is a different job
from the wizard pages, so they carry this sheet instead of theme-light.css's
form-heavy one. The palette, the square corners and the type are still Carbon's,
so the pages read as one product with the rest of the suite.
Two scales do all the spacing and all the control sizing; nothing that uses this
sheet should invent its own. Page-specific rules (per-ID scroll boxes, column
exceptions) stay in the page that owns them.
══ TOKENS ══════════════════════════════════════════════════════════════════ */
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
--green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
--ctl:32px; /* every button / input / select that sits in a form row */
--ctl-sm:26px; } /* every control that sits inside a table cell */
*{ box-sizing:border-box; }
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
/* ══ PAGE ══════════════════════════════════════════════════════════════════════
1240px, not 860: the user table is nine columns wide and at 860 it spilled
straight out of its own white card. Wide enough for that table, still a
readable measure for the prose, which is capped separately. */
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
a.home:hover{ text-decoration:underline; }
/* ══ CARDS ═════════════════════════════════════════════════════════════════════ */
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
/* One card header everywhere: small uppercase accent label on a hairline. The
scripts also emit h2 for sub-sections inside a card with an inline margin-top —
the same treatment reads correctly as a divider there, so both get it. */
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
/* ══ CONTROLS ══════════════════════════════════════════════════════════════════
Every button, input and select in a form row is exactly --ctl tall, so a
toolbar is one clean band instead of a ragged one. */
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* The filter / search / button strip at the top of a card. */
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
.toolbar + .banner{ margin-top:0; }
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* Checkboxes are excluded: they are drawn by the platform and want none of a
text field's height, padding or border. */
.toolbar input:not([type=checkbox]), .toolbar select,
.urow input:not([type=checkbox]), .urow select{
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
.toolbar input:focus-visible, .toolbar select:focus-visible,
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
.toolbar > input{ flex:1 1 240px; min-width:150px; }
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
/* ══ FEEDBACK: banners, notes, console output, key/value ════════════════════════ */
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
background:var(--surface); color:var(--text); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
.banner.warn{ background:var(--amber-bg); color:var(--amber); border-color:#fddc69; border-left-color:var(--amber); }
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
and the boxes the scripts fill are themselves .note — their primary toggle
labels inherit this colour. */
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
.note strong, .note em{ color:var(--text); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 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:var(--s2); }
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
/* ══ DATA TABLES ═══════════════════════════════════════════════════════════════
table.users is the name admin.js already emits; table.grid is the same object
under the shared name. One rule set serves both, so existing markup picks up the
dense styling without being rewritten. border-collapse is separate rather than
collapse because a collapsed border does not travel with a sticky header. */
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
font-size:13px; color:var(--text); background:var(--surface); }
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
box-shadow:inset 0 -1px 0 var(--border); }
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and the
table stops reading as rows. Anything genuinely long truncates (.ell) or is
exempted by name in the page that owns the table. */
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
vertical-align:middle; white-space:nowrap; }
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
projects" pill sitting on its own colour disappears the moment you hover it. */
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
/* A row for an account this caller may see but not change. Dimmed as a whole so
the disabled controls aren't the only clue. */
table.grid tbody tr.is-locked td, table.users tbody tr.is-locked td{ color:var(--muted); }
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
never appears, which is the usual reason this trick looks like it works in the
stylesheet and doesn't on the page. The scripts emit <td class="ell"><span>. */
.ell{ max-width:240px; }
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
/* Every action cell the scripts render is a .cellactions, and it must not wrap:
unwrapped, the three buttons stack and the row grows fourfold. */
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
/* Controls that live in a cell are one step smaller, which is what keeps a row at
~34px instead of ~100px. .chk is form-row sized by default, so it needs saying
again here or checkbox rows stand 6px taller than the rest. */
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
background:#fff; color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
select.role-select:disabled{ color:var(--dim); border-color:var(--border); background:var(--bg); cursor:default; }
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
line-height:1.55; white-space:nowrap; vertical-align:middle; }
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
.tag.super{ background:#e8daff; color:#6929c4; }
.tag.user{ background:#e8e8e8; color:var(--muted); }
.tag.on{ background:var(--green-bg); color:var(--green); }
.tag.off{ background:var(--red-bg); color:var(--red); }
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
/* A wide table scrolls inside its own box so the page never scrolls sideways, and
the capped height is what gives the sticky header something to do. */
.tscroll{ overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* ══ MODALS ════════════════════════════════════════════════════════════════════
The project-access dialog, shared by both pages. */
.modal-ov{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:flex; align-items:center;
justify-content:center; z-index:10002; padding:var(--s5); }
.modal-box{ background:var(--surface); border-radius:0; max-width:660px; width:100%; max-height:82vh;
display:flex; flex-direction:column; overflow:hidden; box-shadow:0 12px 40px rgba(20,30,50,.3); }
.modal-head{ padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); font-weight:700; }
.modal-body{ padding:var(--s3) var(--s4); overflow:auto; }
.modal-foot{ padding:var(--s3) var(--s4); border-top:1px solid var(--border);
display:flex; gap:var(--s2); justify-content:flex-end; }
.pickrow{ display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s1);
border-bottom:1px solid var(--border); font-size:13px; }
.pickrow:last-child{ border-bottom:none; }
.pickrow > label{ display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; cursor:pointer; }
.pickrow > label > span{ overflow:hidden; text-overflow:ellipsis; }
/* ══ GATES & WARNINGS ══════════════════════════════════════════════════════════ */
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
/* ══ NARROW SCREENS ════════════════════════════════════════════════════════════
The page itself must never scroll sideways; the wide tables scroll inside their
own box instead, and there they get the full page height to do it. */
@media (max-width:900px){
.wrap{ padding:var(--s4) var(--s3) 60px; }
.card{ padding:var(--s3) var(--s4) var(--s4); }
.toolbar > input{ flex:1 1 100%; }
.tscroll{ max-height:none; }
}
@media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; }
}

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field View — Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="wp-sidenav.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
.fld-ctx b { color: var(--cds-text-primary); }
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
.wp-card:active { background: var(--cds-layer-hover); }
.wp-card.ready { border-left-color: var(--cds-support-success); }
.wp-card.hold { border-left-color: var(--cds-support-error); }
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: #defbe6; color: #0e6027; }
.pill.warn { background: #fdf6dd; color: #8a6d00; }
.pill.bad { background: #fff1f1; color: #da1e28; }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
.fld-empty a { color: var(--cds-link-primary); }
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
.cx-row:last-child { border-bottom: none; }
.cx-name { flex: 1; font-size: 15px; }
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
.fld-toast.show { opacity: 1; }
</style>
</head>
<body>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Field View</span>
</a>
<div class="wp-appbar-spacer"></div>
<a class="wp-appbar-link" href="index.html">Home</a>
</header>
<div class="field-wrap">
<div class="fld-ctx" id="fld-ctx"></div>
<section id="screen-list">
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
<div id="wp-list"></div>
</section>
<section id="screen-detail" style="display:none"></section>
</div>
<div id="toast" class="fld-toast"></div>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="field.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -1,178 +0,0 @@
/* Field view — a touch-optimized screen for updating a Work Package's status,
constraints, and a photo/note log from the work face. Reads the same shared
data as the desktop creator (via project-data.js) and saves through the sync
outbox, so it works offline and syncs when the network returns. */
'use strict';
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function nsKey(id) { return 'wp_iwp_v1__' + id; }
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
// is not release-ready either, so the field list must not call it Ready — the
// server would refuse to issue it (see enforce_release_gates).
function waitingCount(p, all) {
var preds = (p && p.predecessors) || [];
if (!preds.length) return 0;
var byId = {};
(all || []).forEach(function (x) { byId[x.id] = x; });
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
}
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
// ── boot / data ──────────────────────────────────────────────────────────────
function boot() {
var params = new URLSearchParams(location.search);
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
if (!PID) { showNoProject(); return; }
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
loadWPs();
}
function renderCtx() {
var el = document.getElementById('fld-ctx'); if (!el) return;
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
else el.textContent = 'Project: ' + PID;
}
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
function loadWPs() {
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
renderList();
if (ProjectData.pullProject) {
ProjectData.pullProject(PID).then(function () {
WPS = activePkgs(readCache());
if (!curId) renderList(); else renderDetail();
}).catch(function () {});
}
}
function showNoProject() {
var s = document.getElementById('screen-list');
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
}
// ── list ───────────────────────────────────────────────────────────────────
function renderList() {
var box = document.getElementById('wp-list'); if (!box) return;
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
box.innerHTML = rows.map(function (p) {
var open = openCount(p);
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
: (open ? '<span class="pill warn">' + open + ' open</span>'
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
: '<span class="pill ok">Ready</span>'));
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
'<div class="subj">' + esc(p.subject || '') + '</div>' +
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
}).join('');
}
// ── detail ─────────────────────────────────────────────────────────────────
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
function backToList() {
curId = null; pendingPhoto = ''; draftNote = '';
document.getElementById('screen-detail').style.display = 'none';
document.getElementById('screen-list').style.display = '';
renderList();
}
function renderDetail() {
var p = curWP(); if (!p) { backToList(); return; }
document.getElementById('screen-list').style.display = 'none';
var d = document.getElementById('screen-detail'); d.style.display = '';
var stBtns = STATUSES.map(function (s) {
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
}).join('');
var cx = (p.constraints) || [];
var cxRows = cx.length ? cx.map(function (c, i) {
var st = c.status || 'open';
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
d.innerHTML =
'<button class="fld-back" onclick="backToList()"> All packages</button>' +
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
}
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
function saveWP(p) {
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) WPS[ix] = p;
writeCache();
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
}
function setStatus(s) {
var p = curWP(); if (!p) return;
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
if (p.status === s) return;
p.status = s;
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
}
function cycleConstraint(i) {
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
var order = ['open', 'cleared', 'na'];
var cur = p.constraints[i].status || 'open';
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
saveWP(p); renderDetail();
}
function onPhoto(ev) {
var f = ev.target.files && ev.target.files[0]; if (!f) return;
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
var url = URL.createObjectURL(f);
var img = new Image();
img.onload = function () {
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
var cv = document.createElement('canvas');
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
URL.revokeObjectURL(url);
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
};
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
img.src = url;
}
function addUpdate() {
var p = curWP(); if (!p) return;
var note = (draftNote || '').trim();
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
if (!p.fieldLog) p.fieldLog = [];
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
pendingPhoto = ''; draftNote = '';
saveWP(p); renderDetail(); toast('Update added to log');
}
boot();

View File

@@ -15,64 +15,64 @@
// ── styles ──────────────────────────────────────────────────────────────── // ── 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:#525252; color:#fff; font-size:10px; font-weight:700; margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; } font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%); .help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400; background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999; line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); } opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%); .help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#161616; 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:center; .ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; } 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:#161616; max-width:980px; width:100%; height:88vh; max-height:880px; .ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden; border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; } font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; } .ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; } .ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; } .ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0; .ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
font-size:13px; outline:none; background:#f7f8fa; } font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); } .ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; } .ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; } .ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; } .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:0; color:#27313f; text-decoration:none; font-size:13px; .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; } cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; } .ui-help-nav a:hover{ background:#eef1f6; }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; } .ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
.ui-help-nav a.nohit{ display:none; } .ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; } .ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; } .ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; } .ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; } .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:#0f62fe; } .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 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 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 li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; } .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 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 #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; } .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-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; } .ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; } .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-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; } .pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
.pill-hold{ background:#fde8e8; color:#c0392b; } .pill-hold{ background:#fde8e8; color:#c0392b; }
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0; .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; } font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; } .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-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%; .ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer; border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); } box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#0353e9; } .ui-help-fab:hover{ background:#1d4ed8; }
@media (max-width:760px){ @media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; } .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 #e0e0e0; } .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-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; } .ui-help-head{ flex-wrap:wrap; }
}`; }`;
@@ -94,13 +94,6 @@
</ol> </ol>
<h4>Moving around</h4> <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> <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>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<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>` }, <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>` },
{ id: 'projects', title: 'Projects', body: ` { id: 'projects', title: 'Projects', body: `

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -5,72 +5,125 @@
<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> <script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--cds-background); background: var(--cds-background);
color: var(--cds-text-primary); color: var(--cds-text-primary);
line-height: 1.5; line-height: 1.5;
} }
/* HEADER */
.header {
background: var(--cds-layer);
padding: 1.5rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-bottom: 1px solid var(--cds-border-subtle);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 16px;
text-decoration: none;
color: var(--cds-text-primary);
background: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.logo img {
height: 32px;
width: auto;
}
.logo:hover { opacity: 0.9; }
.header-spacer { flex: 1; }
.header-nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
.header-nav a {
color: var(--cds-text-secondary);
text-decoration: none;
font-size: 13px;
transition: color 0.2s;
}
.header-nav a:hover { color: var(--cds-text-primary); }
/* CONTAINER */ /* CONTAINER */
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2.5rem 2rem 3rem; padding: 3rem 2rem;
} }
/* HERO */ /* HERO */
.hero { .hero {
margin-bottom: 2.5rem; text-align: center;
margin-bottom: 4rem;
} }
.hero h1 { .hero h1 {
font-size: 2.25rem; font-size: 2.625rem;
font-weight: 300; font-weight: 300;
letter-spacing: -0.01em; margin-bottom: 1rem;
margin-bottom: 0.5rem;
color: var(--cds-text-primary); color: var(--cds-text-primary);
} }
.hero p { .hero p {
font-size: 1rem; font-size: 1.125rem;
color: var(--cds-text-secondary); color: var(--cds-text-secondary);
max-width: 760px; margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
} }
/* CARDS */ /* CARDS */
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1rem; gap: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 3rem;
} }
.card { .card {
background: var(--cds-layer); background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-left: 4px solid var(--cds-border-strong); border-radius: 4px;
padding: 1.5rem; padding: 1.5rem;
transition: border-color 0.15s, background 0.15s; box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
text-decoration: none; text-decoration: none;
color: var(--cds-text-primary); color: var(--cds-text-primary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.card:hover { .card:hover {
border-left-color: var(--cds-interactive-01); box-shadow: 0 4px 8px rgba(0,0,0,0.4);
background: var(--cds-layer-hover); transform: translateY(-2px);
border-color: var(--cds-button-primary);
} }
.card-badge { .card-badge {
@@ -100,10 +153,10 @@
.card-button { .card-button {
display: inline-block; display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary); background: var(--cds-button-primary);
color: white; color: white;
padding: 0.7rem 1.25rem; padding: 0.75rem 1.5rem;
border-radius: 3px;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
@@ -112,22 +165,23 @@
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.card-button:hover { .card-button:hover {
background: var(--cds-hover-primary); background: var(--cds-hover-primary);
} }
/* COMPLETE STATE (SOP done) */ /* COMPLETE STATE (SOP done) */
.card.complete { .card.complete {
border-left-color: var(--cds-support-success); background: #ecfdf5;
border-color: #16a34a;
} }
.card.complete .card-button { background: var(--cds-support-success); } .card.complete .card-button { background: #16a34a; }
.card.complete .card-button:hover { background: #0e6027; } .card.complete .card-button:hover { background: #15803d; }
.card-status { .card-status {
display: inline-block; display: inline-block;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--cds-support-success); color: #16a34a;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.card.disabled { .card.disabled {
@@ -138,8 +192,9 @@
/* SECTION */ /* SECTION */
.section { .section {
background: var(--cds-layer); background: var(--cds-layer);
padding: 1.75rem; border-radius: 4px;
margin-bottom: 1.5rem; padding: 2rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
@@ -162,6 +217,16 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.quick-start {
background: var(--cds-button-primary);
color: white;
padding: 2rem;
}
.quick-start h2 { color: white; }
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
.quick-start li { margin-bottom: 0.5rem; }
/* FOOTER */ /* FOOTER */
.footer { .footer {
background: var(--cds-ui-01); background: var(--cds-ui-01);
@@ -182,93 +247,94 @@
/* COMMENTS SECTION */ /* COMMENTS SECTION */
.comments-section { .comments-section {
background: var(--cds-layer); background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
.comments-toggle { .comments-toggle {
padding: 0.7rem 1.25rem; padding: 0.75rem 1.5rem;
background: var(--cds-button-primary); background: var(--cds-button-primary);
color: white; color: white;
border: none; border: none;
border-radius: 3px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.comments-toggle:hover { background: var(--cds-hover-primary); } .comments-toggle:hover { background: var(--cds-hover-primary); }
.comments-panel { .comments-panel {
display: none; display: none;
margin-top: 1rem; margin-top: 1rem;
padding: 1rem; padding: 1rem;
background: var(--cds-layer-accent); background: var(--cds-ui-01);
border-radius: 3px;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
.comments-panel.open { display: block; } .comments-panel.open { display: block; }
.comments-panel input, .comments-panel input,
.comments-panel textarea { .comments-panel textarea {
width: 100%; width: 100%;
padding: 0.7rem; padding: 0.75rem;
border: 1px solid var(--cds-border-strong); border: 1px solid var(--cds-border-subtle);
background: var(--cds-field); border-radius: 3px;
background: var(--cds-ui-02);
color: var(--cds-text-primary); color: var(--cds-text-primary);
font-family: inherit; font-family: inherit;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.comments-panel input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea { .comments-panel textarea {
resize: vertical; resize: vertical;
min-height: 80px; min-height: 80px;
} }
.comment-buttons { .comment-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.comment-buttons button { .comment-buttons button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 3px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.submit-btn { .submit-btn {
background: var(--cds-button-primary); background: var(--cds-button-primary);
color: white; color: white;
} }
.submit-btn:hover { background: var(--cds-hover-primary); } .submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn { .close-btn {
background: var(--cds-layer-selected); background: var(--cds-border-subtle);
color: var(--cds-text-primary); color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
} }
.close-btn:hover { background: var(--cds-layer-selected-hover); } .close-btn:hover { background: var(--cds-hover-ui); }
.comments-list { .comments-list {
margin-top: 1rem; margin-top: 1rem;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
} }
.comment-item { .comment-item {
padding: 0.75rem; padding: 0.75rem;
background: var(--cds-background); background: var(--cds-background);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 12px; font-size: 12px;
} }
@@ -287,24 +353,22 @@
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; } .proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px; .proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; } border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d); .proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
padding: 1.25rem; } border-radius: 6px; padding: 1.25rem; }
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); } .proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; } .proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
/* Shown once when the project someone had open turns out to have been archived .proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
rather than deleted — otherwise the picker just silently resets on them. */
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; } .proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); } .proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); } .proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); } .proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; } .link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { flex-direction: column; text-align: center; }
.header-spacer { display: none; }
.hero h1 { font-size: 1.75rem; } .hero h1 { font-size: 1.75rem; }
.cards-grid { grid-template-columns: 1fr; } .cards-grid { grid-template-columns: 1fr; }
.container { padding: 1.5rem; } .container { padding: 1.5rem; }
@@ -313,17 +377,19 @@
</head> </head>
<body> <body>
<!-- HEADER --> <!-- HEADER -->
<header class="wp-appbar"> <header class="header">
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home"> <div class="header-content">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span> <a href="index.html" class="logo">
<span class="wp-appbar-title">Work Package Suite</span> <img src="prime-controls-logo.jpg" alt="Prime Controls">
</a> <div>Work Package Suite</div>
<div class="wp-appbar-spacer"></div> </a>
<nav class="wp-appbar-actions"> <div class="header-spacer"></div>
<a class="wp-appbar-link" href="#overview">Overview</a> <nav class="header-nav">
<a class="wp-appbar-link" href="#comments">Feedback</a> <a href="#overview">Overview</a>
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a> <a href="#comments">Feedback</a>
</nav> <a href="#" onclick="openHelp();return false;">Help</a>
</nav>
</div>
</header> </header>
<!-- MAIN CONTENT --> <!-- MAIN CONTENT -->
@@ -366,20 +432,17 @@
<button class="card-button" id="card-dash-btn">Open Dashboard</button> <button class="card-button" id="card-dash-btn">Open Dashboard</button>
</a> </a>
<!-- FIELD VIEW --> </div>
<a href="field.html" class="card" id="card-field">
<h3>Field View</h3>
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
<!-- USER DIRECTORY -->
<a href="users.html" class="card" id="card-users">
<h3>User Directory</h3>
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
<button class="card-button" id="card-users-btn">Open Directory</button>
</a>
<!-- QUICK START -->
<div class="section quick-start">
<h2>Getting Started</h2>
<ol>
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
</ol>
</div> </div>
<!-- COMMENTS SECTION --> <!-- COMMENTS SECTION -->
@@ -425,34 +488,12 @@
_projects = list || []; _projects = list || [];
// Reconcile the active project against the list; clear if it's gone. // Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive(); const active = ProjectData.getActive();
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null; if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
if(dropped) ProjectData.setActive(null);
renderProjectPicker(); renderProjectPicker();
applyActiveProject(); applyActiveProject();
// "No longer in the list" used to mean one thing — deleted. Now it also
// means archived, and resetting someone to "Select a project" with no word
// about it sends them hunting for a job that is merely finished.
if(dropped) explainDroppedProject(dropped);
}); });
} }
function explainDroppedProject(p){
// The project is still readable when it's archived; a delete (or access being
// taken away) fails here, and that case genuinely has nothing to say.
ProjectData.get(p.id).then(full => {
if(!full || !full.archived) return;
const box = document.getElementById('project-picker');
if(!box || document.getElementById('proj-archived-note')) return;
const note = document.createElement('div');
note.id = 'proj-archived-note';
note.className = 'proj-archived-note';
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
archived — it is read-only and no longer listed here. An administrator can unarchive it
from the Admin Console.`;
box.insertBefore(note, box.firstChild);
}).catch(() => {});
}
function createFormHtml(){ function createFormHtml(){
return `<div class="proj-form" id="proj-form" style="display:none"> return `<div class="proj-form" id="proj-form" style="display:none">
<div class="proj-form-grid"> <div class="proj-form-grid">
@@ -547,7 +588,6 @@
setHref('card-sop', 'work-package-suite.html?tab=sop'); setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp'); setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard'); setHref('card-dash', 'work-package-suite.html?view=dashboard');
setHref('card-field', 'field.html?src=home');
cards.style.display = ''; cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite'; heroTitle.textContent = active.name || 'Work Package Suite';
@@ -555,8 +595,8 @@
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>`;
// Pull the project's shared SOP/WPs from the server into the local cache // Pull the project's shared SOP from the server into the local cache first,
// first, so the SOP "Complete / Review" status reflects what other users did. // 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)); } if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active); else reflectSOPStatus(active);
} }
@@ -698,6 +738,5 @@
.catch(() => {}); .catch(() => {});
} }
</script> </script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

View File

@@ -21,7 +21,7 @@
max-width: 400px; max-width: 400px;
background: var(--cds-layer); background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-top: 3px solid var(--cds-interactive-01); box-shadow: 0 2px 6px var(--cds-shadow);
padding: 2.5rem 2rem; padding: 2.5rem 2rem;
} }
.brand { .brand {
@@ -70,25 +70,6 @@
} }
.error.show { display: block; } .error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; } .foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok {
display: none;
background: #defbe6;
border-left: 3px solid var(--cds-support-success);
color: #0e6027;
padding: 0.75rem;
font-size: 0.8125rem;
margin-bottom: 1.25rem;
}
.ok.show { display: block; }
.note {
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; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style> </style>
</head> </head>
<body> <body>
@@ -96,64 +77,29 @@
<div class="brand"> <div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'"> <img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
</div> </div>
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<div id="error" class="error" role="alert"></div> <div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div>
<!-- SIGN IN --> <form id="login-form" autocomplete="on">
<section id="view-login"> <div class="field">
<h1>Sign in</h1> <label for="username">Username</label>
<p class="sub">Work Package Suite</p> <input id="username" name="username" type="text" autocomplete="username" autofocus required>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div> </div>
<form id="forgot-form" autocomplete="on"> <div class="field">
<div class="field"> <label for="password">Password</label>
<label for="forgot-username">Username or email</label> <input id="password" name="password" type="password" autocomplete="current-password" required>
<input id="forgot-username" type="text" autocomplete="username" required> </div>
</div> <button id="submit" type="submit">Sign in</button>
<button id="forgot-submit" type="submit">Email me a reset link</button> </form>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) --> <p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
<section id="view-reset" style="display:none"> <a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
<h1>Set a new password</h1> </p>
<p class="sub">Choose a password you don't use anywhere else.</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;">
<form id="reset-form" autocomplete="on"> 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 class="field"> </div>
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p> <p class="foot">Authorized use only · BTG / Pilot</p>
</main> </main>

View File

@@ -1,42 +1,13 @@
/* Login page logic for the Work Package Suite. /* Login page logic for the Work Package Suite.
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
Three views on one page: session cookie (not readable here — that's the point) and we redirect to the
• sign in posts to /api/auth/login. On success the server sets an page the user was trying to reach, or the home page. */
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
(function () { (function () {
'use strict'; 'use strict';
var form = document.getElementById('login-form');
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok'); var submitBtn = document.getElementById('submit');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
okBox.classList.remove('show');
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Where to go after signing in: the ?next= param if it's a safe same-site // 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 // path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
@@ -49,58 +20,44 @@
return 'index.html'; return 'index.html';
} }
function resetToken() { function showError(msg) {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; } errorBox.textContent = msg;
errorBox.classList.add('show');
} }
function postJson(url, payload) { var forgot = document.getElementById('forgot-link');
return fetch(url, { if (forgot) {
method: 'POST', forgot.addEventListener('click', function (e) {
headers: { 'Content-Type': 'application/json' }, e.preventDefault();
body: JSON.stringify(payload) var m = document.getElementById('forgot-msg');
}).then(function (r) { if (m) m.style.display = 'block';
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
}); });
} }
function detail(res, fallback) {
var d = res && res.json && res.json.detail;
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
clearBanners(); errorBox.classList.remove('show');
var username = byId('username').value.trim(); var username = document.getElementById('username').value.trim();
var password = byId('password').value; var password = document.getElementById('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; } if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…'; submitBtn.textContent = 'Signing in…';
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) { fetch('/api/auth/login', {
if (res.ok) { location.replace(nextTarget()); return; } method: 'POST',
if (res.status === 401) showError('Invalid username or password.'); headers: { 'Content-Type': 'application/json' },
else if (res.status === 403) showError(detail(res, 'Your account is disabled.')); body: JSON.stringify({ username: username, password: password })
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.')); })
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').')); .then(function (r) {
submitBtn.disabled = false; if (r.ok) { location.replace(nextTarget()); return null; }
submitBtn.textContent = 'Sign in'; 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 () { .catch(function () {
showError('Could not reach the server. Check your connection and try again.'); showError('Could not reach the server. Check your connection and try again.');
@@ -108,108 +65,4 @@
submitBtn.textContent = 'Sign in'; submitBtn.textContent = 'Sign in';
}); });
}); });
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})(); })();

View File

@@ -1,18 +0,0 @@
{
"name": "Prime Work Package Suite",
"short_name": "WP Suite",
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
"start_url": "/index.html",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f4f4f4",
"theme_color": "#161616",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
]
}

View File

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj'; var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); } function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } } function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} } function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -59,24 +59,12 @@
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only .catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
}, },
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) { remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' }) return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function (r) { .then(function () { cacheRemove(id); })
if (r.ok || r.status === 404) { cacheRemove(id); return true; } .catch(function () { cacheRemove(id); });
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
}, },
// Archiving a project lives in the admin console (html/admin.js), which doesn't
// load this file — deliberately not mirrored here, so there's only one
// implementation of it rather than two that can disagree.
// ── active project context ──────────────────────────────────────────────── // ── active project context ────────────────────────────────────────────────
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } }, getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } }, getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
@@ -99,8 +87,6 @@
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact // 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 // 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. // API whenever the apps save. The apps' own (synchronous) reads are unchanged.
// (Original author: C-West8, "storing data in DB instead of client only";
// reintegrated on top of the BIM/per-package work.)
function nsKey(base, id) { return id ? base + '__' + id : base; } function nsKey(base, id) { return id ? base + '__' + id : base; }
function currentUser() { function currentUser() {
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; } try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
@@ -108,8 +94,7 @@
// A saved Work Package is a flat object in the browser; the API splits it into // 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` // promoted columns + a `data` blob. We store the whole flat object in `data`
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all // for perfect round-tripping, and mirror the few fields the API promotes.
// survive), and mirror the few fields the API promotes to columns.
function pkgToServer(p, projectId) { function pkgToServer(p, projectId) {
return { return {
id: p.id, id: p.id,
@@ -119,7 +104,6 @@
subject: p.subject || '', subject: p.subject || '',
type: p.type || '', type: p.type || '',
status: p.status || 'Draft', status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(), created_by: p.createdBy || currentUser(),
data: p data: p
}; };
@@ -133,8 +117,6 @@
if (row.type != null) p.type = row.type; if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id; if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p; return p;
} }
@@ -167,225 +149,37 @@
return Promise.all(jobs).then(function () {}); return Promise.all(jobs).then(function () {});
}; };
// ── Durable write-through outbox ─────────────────────────────────────────── // Write a completed SOP (plus the builder's raw state) to the API. Uses a
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a // deterministic id per project so re-completing updates the same row.
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever. `err` is the server's own
// explanation when it sent one; it is what the sync badge shows the user.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) {
// Refused once, refused forever — so the only useful thing left is the
// reason. A 409 here is the archived-project gate, whose detail tells
// the user the project is read-only and how to get it unarchived; a
// bare "HTTP 409" would leave them staring at a change that vanished.
return r.json().catch(function () { return null; }).then(function (j) {
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
markDead(op.opId, why);
});
}
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
// `failed` stays the total not-getting-through count (what listeners already
// read); `dead` splits out the ops the server has permanently refused, with the
// first reason it gave, because those two states need different words.
function syncCounts() {
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
for (var i = 0; i < q.length; i++) {
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
else if ((q[i].tries || 0) >= 3) failed++;
else pending++;
}
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
// reason is the only thing that tells the user what to do (e.g. the project is
// archived). Stack it under the headline; the badge never hides in this state.
el.style.flexDirection = c.dead ? 'column' : 'row';
el.style.alignItems = c.dead ? 'flex-start' : 'center';
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
} else if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) { ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null); if (!projectId) return Promise.resolve(null);
enqueue({ var body = {
kind: 'sop', key: 'sop__' + projectId, id: 'sop__' + projectId,
body: { project_id: projectId,
id: 'sop__' + projectId, project_id: projectId, name: (sop && sop.project && sop.project.name) || 'SOP',
name: (sop && sop.project && sop.project.name) || 'SOP', number: (sop && sop.project && sop.project.number) || '',
number: (sop && sop.project && sop.project.number) || '', complete: true,
complete: true, created_by: currentUser(), data: { sop: sop, state: state } created_by: currentUser(),
} data: { sop: sop, state: state }
}); };
return Promise.resolve(true); 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. The local cache stays the source of truth for // Upsert a single Work Package to the API (fire-and-forget from the caller's
// immediate rendering; the outbox guarantees the write reaches the server. // perspective; the local cache is the source of truth for immediate rendering).
ProjectData.pushWP = function (p, projectId) { ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null); if (!p || !p.id) return Promise.resolve(null);
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) }); return fetch(API + '/wps', {
return Promise.resolve(true); 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) { ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve(); if (!id) return Promise.resolve();
enqueue({ kind: 'wp-del', key: id }); return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
return Promise.resolve(true); .then(function () {}).catch(function () {});
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
}; };
// 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

View File

@@ -1,108 +0,0 @@
/* Service worker for the Work Package Suite PWA.
Goal: let the app (and especially the field view) load and run offline. Data
durability is already handled by the sync outbox in project-data.js — this
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
• HTML / CSS / JS → network-first, cache as fallback. These reference each
other, so a page must never run against a stale sibling.
• images / icons / manifest → stale-while-revalidate (instant from cache).
*/
'use strict';
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v6';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html', '/users.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
'/console-util.js', '/admin.js', '/users.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];
self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
// can be filled from stale HTTP entries, freezing a mismatched shell.
.then((c) => Promise.all(SHELL.map(
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
// where offline speed actually comes from.
//
// Why not cache-first for code: these files reference each other, and the cache
// stores them as independent entries. Cache-first served whichever copy of each file
// happened to be stored, so a browser could run new HTML against old CSS — which is
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
// only ever run against the stylesheet and scripts it shipped with.
const CODE_RE = /\.(html|css|js)$|\/$/i;
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
if (url.pathname.startsWith('/api/')) return; // never cache the API
const isCode = CODE_RE.test(url.pathname);
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
// full URL both missed every offline navigation and grew the cache without bound.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
const store = (res) => {
if (res && res.ok && res.type !== 'opaque') {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
}
return res;
};
if (isCode) {
e.respondWith(
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
// the request's default cache mode, which consults the browser HTTP cache — so
// "network-first" alone still let a page run against a stale sibling file.
fetch(req, { cache: 'no-cache' })
.then((res) => {
// A 502/404 must not replace a page the cache could still serve.
if (!res || !res.ok) return fromCache().then((c) => c || res);
return store(res);
})
.catch(() => fromCache()) // offline → last good copy
.then((res) => res || Response.error()) // never resolve to undefined
);
return;
}
e.respondWith(
caches.match(key).then((cached) => {
const network = fetch(req).then(store).catch(() => cached);
return cached || network.then((res) => res || Response.error());
})
);
});

View File

@@ -157,93 +157,3 @@ input, textarea, select {
font-family: inherit; font-family: inherit;
color: var(--cds-text-primary); color: var(--cds-text-primary);
} }
/* ============================================================================
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
----------------------------------------------------------------------------
One dark top bar across every page so the suite reads as a single product.
The Prime Controls logo is a white-background wordmark, so it sits inside a
white "chip" on the near-black bar (reads as intentional, not a stray box).
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
============================================================================ */
:root {
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
--wp-appbar-fg: #ffffff;
--wp-appbar-fg-dim: #c6c6c6;
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
--wp-appbar-hover: #353535;
--wp-appbar-height: 48px;
}
.wp-appbar {
background: var(--wp-appbar-bg);
color: var(--wp-appbar-fg);
display: flex;
align-items: center;
gap: 16px;
height: var(--wp-appbar-height);
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
}
.wp-appbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
text-decoration: none;
color: var(--wp-appbar-fg);
}
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
.wp-logo-chip {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.wp-logo-chip img { height: 24px; width: auto; display: block; }
.wp-appbar-title {
font-size: 15px;
font-weight: 600;
color: var(--wp-appbar-fg);
white-space: nowrap;
letter-spacing: .01em;
}
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
.wp-appbar-spacer { flex: 1 1 auto; }
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
/* Buttons and links that live on the dark bar */
.wp-appbar-btn {
background: transparent;
color: var(--wp-appbar-fg);
border: 1px solid var(--wp-appbar-border);
border-radius: 0;
padding: 7px 14px;
font-size: 14px;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background .15s, border-color .15s;
}
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
@media (max-width: 720px) {
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.wp-appbar-actions { flex-wrap: wrap; }
.wp-appbar-meta { width: 100%; order: 5; }
}

View File

@@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Directory — Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="console.css">
<link rel="stylesheet" href="wp-sidenav.css">
<style>
/* Page-specific only — everything structural is in console.css.
The directory is one wide table, so the column exceptions live here: email is
the one cell long enough to stretch a row, and the two role dropdowns need
room for "Assistant Project Manager" without pushing Actions off screen. */
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
/* The create form is a lot of fields; give the password one room to breathe and
let the project picker take a full row of its own. */
#nu-password{ flex:1 1 200px; }
#nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with
thirty does, and it must not push the Create button below the fold. */
#nu-project-list{ max-height:200px; overflow:auto; border:1px solid var(--border); }
.whoami-chip{ font-size:12px; color:var(--muted); }
.whoami-chip strong{ color:var(--text); }
</style>
</head>
<body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| User Directory</span></span>
</a>
</header>
<div class="wrap" id="users-main" style="display:none">
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
<div>
<h1>User Directory</h1>
<div class="sub" style="margin:0" id="dir-sub">The people on your projects — who they are, and how to reach them.</div>
</div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
<!-- WHAT YOU MAY DO HERE (rendered from GET /api/auth/user-scope) -->
<div id="scope-banner"></div>
<!-- THE DIRECTORY -->
<div class="card">
<h2>People</h2>
<div class="sub" id="people-sub"></div>
<div class="toolbar">
<button onclick="loadUsers()">Refresh</button>
<input id="user-search" placeholder="Search name / username / email / job function…" oninput="renderUsers()">
<select id="user-filter" onchange="renderUsers()">
<option value="">Everyone</option>
<option value="active">Active only</option>
<option value="disabled">Disabled only</option>
<option value="mine">Accounts I manage</option>
</select>
</div>
<div id="users-banner"></div>
<div id="users-table"><div class="note">Loading…</div></div>
</div>
<!-- ADD A USER (managers only; hidden otherwise) -->
<div class="card" id="create-card" style="display:none">
<h2>Add a user</h2>
<div class="sub" id="create-sub"></div>
<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" title="Permissions — what this account may do"></select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
</div>
<div id="nu-projects">
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
<div id="nu-project-list"></div>
</div>
<div class="row" style="margin-top:var(--s3)">
<button class="primary" onclick="createUser()">Create user</button>
<span id="users-create-msg" class="note" style="margin:0"></span>
</div>
</div>
</div>
<script src="console-util.js"></script>
<script src="users.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -1,473 +0,0 @@
/* User Directory for the Work Package Suite.
Moved out of the Admin Console because user administration is no longer
admin-only: a PROJECT SUPER USER creates and manages the accounts on the projects
they administer, which means the page has to be reachable by people who must never
see the console's settings, diagnostics or app-wide switches.
ACCESS — three audiences on one page, decided by GET /api/auth/user-scope:
• App admin every account, every control.
• Project super user the accounts on the projects they administer. Controls
appear per row: an account that is also on a job they
don't administer is read-only, and the row says why.
• Everyone else a read-only directory of the people on their own
projects. No controls at all.
The server enforces every one of those rules (server/app.py: require_user_manager,
require_manage_user, visible_user_ids). Nothing here is a security boundary — it is
here so nobody is shown a button that would only 403, and so the reason is on the
page instead of in an alert.
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
let _users = []; // the directory as the server scoped it
let _scope = null; // GET /api/auth/user-scope
let _meId = null;
// ── boot ──────────────────────────────────────────────────────────────────────
async function boot(){
document.getElementById('users-main').style.display = '';
_meId = (window.WP_USER && window.WP_USER.id) || null;
const { status, json } = await api('GET','/api/auth/user-scope');
// A failed scope call must not leave the page pretending to be read-only-with-no-
// reason: fall back to the least-privileged rendering and say so.
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
if(status !== 200){
banner('scope-banner','bad','❌ '+apiError(status, json, 'Could not work out what you may do here')+
' Showing the directory read-only.');
} else {
renderScope();
}
renderCreateForm();
loadUsers();
}
function banner(id, kind, text){
const el = document.getElementById(id);
if(!el) return;
if(!text){ el.innerHTML=''; return; }
el.className = 'banner' + (kind ? ' '+kind : '');
el.textContent = text;
}
// What this account may do here, stated once at the top rather than implied by which
// buttons happen to be missing.
function renderScope(){
const el = document.getElementById('scope-banner');
const sub = document.getElementById('dir-sub');
if(!_scope.can_manage_users){
el.innerHTML = '';
if(sub) sub.textContent = 'The people on your projects — who they are, and how to reach them. '+
'Only an administrator or a Project Super User can change accounts.';
return;
}
if(_scope.scope === 'all'){
el.className = 'banner';
el.innerHTML = 'You are an <strong>Administrator</strong>: you manage every account in the suite. '+
'App settings, diagnostics and the default-member rules live in the '+
'<a class="home" href="admin.html">Admin Console</a>.';
if(sub) sub.textContent = 'Every login account in the suite.';
return;
}
const names = (_scope.managed_projects||[]).map(p => p.name || p.number || p.id);
el.className = 'banner';
el.innerHTML = 'You are a <strong>Project Super User</strong> on '+
(names.length === 1 ? uesc(names[0]) : names.length+' projects')+
' — you create and manage the accounts on '+(names.length === 1 ? 'that project' : 'those projects')+
(names.length > 1 ? ': <strong>'+names.map(uesc).join('</strong>, <strong>')+'</strong>' : '')+'. '+
'An account that is also on a project you dont administer is read-only here.';
if(sub) sub.textContent = 'The people on your projects, and the accounts you administer.';
}
// ── the table ─────────────────────────────────────────────────────────────────
async function loadUsers(){
const wrap = document.getElementById('users-table');
const { status, json } = await api('GET','/api/auth/users');
if(status !== 200 || !Array.isArray(json)){
banner('users-banner','bad','❌ '+apiError(status, json, 'Could not load the directory'));
wrap.innerHTML = ''; return;
}
banner('users-banner','', '');
_users = json;
renderUsers();
}
function manages(){ return !!(_scope && _scope.can_manage_users); }
function renderUsers(){
const wrap = document.getElementById('users-table');
const q = ((document.getElementById('user-search')||{}).value||'').trim().toLowerCase();
const f = ((document.getElementById('user-filter')||{}).value||'');
const total = _users.length;
const sub = document.getElementById('people-sub');
if(sub){
sub.textContent = manages()
? 'Login accounts you can see. The ones you administer carry controls; the rest are listed for reference.'
: 'Everyone on the projects you can access, plus the administrators.';
}
if(!total){ wrap.innerHTML = '<div class="note">Nobody to show yet.</div>'; return; }
const list = _users.filter(u => {
if(f === 'active' && !u.is_active) return false;
if(f === 'disabled' && u.is_active) return false;
if(f === 'mine' && !u.manageable) return false;
if(!q) return true;
return ((u.username||'')+' '+(u.full_name||'')+' '+(u.email||'')+' '+
(u.project_role||'')+' '+roleLabel(u.role)).toLowerCase().indexOf(q) >= 0;
});
const count = '<div class="note">'+list.length+' of '+total+' '+(total===1?'person':'people')+'</div>';
if(!list.length){ wrap.innerHTML = count+'<div class="note">Nothing matches.</div>'; return; }
const head = manages()
? ['Username','Name','Email',
['Permissions','What this account may do in the app'],
['Project role','Job function on the project — descriptive only'],
['Project access','Which projects this user can access, and their role on each'],
'Status','Last login','Actions']
: ['Name','Username','Email',
['Permissions','What this account may do in the app'],
['Project role','Job function on the project — descriptive only'],
'Status'];
const ths = head.map(h => Array.isArray(h)
? '<th title="'+uesc(h[1])+'">'+uesc(h[0])+'</th>' : '<th>'+uesc(h)+'</th>').join('');
const rows = list.map(manages() ? managerRow : readonlyRow).join('');
wrap.innerHTML = count+'<div class="tscroll"><table class="grid"><thead><tr>'+ths+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+legend();
}
function legend(){
if(!manages()){
return '<div class="note" style="margin-top:10px"><strong>Project role</strong> is the persons job '+
'function — it feeds the SOP team pickers and notification routing, and grants nothing on its own.</div>';
}
return '<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
PERM_ROLES.map(r => '<em>'+uesc(PERM_LABELS[r])+'</em>: '+uesc(PERM_HELP[r])).join(' ')+
' <strong>Project role</strong> is the persons job function — it feeds the SOP team pickers '+
'and notification routing, and grants nothing on its own.</div>';
}
// The read-only card: name, contact, role. No ids are bound into handlers because
// there are no handlers — that is the point of this rendering.
function readonlyRow(u){
return '<tr>'+
'<td><strong>'+uesc(u.full_name || u.username)+'</strong>'+(u.id===_meId?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.username)+'</td>'+
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+
(u.email ? '<a class="home" href="mailto:'+uesc(u.email)+'">'+uesc(u.email)+'</a>' : '—')+'</span></td>'+
'<td><span class="tag '+roleTagClass(u.role)+'">'+uesc(roleLabel(u.role))+'</span></td>'+
'<td>'+uesc(u.project_role || '—')+'</td>'+
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
'</tr>';
}
function managerRow(u){
const me = u.id === _meId;
const uid = jsq(u.id), uname = jsq(u.username);
const can = !!u.manageable;
const why = u.manage_blocked_reason || '';
const fmt = s => s ? wpFormatDateTime(s) : '—';
const role = normRole(u.role);
// Your own row never offers the controls that could lock you out of the app.
const roleCell = me
? '<span class="tag '+roleTagClass(role)+'">'+uesc(roleLabel(role))+'</span><span class="me-tag">locked</span>'
: !can
? '<span class="tag '+roleTagClass(role)+'" title="'+uesc(why)+'">'+uesc(roleLabel(role))+'</span>'
: roleSelect(uid, uname, role);
// Job function follows the same permission as everything else on the row. Note a
// super user cannot edit their OWN row: the server refuses account changes to any
// admin or super-user account, including the caller's.
const projRoleCell = can
? projRoleSelect(uid, uname, u.project_role || '')
: projRoleReadonly(u, can, why);
const actions = [];
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+
// The address is truncated with the full value on the title: a long one used to
// wrap mid-word and push the whole row onto three lines.
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td><div class="cellactions">'+actions.join('')+'</div></td>'+
'</tr>';
}
// Only the roles the server said this caller may grant are offered. The account's
// CURRENT role is always included even when it isn't grantable, or the dropdown would
// silently misreport a Project Admin as a Project User the moment it renders.
function roleSelect(uid, uname, role){
const grantable = (_scope && _scope.grantable_roles) || [];
const opts = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === role);
return '<select class="role-select'+(role==='admin'?' is-admin':'')+
'" title="Change what this account may do" onchange="changeRole(\''+uid+'\',this.value,\''+uname+'\')">'+
opts.map(r => '<option value="'+r+'"'+(role===r?' selected':'')+
(grantable.indexOf(r) < 0 ? ' disabled' : '')+'>'+uesc(PERM_LABELS[r])+'</option>').join('')+
'</select>';
}
function projRoleSelect(uid, uname, pr){
const list = (_scope && _scope.project_roles) || PROJECT_ROLES;
return '<select class="role-select" title="Job function on the project" '+
'onchange="changeProjectRole(\''+uid+'\',this.value,\''+uname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
list.map(r => '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>').join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && list.indexOf(pr) < 0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
}
function projRoleReadonly(u, can, why){
return '<span'+(can?'':' title="'+uesc(why)+'"')+'>'+uesc(u.project_role || '—')+'</span>';
}
// Per-user project access gets its own column: buried among the action buttons, it
// was exactly where you'd fail to find "which projects can this person see, and what
// may they do there".
function projAccessCell(u){
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
const n = u.project_count;
const label = (n === undefined || n === null) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n+' project'+(n===1?'':'s'));
if(!u.manageable){
return '<span class="note" style="margin:0" title="'+uesc(u.manage_blocked_reason||'')+'">'+uesc(label)+'</span>';
}
return '<button class="mini'+(n === 0 ? ' danger' : '')+
'" onclick="manageProjects(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')"'+
' title="Choose which projects this user can access, and their role on each">'+
uesc(label)+'</button>';
}
// ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
async function resetPw(id, username){
const pw = prompt('New password for "'+username+'" (min 12 characters):');
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.');
else alert('Could not reset the password: '+apiError(status, json));
}
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('Could not change that account: '+apiError(status, json)); loadUsers(); }
}
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status !== 200) alert('Could not change permissions for '+username+': '+apiError(status, json));
loadUsers();
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status !== 200) alert('Could not set the project role for '+username+': '+apiError(status, json));
loadUsers();
}
async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
'This cannot be undone — disable the account instead if you only want to block sign-in.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers();
else alert('Could not delete '+username+': '+apiError(status, json));
}
// ── create ────────────────────────────────────────────────────────────────────
function renderCreateForm(){
const card = document.getElementById('create-card');
if(!card) return;
if(!manages()){ card.style.display = 'none'; return; }
card.style.display = '';
const grantable = _scope.grantable_roles || [];
const roleSel = document.getElementById('nu-role');
roleSel.innerHTML = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0)
.map(r => '<option value="'+r+'"'+(r==='project_user'?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>').join('');
const prSel = document.getElementById('nu-project-role');
prSel.innerHTML = '<option value="">Project role…</option>'+
(_scope.project_roles||PROJECT_ROLES).map(r => '<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
// The project picker is REQUIRED for a super user and optional for an admin —
// because a super user's authority over an account comes from the projects it is
// on, so an account created with none is one they instantly cannot manage. The
// server refuses that; the form says so up front rather than after a failed save.
const admin = _scope.scope === 'all';
const projects = _scope.managed_projects || [];
document.getElementById('create-sub').innerHTML = admin
? 'Creates a login account. Assign projects here or later from <strong>Project access</strong> in the table above.'
: 'Creates a login account on your project'+(projects.length===1?'':'s')+
'. You administer users per project, so a new account has to start on at least one of them.';
document.getElementById('nu-projects-label').innerHTML = admin
? 'Projects (optional — you can assign them later)'
: 'Projects <strong>*</strong> — pick at least one';
const live = projects.filter(p => !p.archived);
const list = document.getElementById('nu-project-list');
if(!projects.length){
list.innerHTML = '<div class="note" style="padding:var(--s2)">You dont administer any project yet.</div>';
} else {
// Archived projects are omitted, not disabled: staffing a frozen job is never
// what you mean when creating an account, and an admin can still assign one
// afterwards from the project-access dialog.
list.innerHTML = (live.length ? live : []).map(p =>
'<div class="pickrow"><label><input type="checkbox" value="'+uesc(p.id)+'"'+
(live.length === 1 ? ' checked' : '')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span style="color:var(--muted)">'+uesc(p.number)+'</span>' : '')+'</span></label></div>').join('')
|| '<div class="note" style="padding:var(--s2)">Every project you administer is archived.</div>';
}
}
async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
const username = val('nu-username').trim();
const password = val('nu-password');
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; }
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return;
}
say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{
username, password, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)','✅ Created '+username+'.');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','❌ '+apiError(status, json, 'Could not create the account'));
}
}
// ── project access dialog ─────────────────────────────────────────────────────
// For an admin this is the whole of a person's access. For a super user it is their
// slice of it: the server returns only the projects they administer and says how many
// more the person is on, and a save leaves those others untouched.
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: '+apiError(status, json)); return; }
openProjectModal(id, username, json);
}
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
// A project's role dropdown only matters while that project is ticked.
function projRowToggled(cb){
const row = cb.closest('.pickrow');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
function openProjectModal(userId, username, data){
closeProjectModal();
const projects = (data.projects||[]).slice()
// Live jobs first — an archived one is still listed (an existing assignment has
// to stay removable) but it is finished work, so it doesn't belong at the top of
// a list you're using to staff someone.
.sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
const assigned = new Set(data.assigned||[]);
const roles = data.roles || {};
const userObj = data.user || {};
const isAdmin = normRole(userObj.role) === 'admin';
const acctRole = normRole(userObj.role);
const grantable = data.grantable_project_roles || PROJECT_SCOPED_ROLES;
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
uesc(PERM_LABELS[acctRole]||acctRole)+')</option>']
.concat(PROJECT_SCOPED_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === cur).map(r =>
'<option value="'+r+'"'+(cur===r?' selected':'')+(grantable.indexOf(r)<0?' disabled':'')+'>'+
uesc(PERM_LABELS[r])+' here</option>'));
return '<div class="pickrow">'+
'<label><input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+
'</span></label>'+
'<select class="role-select" data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+'>'+
opts.join('')+'</select>'+
'</div>';
}).join('') : '<div class="note">No projects to choose from.</div>';
const others = data.other_projects || 0;
const intro = isAdmin
? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</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, and set their role '+
'on each. <strong>Project Admin</strong> can delete work packages, change a completed SOP and delete '+
'that project; <strong>Project Super User</strong> can also manage that projects user accounts; '+
'<strong>Project User</strong> can do neither. Leave it on <em>Same as account</em> to use their '+
'Permissions setting.</div>'+
(others ? '<div class="banner warn" style="margin:0 0 10px">Also on '+others+' project'+
(others===1?'':'s')+' you dont administer. Those stay exactly as they are — saving here only '+
'changes the projects listed below.</div>' : '');
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.className = 'modal-ov';
modal.innerHTML =
'<div class="modal-box">'+
'<div class="modal-head">Project access &amp; permissions — '+uesc(username)+'</div>'+
'<div class="modal-body">'+intro+'<div id="proj-list">'+items+'</div></div>'+
'<div class="modal-foot">'+
'<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 roleMap = {};
ids.forEach(pid => {
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
if(sel && sel.value) roleMap[pid] = sel.value;
});
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
{ project_ids: ids, roles: roleMap });
if(status === 200){ closeProjectModal(); loadUsers(); }
else alert('Save failed: '+apiError(status, json));
};
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeProjectModal(); });
// ── start ─────────────────────────────────────────────────────────────────────
// auth-guard.js requires a login and publishes window.WP_USER (firing
// 'wp-auth-ready'). Unlike the Admin Console there is no role gate here: everyone
// signed in gets a directory, and what they can DO comes from the scope call.
let _booted = false;
function start(){
if(_booted || !window.WP_USER) return;
_booted = true;
boot();
}
document.addEventListener('wp-auth-ready', start);
start();

View File

@@ -24,20 +24,14 @@ function onSizePresetChange(){
} }
let state = { let state = {
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
project: {name:'', number:'', client:'', division:'', site:''}, project: {name:'', number:'', client:'', division:'', site:''},
// Leadership names are kept as display strings (so existing SOPs and exports
// still read the same) alongside the user-account id each one resolves to.
// The ids are what let the Creator offer these people as a WP owner and what
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''}, team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [], teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [], wpTypes: [],
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''}, governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
quality: {qcreq:'', photo:'', hold:''}, quality: {qcreq:'', photo:'', hold:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''}, platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
sequence: [], sequence: [],
constraints: [], constraints: [],
sources: [] sources: []
@@ -98,14 +92,7 @@ const OPTIONAL_ROLES = [
'Quality Representative', 'Quality Representative',
'Planner', 'Planner',
'Safety Manager', 'Safety Manager',
'Project Controls Manager', 'Project Controls Manager'
// BIM / VDC roles (used by the BIM template; also selectable on any SOP)
'BIM Coordinator',
'BIM Modeler / Detailer',
'VDC Manager',
'Construction Lead (CRS)',
'Field Lead',
'General Contractor'
]; ];
const LABOR_COST_CODES = [ const LABOR_COST_CODES = [
@@ -150,125 +137,6 @@ const LABOR_COST_CODES = [
'9000|Administration' '9000|Administration'
]; ];
// ── BIM / VDC TEMPLATE ──────────────────────────────────────────────────────────
// The BIM/VDC department also produces work packages under Advanced Work Packaging —
// model & engineering deliverables (EWPs) that feed the field's install packages.
// These defaults are drawn from the EN06 SOP + work instructions and are added by
// enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4
// (each is flagged bim so the Creator can offer them under the EWP kind). Everything
// remains editable afterward.
const BIM_WP_TYPES = [
'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package',
'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package',
'Laser Scan Package', 'As-Built Model / Drawings'
];
// BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5).
const BIM_PHASES = [
'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations',
'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups'
];
const BIM_TEMPLATE_ROLES = [
'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager',
'Construction Lead (CRS)', 'Field Lead', 'General Contractor'
];
// Release-gate constraints for a BIM/model package (the BIM equivalent of the field's
// AWP constraints). Citations point back to the EN06 documents.
const BIM_CONSTRAINTS = [
{name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'},
{name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'},
{name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'},
{name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'},
{name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'},
{name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'},
{name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'},
{name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'}
];
const BIM_SEQUENCE = [
'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs',
'Field coordination / laser scan', 'Model racks, instruments & panels',
'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades',
'Constructability review (CRS)', 'GC submission & sign-off (IFF)',
// BIM deliverable that hands off to the field — only present when BIM is enabled.
'2D installation sheets / Spool Drawings'
];
const BIM_SOURCES = [
{label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'},
{label:'P&IDs', ph:'Procore / SharePoint'},
{label:'Contract / Design Drawings', ph:'Procore / Bluebeam'},
{label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'},
{label:'Specs & Submittals', ph:'client portal'},
{label:'Conduit Schedule', ph:'Excel on SharePoint'},
{label:'Bluebeam Project', ph:'Bluebeam Studio'},
{label:'Pre-Construction Tracker', ph:'SharePoint'},
{label:'Constructability Review Sheet (CRS)', ph:'SharePoint'}
];
// Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable.
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
// BIM is off until it's ready for the field. With the flag off we hide the
// per-project toggle so no new project can be put on the BIM path — but we never
// strip a SOP that already has it on, because that would silently delete its BIM
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
// flag comes back.
function applyBimFlag(){
const wrap = document.getElementById('bim-toggle-wrap');
const note = document.getElementById('bim-disabled-note');
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
if(wrap) wrap.style.display = enabled ? '' : 'none';
if(note){
const stale = !enabled && !!state.bimEnabled;
note.style.display = enabled ? 'none' : '';
note.innerHTML = stale
? '<strong>BIM / VDC is switched off for the whole suite.</strong> This SOP already has BIM enabled, so its ' +
'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' +
'feature is off. An administrator can turn it back on under Features in the Admin Console.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> Every project is install-only (IWP) for now. ' +
'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.';
}
}
// Re-check once the flags land (they arrive asynchronously after auth).
document.addEventListener('wp-flags-ready', applyBimFlag);
function setBimEnabled(on){
state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM();
const cb = document.getElementById('bim_enabled'); if(cb) cb.checked = !!on;
track(on ? 'bim_enabled' : 'bim_disabled');
}
function enableBIM(){
// Package types (flagged bim so the Creator can offer them under "BIM (EWP)").
BIM_WP_TYPES.forEach(n => {
const t = state.wpTypes.find(x => x.name === n);
if(t){ t.bim = true; t.enabled = true; }
else state.wpTypes.push({name:n, enabled:true, notes:'', approval:'', bim:true});
});
renderWPTypes();
// Release-gate constraints (seed standard 10 first if empty, then add BIM gates).
if(!state.constraints || !state.constraints.length) state.constraints = STANDARD_10_CONSTRAINTS.map(c => ({...c}));
_constraintsSeeded = true;
BIM_CONSTRAINTS.forEach(c => { if(!state.constraints.some(x => x.name === c.name)) state.constraints.push({...c, bim:true}); });
renderStandardConstraints();
// BIM sign-off roles (optional), reference sources, and process steps (idempotent).
BIM_TEMPLATE_ROLES.forEach(r => { if(!state.signoffRoles.some(x => x.role === r)) state.signoffRoles.push({role:r, name:'', bim:true}); });
renderOptionalRoles();
// BIM work precedes construction, so put the BIM steps at the FRONT of the sequence.
const bimSteps = BIM_SEQUENCE.filter(lbl => !state.sequence.some(s => s.label === lbl)).map(lbl => ({label:lbl, kind:'step', bim:true}));
state.sequence = [...bimSteps, ...state.sequence];
renderSequenceSteps();
BIM_SOURCES.forEach(s => { if(!state.sources.some(x => x.label === s.label)) state.sources.push({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true, bim:true}); });
renderSources();
}
function disableBIM(){
state.wpTypes = state.wpTypes.filter(t => !t.bim); renderWPTypes();
state.constraints = (state.constraints || []).filter(c => !c.bim); renderStandardConstraints();
state.signoffRoles = state.signoffRoles.filter((r,i) => i < 2 || !r.bim); renderOptionalRoles();
state.sequence = (state.sequence || []).filter(s => !s.bim); renderSequenceSteps();
state.sources = (state.sources || []).filter(s => !s.bim); renderSources();
}
// ── INITIALIZATION ──────────────────────────────────────────────────────────── // ── INITIALIZATION ────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded',()=>{ window.addEventListener('DOMContentLoaded',()=>{
initializeWPTypes(); initializeWPTypes();
@@ -289,17 +157,10 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP(); restoreSavedSOP();
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
// keep opening the dashboard for the rest of the session.
const tab = params.get('tab'); const tab = params.get('tab');
_deepLinkWp = params.get('wp') || ''; if(params.get('view') === 'dashboard') switchTool('dashboard');
const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab); else if(tab === 'wp' || tab === 'sop') switchTool(tab);
else if(_deepLinkWp) switchTool('wp');
} }
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull); ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -347,25 +208,15 @@ function loadSampleData(){
document.getElementById('proj_division').value = 'Semiconductor'; document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7'; document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
// Populate Step 2. The leadership slots are account pickers now, so the sample's // Populate Step 2
// fictional names can't be "selected" — setting .value on a <select> with no document.getElementById('proj_pm').value = 'Mariano Sanchez';
// matching option silently does nothing. Store them as names without an account, document.getElementById('proj_apm').value = 'Assistant PM';
// which is exactly how the picker shows a person who isn't a suite user yet. document.getElementById('proj_cm').value = 'K. Boyd';
state.team.pm = 'Mariano Sanchez'; document.getElementById('proj_qm').value = 'D. Nguyen';
state.team.apm = 'Assistant PM';
state.team.cm = 'K. Boyd';
state.team.qm = 'D. Nguyen';
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
renderTeamPickers();
// Step 3 — standard required roles // Step 3 already has defaults
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent'; document.getElementById('role_super_name').value = 'John Smith';
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman'; document.getElementById('role_foreman_name').value = 'Mike Jones';
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
renderSignoffRolePickers();
// Populate Step 5 // Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]'; document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
@@ -380,13 +231,7 @@ function loadSampleData(){
document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.'; document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.';
// Step 7 already has defaults // Step 7 already has defaults
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
state.bimEnabled = true;
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
enableBIM();
// Collect all data // Collect all data
collectStepData(); collectStepData();
track('sample_loaded'); track('sample_loaded');
@@ -412,9 +257,6 @@ function restoreSavedSOP(){
state = savedState; state = savedState;
sop = savedSop; sop = savedSop;
sopComplete = true; sopComplete = true;
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
// Re-render dynamic lists from restored state. // Re-render dynamic lists from restored state.
renderWPTypes(); renderWPTypes();
@@ -436,12 +278,12 @@ function repopulateForm(){
set('proj_client', state.project.client); set('proj_client', state.project.client);
set('proj_division', state.project.division); set('proj_division', state.project.division);
set('proj_site', state.project.site); set('proj_site', state.project.site);
// The leadership slots and sign-off names are account pickers, not text inputs — set('proj_pm', state.team.pm);
// these build their options and mark the current selection. set('proj_apm', state.team.apm);
renderTeamPickers(); set('proj_cm', state.team.cm);
renderSignoffRolePickers(); set('proj_qm', state.team.qm);
if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role); if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role); if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
set('gov_woformat', state.governance.woformat); set('gov_woformat', state.governance.woformat);
// gov_wosize is now a <select>; if a saved value isn't one of the presets // gov_wosize is now a <select>; if a saved value isn't one of the presets
// (e.g. legacy free text), add it as an option so the round-trip preserves it. // (e.g. legacy free text), add it as an option so the round-trip preserves it.
@@ -458,9 +300,6 @@ function repopulateForm(){
set('qual_hold', state.quality.hold); set('qual_hold', state.quality.hold);
set('plat_tracking', state.platforms.tracking); set('plat_tracking', state.platforms.tracking);
set('plat_commissioning', state.platforms.commissioning); set('plat_commissioning', state.platforms.commissioning);
set('plat_tracking_url', state.platforms.trackingUrl);
set('plat_commissioning_url', state.platforms.commissioningUrl);
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled;
} }
// ── TOOL SWITCHING ──────────────────────────────────────────────────────────── // ── TOOL SWITCHING ────────────────────────────────────────────────────────────
@@ -484,81 +323,11 @@ function switchTool(tool){
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—'; document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
if(contentTool === 'wp') renderWPTab(isDash); if(contentTool === 'wp') renderWPTab(isDash);
// Only go full-bleed when the creator is actually showing. With the SOP
// incomplete this tab shows a short 'complete the SOP first' gate; making the
// page unscrollable around it can clip its button off the bottom.
applyEmbedLayout(contentTool === 'wp' && sopComplete);
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
} }
// The embedded creator/dashboard fills the window below the app chrome, so there
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
// page — and the creator's sticky bars have a real viewport to stick to.
// A work package the global search asked us to open, handed to the creator on its
// next load and then cleared.
let _deepLinkWp = '';
function applyEmbedLayout(on){
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter
// than the creator's own sticky bars while the page itself can't scroll. Fall back
// to normal page flow and let the page scroll instead.
const MIN_FILL_H = 460;
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H;
const full = !!on && fits;
const area = document.querySelector('.content-area');
const frame = document.getElementById('wp-frame');
if(area) area.classList.toggle('embed-full', full);
if(frame) frame.classList.toggle('fill', full);
document.body.classList.toggle('embed-full', full);
sizeWPFrame(full);
}
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any
// stylesheet — including a stale cached one — so the creator can't collapse to the
// 300x150 default iframe box if the CSS and the HTML are ever out of step.
function sizeWPFrame(on){
const frame = document.getElementById('wp-frame');
if(!frame) return;
frame.style.width = '100%';
frame.style.border = '0';
if(on){
const h = viewportMinusChrome();
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px');
frame.style.height = h + 'px';
frame.style.minHeight = '0';
} else {
frame.style.height = '';
frame.style.minHeight = 'calc(100vh - 200px)';
}
}
function chromeHeight(){
const hdr = document.querySelector('.header');
const nav = document.querySelector('.main-nav');
return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
}
// Whatever is left of the window below the app bar + tab strip. No floor: a floor
// taller than the remaining space pushes the frame (and the creator's fixed save bar)
// off a window that has scrolling disabled.
function viewportMinusChrome(){
return Math.max(0, window.innerHeight - chromeHeight());
}
// Re-measure on resize, and re-decide whether full-bleed still fits.
window.addEventListener('resize', () => {
if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true);
}, {passive:true});
// The app bar grows when wp-chrome.js injects the project switcher and search, which
// happens after the frame has already been sized. Watch the chrome instead of relying
// on someone resizing the window.
try {
const _ro = new ResizeObserver(() => {
if(document.body.classList.contains('embed-full')) sizeWPFrame(true);
});
['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); });
} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ }
// Show the gate or the embedded Work Package Creator depending on SOP status. // Show the gate or the embedded Work Package Creator depending on SOP status.
// wantDash=true opens the creator straight to the dashboard view. // wantDash=true opens the creator straight to the dashboard view.
function renderWPTab(wantDash){ function renderWPTab(wantDash){
@@ -568,49 +337,12 @@ function renderWPTab(wantDash){
if(sopComplete){ if(sopComplete){
gate.style.display = 'none'; gate.style.display = 'none';
frame.style.display = 'block'; frame.style.display = 'block';
// Reload each time so the creator picks up the latest SOP from localStorage.
const sp = new URLSearchParams(window.location.search); const sp = new URLSearchParams(window.location.search);
const dash = wantDash || sp.get('view') === 'dashboard';
const projId = sp.get('project') || (activeProject && activeProject.id) || ''; const projId = sp.get('project') || (activeProject && activeProject.id) || '';
const wantWp = _deepLinkWp; _deepLinkWp = ''; frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
const dash = wantDash; + (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
// The frame's identity is the PROJECT only. The view (form vs dashboard) and
// which package to open are applied by calling into the loaded document, so
// switching tabs never reloads it — reloading discarded unsaved form edits, made
// the creator unreachable offline, and stored a fresh copy in the SW cache each
// time. It's same-origin, so a direct call is fine.
const src = 'wp-creation-index.html?embedded=1'
+ (projId ? '&project=' + encodeURIComponent(projId) : '');
const applyNow = () => {
try {
const cw = frame.contentWindow;
if(!cw) return;
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
}
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
} catch(e){ /* cross-document timing; nothing useful to do */ }
};
// Wait for the creator's data, not just its document. `load` fires before
// pullProject() resolves, so opening a specific package straight after load
// silently found nothing.
const whenReady = () => {
try {
const cw = frame.contentWindow;
if(cw && cw.wpCreatorReady) { applyNow(); return; }
if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; }
} catch(e){}
applyNow();
};
if(frame.getAttribute('data-src') === src && frame.contentWindow){
whenReady();
} else {
frame.setAttribute('data-src', src);
frame.addEventListener('load', whenReady, {once:true});
frame.src = src;
}
}else{ }else{
gate.style.display = 'block'; gate.style.display = 'block';
frame.style.display = 'none'; frame.style.display = 'none';
@@ -669,7 +401,6 @@ function renderWPTypes(){
container.innerHTML = `<div class="wp-types-header"> container.innerHTML = `<div class="wp-types-header">
<div>Work Order Type</div> <div>Work Order Type</div>
<div style="text-align:center;">Enabled</div> <div style="text-align:center;">Enabled</div>
<div>Spec Section</div>
<div>Special Rules / Notes</div> <div>Special Rules / Notes</div>
<div>WO Complete Approval</div> <div>WO Complete Approval</div>
</div>`; </div>`;
@@ -685,7 +416,6 @@ function renderWPTypes(){
row.innerHTML = ` row.innerHTML = `
${nameCell} ${nameCell}
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div> <div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`; `;
@@ -717,163 +447,20 @@ function removeWPType(i){
renderWPTypes(); renderWPTypes();
} }
// ── PROJECT TEAM (drawn from user accounts on the project) ────────────────
// The people nameable on a SOP are the project's members (plus admins), so every
// name on the team resolves to an account the suite can assign work to and email.
// Their `project_role` (job function, set in the Admin Console) is offered as the
// default title for an additional team member.
let projectUsers = []; // [{id, full_name, username, email, project_role}]
let projectUsersLoaded = false;
function userLabel(u){
const name = u.full_name || u.username || '';
return u.project_role ? `${name}${u.project_role}` : name;
}
function userById(id){ return projectUsers.find(u => u.id === id) || null; }
async function loadProjectUsers(){
const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : '';
if(pid){
try {
const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'});
if(r.ok) projectUsers = await r.json();
} catch(e){ /* offline — fall back to whatever the SOP already stored */ }
}
projectUsersLoaded = true;
renderTeamPickers();
renderTeamMembers();
renderSignoffRolePickers();
}
// One <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
// name that has no matching account as a selected "(no account)" option so older
// SOPs — and the sample's fictional names — are never silently dropped.
function userSelectOptions(curId, curName){
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
}
return html;
}
// Sign-off roles (step 3) name the people who must sign a package, so they use the
// same picker as the leadership slots — a signature belongs to an account.
function renderSignoffRolePickers(){
[['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => {
const sel = document.getElementById(id);
const r = state.signoffRoles[ix];
if(!sel || !r) return;
sel.innerHTML = userSelectOptions(r.userId || '', r.name || '');
sel.onchange = function(){
if(this.value === '__orphan__') return;
const u = userById(this.value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderSignoffRolePickers();
};
});
renderOptionalRoles();
}
// Read the four leadership pickers back into state. `state.team[key]` always holds
// a display NAME and `state.teamIds[key]` the account id; a name kept from an older
// SOP whose person has no account (the "(no account)" option) is left alone.
function syncTeamFromPickers(){
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
if(sel.value === '__orphan__') return; // legacy typed name — keep it
const u = userById(sel.value);
state.teamIds[key] = u ? u.id : '';
if(u) state.team[key] = u.full_name || u.username;
else if(sel.value === '') state.team[key] = ''; // explicitly unassigned
});
}
function setOptionalRolePerson(ix, value){
const r = state.signoffRoles[ix];
if(!r || value === '__orphan__') return;
const u = userById(value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderOptionalRoles();
}
function setTeamLead(key, userId){
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
const u = userById(userId);
state.teamIds[key] = u ? u.id : '';
state.team[key] = u ? (u.full_name || u.username) : '';
renderTeamPickers();
}
function renderTeamMembers(){ function renderTeamMembers(){
const container = document.getElementById('team-members-list'); const container = document.getElementById('team-members-list');
if(!container) return; if(!container) return;
const opts = (cur) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>` container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> <div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select> <input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<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="removeTeamMember(${i})">✕</button> <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="removeTeamMember(${i})">✕</button>
</div> </div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).join(''); `).join('');
} }
// Picking the person fills the title from their project role but leaves it
// editable — the same person can wear a different hat on a given project.
function setExtraTeamMember(i, userId){
const m = state.teamMembers[i]; if(!m) return;
const u = userById(userId);
m.userId = u ? u.id : '';
m.name = u ? (u.full_name || u.username) : '';
if(u && !m.role) m.role = u.project_role || '';
renderTeamMembers();
}
function addTeamMember(){ function addTeamMember(){
state.teamMembers.push({role:'',name:'',userId:''}); state.teamMembers.push({role:'',name:''});
renderTeamMembers(); renderTeamMembers();
} }
@@ -884,14 +471,13 @@ function removeTeamMember(i){
function renderOptionalRoles(){ function renderOptionalRoles(){
const container = document.getElementById('optional-roles-list'); const container = document.getElementById('optional-roles-list');
// The first two entries are the required (editable-title) roles; the rest are optional. const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
const current = state.signoffRoles.slice(2);
container.innerHTML = current.map((r,i)=>` container.innerHTML = current.map((r,i)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> <div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value"> <select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')} ${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
</select> </select>
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select> <input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button> <button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
</div> </div>
`).join(''); `).join('');
@@ -919,7 +505,6 @@ function renderStandardConstraints(){
_constraintsSeeded = true; _constraintsSeeded = true;
} }
const active = name => state.constraints.some(c=>c.name===name); const active = name => state.constraints.some(c=>c.name===name);
const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); };
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}" ${active(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;">
@@ -927,33 +512,11 @@ function renderStandardConstraints(){
<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>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div> </div>
`).join(''); `).join('');
renderCustomConstraints(); renderCustomConstraints();
} }
// A CRITICAL constraint is one whose reopening after release is announced by
// email (PM + CM + the package owner) rather than only dropping the package to
// Issue (Hold). Only meaningful for a constraint that's switched on.
function criticalToggle(name, enabled, isCritical){
const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_');
const tip = 'Critical: if this constraint reopens after the work package has been released, ' +
'notify the PM, CM and the package owner by email.';
return `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
function toggleCriticalConstraint(name, on){
const c = state.constraints.find(x=>x.name===name);
if(!c) return;
c.critical = !!on;
renderStandardConstraints();
track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name});
}
// Render the custom (non-standard) constraints into their own list with remove buttons. // Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){ function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return; const el = document.getElementById('custom-constraints-list'); if(!el) return;
@@ -962,10 +525,7 @@ function renderCustomConstraints(){
el.innerHTML = customs.length ? customs.map(c=>` 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;"> <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> <strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;"> <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>
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`; </div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
} }
@@ -977,13 +537,7 @@ function removeCustomConstraint(name){
function toggleConstraint(name){ function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name); const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1); if(idx>=0) state.constraints.splice(idx,1);
else { else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
// Copy the library entry — pushing the shared object would let one project's
// `critical` flag leak into every other project's default constraint set.
const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name);
if(def) state.constraints.push({...def});
}
renderStandardConstraints(); // the Critical toggle enables/disables with the row
} }
function showConstraintLibrary(){ function showConstraintLibrary(){
@@ -1021,26 +575,13 @@ function addCustomConstraintText(){
renderStandardConstraints(); renderStandardConstraints();
} }
// Default construction flow (used when BIM is off; the BIM steps are prepended when const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
// the project includes BIM/VDC). Includes QC-hold gates. Items may be strings or
// {label, kind} objects.
const DEFAULT_SEQUENCE = [
{label:'Conduit Install', kind:'step'},
{label:'Tray Install', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Wire Pull', kind:'step'},
{label:'Device Install', kind:'step'},
{label:'Termination', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Commissioning', kind:'step'},
{label:'As-built (scan / redlines)', kind:'step'}
];
let seqDragIndex = null; let seqDragIndex = null;
function renderSequenceSteps(){ function renderSequenceSteps(){
const container = document.getElementById('sequence-list'); const container = document.getElementById('sequence-list');
if(!container) return; if(!container) return;
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'}); if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
container.innerHTML = ''; container.innerHTML = '';
let stepNo = 0; let stepNo = 0;
state.sequence.forEach((item,i)=>{ state.sequence.forEach((item,i)=>{
@@ -1111,13 +652,6 @@ const DEFAULT_SOURCES = [
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >), // SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
// so attribute values must be escaped or a re-render corrupts the field. // so attribute values must be escaped or a re-render corrupts the field.
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;'); }
// A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in
// this order: backslash, then quote (for the JS string literal), then escAttr (for
// the attribute carrying it). Quote-escaping alone breaks on a value containing a
// backslash — the backslash escapes the backslash, the quote closes the literal, and
// the rest runs as code. Constraint names travel with the SOP to everyone on the
// project, so they are not this browser's own input.
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
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, preset:true})); if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
@@ -1202,19 +736,14 @@ function collectStepData(){
state.project.site = document.getElementById('proj_site').value; state.project.site = document.getElementById('proj_site').value;
break; break;
case 2: case 2:
// These are user-account pickers now, so their .value is an ID, not a name. state.team.pm = document.getElementById('proj_pm').value;
// Reading them straight into state.team would put an id where the display state.team.apm = document.getElementById('proj_apm').value;
// name belongs (and it would then print on the SOP as `user_ab12…`). state.team.cm = document.getElementById('proj_cm').value;
syncTeamFromPickers(); state.team.qm = document.getElementById('proj_qm').value;
break; break;
case 3: case 3:
// The two required roles now have editable titles (default Superintendent/Foreman). state.signoffRoles[0].name = document.getElementById('role_super_name').value;
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim(); state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
// Titles are still free text; the NAMES are account pickers whose .value is
// an id, so they're maintained by their own onchange (see
// renderSignoffRolePickers) rather than read as text here.
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
break; break;
case 5: case 5:
state.governance.woformat = document.getElementById('gov_woformat').value; state.governance.woformat = document.getElementById('gov_woformat').value;
@@ -1234,8 +763,6 @@ function collectStepData(){
case 7: case 7:
state.platforms.tracking = document.getElementById('plat_tracking').value; state.platforms.tracking = document.getElementById('plat_tracking').value;
state.platforms.commissioning = document.getElementById('plat_commissioning').value; state.platforms.commissioning = document.getElementById('plat_commissioning').value;
state.platforms.trackingUrl = (document.getElementById('plat_tracking_url').value || '').trim();
state.platforms.commissioningUrl = (document.getElementById('plat_commissioning_url').value || '').trim();
break; break;
} }
} }
@@ -1260,25 +787,12 @@ function validateStep(n){
} }
// ── SOP COMPLETION ──────────────────────────────────────────────────────────── // ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){ function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return;
}
if(!validateStep(10)) return; if(!validateStep(10)) return;
collectStepData(); collectStepData();
sop = { sop = {
meta: {tool:'Work Package Configuration', sample:false}, meta: {tool:'Work Package Configuration', sample:false},
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
project: { project: {
name: state.project.name, name: state.project.name,
number: state.project.number, number: state.project.number,
@@ -1288,20 +802,10 @@ function completeSOP(){
apm: state.team.apm, apm: state.team.apm,
cm: state.team.cm, cm: state.team.cm,
qm: state.team.qm, qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
site: state.project.site, site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId)) teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
}, },
roles: state.signoffRoles.filter(r=>r.role).map(r=>({ roles: state.signoffRoles.filter(r=>r.role),
role: r.role, name: r.name || '',
userId: r.userId || '' // who signs — an account, so it can be notified
})),
governance: { governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'], issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize, woSize: state.governance.wosize,
@@ -1316,21 +820,11 @@ function completeSOP(){
name: t.name.trim(), name: t.name.trim(),
enabled: true, enabled: true,
notes: t.notes || '', notes: t.notes || '',
approval: t.approval || '', approval: t.approval || ''
// Spec section for this type — the Creator fills the WP's Specification
// Section from it, so it's authored once here instead of per package.
specSection: t.specSection || '',
bim: !!t.bim
})), })),
sources: state.sources.filter(s=>s.label), sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''}, field: {trackPlatform: state.platforms.tracking},
commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''}, commissioning: {tool: state.platforms.commissioning},
// Project homepage links in the tracking / commissioning systems. The Creator
// copies these onto every Work Package created for this project.
projectLinks: [
state.platforms.trackingUrl ? {label:'Tracking — '+state.platforms.tracking, system:state.platforms.tracking, url:state.platforms.trackingUrl} : null,
state.platforms.commissioningUrl ? {label:'Commissioning — '+state.platforms.commissioning, system:state.platforms.commissioning, url:state.platforms.commissioningUrl} : null
].filter(Boolean),
quality: { quality: {
qcReq: state.quality.qcreq, qcReq: state.quality.qcreq,
photo: state.quality.photo, photo: state.quality.photo,
@@ -1342,11 +836,7 @@ function completeSOP(){
kind: s.kind || 'step' kind: s.kind || 'step'
})), })),
costCodes: LABOR_COST_CODES, costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({ constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
}; };
sopComplete = true; sopComplete = true;
@@ -1463,8 +953,8 @@ function loadStepComments(){
}else{ }else{
list.innerHTML = stepComments.map(c=>` list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;"> <div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div> <div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div> <div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div> </div>
`).join(''); `).join('');
} }

View File

@@ -1,25 +1,22 @@
:root { :root {
--primary: #0f62fe; --primary: #2563eb;
--primary-light: #edf5ff; --primary-light: #dbeafe;
--success: #198038; --success: #16a34a;
--warning: #8e6a00; --warning: #ea580c;
--warning-bg: #fdf6dd; --danger: #dc2626;
--danger: #da1e28; --text: #1f2937;
--text: #161616; --text-light: #6b7280;
--text-light: #525252; --text-dim: #9ca3af;
--text-dim: #8d8d8d; --border: #e5e7eb;
--border: #e0e0e0; --bg: #f9fafb;
--border-strong: #8d8d8d;
--bg: #f4f4f4;
--bg-card: #ffffff; --bg-card: #ffffff;
--appbar: #161616; --shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow: none; --shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg);
line-height: 1.5; line-height: 1.5;
@@ -31,46 +28,43 @@ body {
min-height: 100vh; min-height: 100vh;
} }
/* HEADER — dark UI Shell bar */ /* HEADER */
.header { .header {
background: var(--appbar); background: #ffffff;
color: #fff; color: var(--text);
padding: 0 16px; padding: 1.5rem 2rem;
height: 48px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: var(--shadow);
border-bottom: 1px solid var(--border);
} }
.header-left { .header-left {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 1.5rem;
min-width: 0;
} }
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
.logo { .logo {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 0.5rem;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
text-decoration: none; text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s; transition: opacity 0.2s;
flex-shrink: 0;
} }
.logo:hover { opacity: 0.92; } .logo:hover { opacity: 0.7; }
.logo img { height: 24px; width: auto; display: block; }
.logo-icon { .logo-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
background: var(--primary-light); background: var(--primary-light);
border-radius: 0; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -79,142 +73,97 @@ body {
} }
.header-title { .header-title {
font-size: 15px; font-size: 24px;
font-weight: 600; font-weight: 700;
margin-bottom: 0; margin-bottom: 0;
color: #fff; color: var(--text);
white-space: nowrap;
} }
.header-subtitle { .header-subtitle {
font-size: 12px; font-size: 13px;
color: #c6c6c6; color: var(--text-light);
min-height: 16px; min-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 1.5rem;
} }
.header-button { .header-button {
padding: 7px 14px; padding: 0.5rem 1rem;
background: transparent; background: var(--bg);
color: #fff; color: var(--primary);
border: 1px solid #6f6f6f; border: 1px solid var(--border);
border-radius: 0; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 600;
transition: background 0.15s, border-color 0.15s; transition: all 0.2s;
} }
.header-button:hover { .header-button:hover {
background: #353535; background: var(--primary-light);
border-color: #6f6f6f; border-color: var(--primary);
} }
.step-counter { .step-counter {
background: transparent; background: var(--bg);
border: 1px solid #6f6f6f; border: 1px solid var(--border);
color: #c6c6c6; color: var(--text-light);
padding: 4px 10px; padding: 0.4rem 0.8rem;
border-radius: 20px; border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
/* MAIN NAVIGATION — underline tabs */ /* MAIN NAVIGATION */
.main-nav { .main-nav {
display: flex; display: flex;
gap: 0; gap: 0.5rem;
padding: 0 16px; padding: 1rem 2rem;
background: var(--bg-card); background: var(--bg-card);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
} }
.nav-tab { .nav-tab {
padding: 13px 18px; padding: 0.75rem 1.5rem;
background: none; background: var(--bg);
border: none; border: 2px solid var(--border);
border-bottom: 3px solid transparent; border-radius: 6px;
border-radius: 0;
cursor: pointer; cursor: pointer;
font-size: 15px; font-size: 14px;
font-weight: 400; font-weight: 600;
color: var(--text-light);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
transition: background 0.15s, color 0.15s; transition: all 0.2s;
} }
.nav-tab:hover { .nav-tab:hover {
background: var(--bg); border-color: var(--primary);
color: var(--text); color: var(--primary);
} }
.nav-tab.active { .nav-tab.active {
background: none; background: var(--primary);
color: var(--text); color: white;
border-bottom-color: var(--primary); border-color: var(--primary);
font-weight: 600;
} }
.tab-icon { font-size: 16px; } .tab-icon { font-size: 16px; }
/* CONTENT AREA /* CONTENT AREA */
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
screen wasted half the display — and it also squeezed the embedded Work Package
Creator (an iframe living in here) into a ~930px column with its own scrollbar
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
(see .content-area.embed-full below). */
.content-area { .content-area {
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
max-width: 1700px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
/* Work Package Creation / Dashboard: the iframe fills the window below the app
chrome and owns the only scrollbar, so the creator's sticky save bar and
navigator drawer position against a real viewport instead of scrolling away. */
.content-area.embed-full {
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
child's `height:100%` resolves to auto and the iframe collapses to its 150px
default. Opting out of flex sizing makes the height definite. */
flex: none;
max-width: none;
padding: 0;
height: calc(100vh - var(--wp-chrome-h, 96px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-area.embed-full > .tool.active {
flex: 1 1 auto;
min-height: 0; /* let it shrink instead of overflowing the shell */
height: 100%;
}
#wp-frame {
width: 100%;
border: 0;
min-height: calc(100vh - 200px);
}
#wp-frame.fill {
display: block;
height: 100%;
min-height: 0;
}
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
body.embed-full { overflow: hidden; }
.tool { .tool {
display: none; display: none;
} }
@@ -237,37 +186,35 @@ body.embed-full { overflow: hidden; }
} }
.step-item { .step-item {
padding: 0.6rem 0.9rem; padding: 0.75rem 1rem;
border-radius: 0; border-radius: 6px;
background: var(--bg-card); background: var(--bg);
border: 1px solid var(--border); border: 2px solid var(--border);
color: var(--text-light);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 600;
white-space: nowrap; white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: all 0.2s;
} }
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); } .step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; } .step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
/* STEP CONTENT */ /* STEP CONTENT */
.step-content { .step-content {
background: var(--bg-card); background: var(--bg-card);
padding: 2rem; padding: 2rem;
border: 1px solid var(--border); border-radius: 8px;
border-radius: 0; box-shadow: var(--shadow);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.step { display: none; } .step { display: none; }
.step h2 { .step h2 {
font-size: 22px; font-size: 20px;
font-weight: 400; font-weight: 700;
letter-spacing: -0.01em; margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
color: var(--text); color: var(--text);
} }
@@ -276,20 +223,15 @@ body.embed-full { overflow: hidden; }
color: var(--text-light); color: var(--text-light);
background: var(--primary-light); background: var(--primary-light);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 0; border-radius: 6px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-left: 4px solid var(--primary); border-left: 4px solid var(--primary);
} }
/* FIELDS /* FIELDS */
The wizard's fields were one per row, which looked right in a 1000px column but
stretches a text input across the screen now that the content area is wide. Flow
them into as many ~340px columns as fit; `.col1` still forces a single column for
the fields that genuinely want the width (long text, textareas). */
.field-grid { .field-grid {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
} }
.field-grid.col1 { grid-template-columns: 1fr; } .field-grid.col1 { grid-template-columns: 1fr; }
@@ -311,7 +253,7 @@ body.embed-full { overflow: hidden; }
.field textarea { .field textarea {
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
color: var(--text); color: var(--text);
@@ -333,19 +275,6 @@ body.embed-full { overflow: hidden; }
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Small helper text under a field. It's used on this page (step 2's CM hint, the
team-member notices) but its only rule used to live in wp-creation-styles.css,
which this page does not link — so it rendered as unstyled body text. */
.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; }
.field-hint strong { color: var(--text-light); }
/* The sign-off name pickers sit outside .field, so they got no form styling at all. */
.user-pick {
padding: 0.75rem; border: 1px solid var(--border); border-radius: 0;
font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg);
}
.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
/* ROLES */ /* ROLES */
.required-roles { .required-roles {
display: flex; display: flex;
@@ -358,7 +287,7 @@ body.embed-full { overflow: hidden; }
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
background: var(--bg); background: var(--bg);
border-radius: 0; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
@@ -385,20 +314,20 @@ body.embed-full { overflow: hidden; }
.wp-type-row { .wp-type-row {
display: grid; display: grid;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr; grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 0.85rem; gap: 1rem;
align-items: center; align-items: center;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg); background: var(--bg);
border-radius: 0; border-radius: 6px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.wp-types-header { .wp-types-header {
display: grid; display: grid;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr; grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 0.85rem; gap: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -410,32 +339,31 @@ body.embed-full { overflow: hidden; }
/* BUTTONS */ /* BUTTONS */
.add-btn { .add-btn {
padding: 0.7rem 1.25rem; padding: 0.75rem 1.25rem;
background: var(--primary); background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); } .add-btn:hover { background: #1d4ed8; }
.nav-btn { .nav-btn {
padding: 0.7rem 1.4rem; padding: 0.75rem 1.5rem;
background: var(--bg-card); background: var(--bg);
border: 1px solid var(--border-strong); border: 2px solid var(--border);
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.2s;
} }
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); } .nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn.primary { .nav-btn.primary {
background: var(--success); background: var(--success);
@@ -443,7 +371,7 @@ body.embed-full { overflow: hidden; }
border-color: var(--success); border-color: var(--success);
} }
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; } .nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -454,7 +382,7 @@ body.embed-full { overflow: hidden; }
justify-content: space-between; justify-content: space-between;
padding: 1.5rem; padding: 1.5rem;
background: var(--bg-card); background: var(--bg-card);
border-radius: 0; border-radius: 8px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -470,7 +398,7 @@ body.embed-full { overflow: hidden; }
background: var(--primary-light); background: var(--primary-light);
color: var(--primary); color: var(--primary);
border: 1px solid var(--primary); border: 1px solid var(--primary);
border-radius: 0; border-radius: 6px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -483,7 +411,7 @@ body.embed-full { overflow: hidden; }
#sequence-list { display: flex; flex-direction: column; gap: 8px; } #sequence-list { display: flex; flex-direction: column; gap: 8px; }
.seq-step { .seq-step {
display: flex; align-items: center; gap: 12px; padding: 11px 14px; display: flex; align-items: center; gap: 12px; padding: 11px 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s; box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
} }
.seq-step:hover { border-color: var(--primary); } .seq-step:hover { border-color: var(--primary); }
@@ -504,7 +432,7 @@ body.embed-full { overflow: hidden; }
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0; width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
} }
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; } .seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; } .seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; } .seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
.seq-gate-badge { .seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff; flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
@@ -520,7 +448,7 @@ body.embed-full { overflow: hidden; }
max-width: calc(100vw - 2rem); max-width: calc(100vw - 2rem);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0; border-radius: 8px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
padding: 1.25rem; padding: 1.25rem;
z-index: 1200; z-index: 1200;
@@ -560,7 +488,7 @@ body.embed-full { overflow: hidden; }
.modal-content { .modal-content {
background: var(--bg-card); background: var(--bg-card);
border-radius: 0; border-radius: 8px;
padding: 2rem; padding: 2rem;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 80vh;
@@ -600,7 +528,7 @@ body.embed-full { overflow: hidden; }
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 768px) { @media (max-width: 768px) {
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; } .header { flex-direction: column; text-align: center; gap: 1rem; }
.main-nav { flex-wrap: wrap; } .main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; } .content-area { padding: 1rem; }
.step-content { padding: 1rem; } .step-content { padding: 1rem; }

View File

@@ -5,14 +5,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> <script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="work-package-suite-styles.css"> <link rel="stylesheet" href="work-package-suite-styles.css">
</head> </head>
<body> <body>
@@ -21,9 +15,9 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<a href="index.html" class="logo" title="Back to Home"> <a href="index.html" class="logo" title="Back to Home">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;"> <img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
</a> </a>
<div style="min-width:0;overflow:hidden"> <div>
<div class="header-title">Work Package Suite</div> <div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div> <div class="header-subtitle" id="project-display"></div>
</div> </div>
@@ -107,28 +101,23 @@
<!-- STEP 2: PROJECT TEAM --> <!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;"> <div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2> <h2>2. Project Team Leadership</h2>
<div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a <div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
as an owner and lets the suite email them — so add anyone missing to the project first, in the
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="field-grid"> <div class="field-grid">
<div class="field"> <div class="field">
<label>Project Manager (PM)</label> <label>Project Manager (PM)</label>
<select id="proj_pm" class="team-pick" data-team="pm"></select> <input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
</div> </div>
<div class="field"> <div class="field">
<label>Assistant Project Manager (APM)</label> <label>Assistant Project Manager (APM)</label>
<select id="proj_apm" class="team-pick" data-team="apm"></select> <input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
</div> </div>
<div class="field"> <div class="field">
<label>Construction Manager (CM)</label> <label>Construction Manager (CM)</label>
<select id="proj_cm" class="team-pick" data-team="cm"></select> <input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Quality Manager (QM)</label> <label>Quality Manager (QM)</label>
<select id="proj_qm" class="team-pick" data-team="qm"></select> <input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
</div> </div>
</div> </div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;"> <div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -141,21 +130,21 @@
<!-- STEP 3: SIGN-OFF ROLES --> <!-- STEP 3: SIGN-OFF ROLES -->
<div class="step" id="sop-step-3" style="display: none;"> <div class="step" id="sop-step-3" style="display: none;">
<h2>3. Required Sign-Off Roles</h2> <h2>3. Required Sign-Off Roles</h2>
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div> <div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
<div class="required-roles"> <div class="required-roles">
<div class="role-required"> <div class="role-required">
<div class="role-checkbox"> <div class="role-checkbox">
<input type="checkbox" id="role_super" checked disabled> <input type="checkbox" id="role_super" checked disabled>
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span> <label>Superintendent *</label>
</div> </div>
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select> <input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div> </div>
<div class="role-required"> <div class="role-required">
<div class="role-checkbox"> <div class="role-checkbox">
<input type="checkbox" id="role_foreman" checked disabled> <input type="checkbox" id="role_foreman" checked disabled>
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span> <label>Foreman *</label>
</div> </div>
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select> <input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div> </div>
</div> </div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;"> <div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -168,15 +157,7 @@
<!-- STEP 4: WORK PACKAGE TYPES --> <!-- STEP 4: WORK PACKAGE TYPES -->
<div class="step" id="sop-step-4" style="display: none;"> <div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2> <h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion. <div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label>
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div> <div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div> </div>
@@ -306,16 +287,6 @@
</select> </select>
</div> </div>
</div> </div>
<div class="field" style="margin-top:1rem;">
<label>Tracking platform — project homepage link</label>
<input type="url" id="plat_tracking_url" placeholder="Paste the project's URL in the tracking platform (e.g. its Procore / CxAlloy project home)">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
<div class="field" style="margin-top:0.75rem;">
<label>Commissioning tool — project homepage link</label>
<input type="url" id="plat_commissioning_url" placeholder="Paste the project's URL in the commissioning tool">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
</div> </div>
<!-- STEP 8: SEQUENCE --> <!-- STEP 8: SEQUENCE -->
@@ -375,13 +346,7 @@
<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 -->
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back <iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
to the HTML default 300x150 box, and the service worker caches this page
and the stylesheet separately — so a browser can hold new HTML with old
CSS and collapse the creator to a tiny scrolling box. Inline attributes
survive any cache mismatch; the CSS below only refines them. -->
<iframe id="wp-frame" title="Work Package Creator"
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
</div> </div>
</div> </div>
@@ -432,6 +397,5 @@
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="work-package-suite-app.js"></script> <script src="work-package-suite-app.js"></script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,237 +0,0 @@
/* ============================================================================
SHARED APP CHROME — project switcher + global search
----------------------------------------------------------------------------
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
the SOP suite and the WP creator). The two live side by side, so every colour
here comes from a variable that wp-chrome.js sets per host bar — the markup and
behaviour are identical on both.
============================================================================ */
.wp-chrome {
display: flex;
align-items: center;
gap: 10px;
min-width: 0; /* lets the search shrink instead of overflowing */
flex: 1 1 auto;
}
/* Light host bar (the two tool pages) */
.wp-chrome {
--wpc-fg: #161616;
--wpc-fg-dim: #525252;
--wpc-bg: #ffffff;
--wpc-bg-soft: #f4f4f4;
--wpc-border: #c6c6c6;
--wpc-hover: #e8e8e8;
--wpc-accent: #0f62fe;
}
/* Dark host bar (the UI-shell appbar) */
.wp-chrome[data-bar="dark"] {
--wpc-fg: #ffffff;
--wpc-fg-dim: #c6c6c6;
--wpc-bg: #262626;
--wpc-bg-soft: #393939;
--wpc-border: #6f6f6f;
--wpc-hover: #353535;
--wpc-accent: #78a9ff;
}
/* ── archived-project banner ──────────────────────────────────────────────── */
/* Inserted by wp-chrome.js as the top bar's next sibling, so it sits directly
under the bar in normal flow and can never overlap it or eat its height (the
bar is sticky; this strip scrolls away under it). `flex: 0 0 auto` is for the
suite page, whose shell is a flex column — without it the strip would squash.
Amber tokens are the suite's warning set, same as the sync badge. */
.wpc-archived {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 16px;
background: #fdf6dd;
color: #8e6a00;
border-bottom: 1px solid #f1c21b;
border-radius: 0;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13.5px;
line-height: 1.35;
}
.wpc-archived-ico { flex: 0 0 auto; font-size: 14px; }
.wpc-archived-text { min-width: 0; } /* wraps instead of forcing a scrollbar */
@media (max-width: 620px) {
.wpc-archived { padding: 8px 12px; font-size: 13px; }
}
/* ── project switcher ─────────────────────────────────────────────────────── */
.wpc-proj { position: relative; flex: 0 0 auto; }
.wpc-proj-btn {
display: flex;
align-items: center;
gap: 10px;
max-width: 280px;
padding: 5px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--wpc-fg);
font: inherit;
font-size: 13px;
line-height: 1.25;
text-align: left;
cursor: pointer;
}
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-labels { min-width: 0; }
.wpc-proj-kicker {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--wpc-fg-dim);
}
.wpc-proj-name {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
line-height: 1; color: var(--wpc-fg-dim); }
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
.wpc-pop {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 2000;
min-width: 320px;
max-width: min(460px, 92vw);
max-height: min(70vh, 560px);
overflow-y: auto;
background: #fff;
color: #161616;
border: 1px solid #e0e0e0;
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
border-radius: 4px;
}
.wpc-pop[hidden] { display: none; }
.wpc-pop-head {
padding: 9px 12px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: .07em;
text-transform: uppercase;
color: #6f6f6f;
border-bottom: 1px solid #f0f0f0;
}
.wpc-item {
display: block;
width: 100%;
padding: 8px 12px;
background: transparent;
border: 0;
border-left: 3px solid transparent;
text-align: left;
font: inherit;
font-size: 13px;
color: #161616;
cursor: pointer;
text-decoration: none;
}
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
.wpc-item-title { display: block; font-weight: 600; }
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
.wpc-pop-foot {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wpc-foot-btn {
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border: 1px solid #c6c6c6;
background: #fff;
color: #161616;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
}
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
/* ── global search ────────────────────────────────────────────────────────── */
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
which keeps the field mid-screen without absolute positioning (so it can never
sit on top of the bar's own buttons). */
.wpc-search {
position: relative;
flex: 1 1 auto;
display: flex;
justify-content: center;
min-width: 0;
}
.wpc-search-box {
position: relative;
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 34px;
background: var(--wpc-bg);
border: 1px solid var(--wpc-border);
border-radius: 3px;
}
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
.wpc-search-input {
flex: 1 1 auto;
min-width: 0;
background: transparent;
border: 0;
outline: none;
color: var(--wpc-fg);
font: inherit;
font-size: 13.5px;
}
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
.wpc-kbd {
flex: 0 0 auto;
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
font-size: 10.5px;
color: var(--wpc-fg-dim);
border: 1px solid var(--wpc-border);
border-radius: 3px;
padding: 1px 5px;
white-space: nowrap;
}
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
.wpc-clear {
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
}
.wpc-clear:hover { color: var(--wpc-fg); }
/* ── narrow screens ───────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.wpc-search-box { max-width: none; }
.wpc-kbd { display: none; }
.wpc-proj-btn { max-width: 190px; }
.wpc-proj-name { max-width: 150px; }
}
@media (max-width: 620px) {
/* Keep the switcher (you must be able to change project) and let the search
collapse to an icon-width field rather than pushing the bar out of shape. */
.wpc-proj-kicker { display: none; }
.wpc-search { flex: 1 1 120px; }
}

View File

@@ -1,388 +0,0 @@
/* Shared app chrome for the Work Package Suite: a project switcher beside the
Prime logo and a global search centered in the top bar.
One script for every page because there are two generations of top bar — the
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
flip a colour set based on how dark the host bar is.
Search hits GET /api/search, which scopes results to the projects the signed-in
user may access — so this is a convenience, never a way to see another job.
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
second bar inside the frame would be nonsense. */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
var SEARCH_MIN = 2; // characters before we ask the server
var DEBOUNCE_MS = 180;
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return false;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return false; }
}
// ── where to put the chrome ────────────────────────────────────────────────
// Returns {host, insertBefore} or null. The insertion point matters: on the
// dark bar we sit before the spacer (so search takes the middle); on the light
// bars we sit between the left block and the right-hand buttons.
function findMount() {
var appbar = document.querySelector('.wp-appbar');
if (appbar) {
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
}
var header = document.querySelector('.header');
if (header) {
// The suite page wraps its own left/right groups; the creator's bar is a
// flat row of buttons whose first button carries margin-left:auto.
var right = header.querySelector('.header-right');
if (right) return { host: header, before: right };
var firstBtn = header.querySelector('.btn, button');
return { host: header, before: firstBtn };
}
return null;
}
// ── project switcher ───────────────────────────────────────────────────────
var projects = [];
function activeProject() {
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
}
function projectLabel(p) {
if (!p) return 'Select a project';
var n = p.name || '(unnamed)';
return p.number ? (p.number + ' — ' + n) : n;
}
// Switching project reloads the current page with ?project=<id>. Every page
// already resolves its project from that param (falling back to the stored
// active id), so a reload is both the simplest and the safest route — no page
// has to re-hydrate half its state in place.
function switchProject(p) {
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
var url = new URL(location.href);
url.searchParams.set('project', p.id);
url.hash = '';
location.assign(url.toString());
}
function buildProjectSwitcher() {
var wrap = el('div', 'wpc-proj');
var btn = el('button', 'wpc-proj-btn');
btn.type = 'button';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
btn.title = 'Switch project';
var cur = activeProject();
btn.innerHTML =
'<span class="wpc-proj-labels">' +
'<span class="wpc-proj-kicker">Project</span>' +
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
'</span><span class="wpc-caret">▾</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(btn);
wrap.appendChild(pop);
function render() {
var curId = (activeProject() || {}).id || '';
var rows = projects.map(function (p) {
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
'" data-pid="' + esc(p.id) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
'</button>';
}).join('');
pop.innerHTML =
'<div class="wpc-pop-head">Switch project</div>' +
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
item.addEventListener('click', function () {
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
if (p) switchProject(p);
});
});
}
function open() {
render();
pop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (pop.hidden) open(); else close();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// Refresh the label once the project list (and any active project) is known.
wrap.wpcRefresh = function () {
var c = activeProject();
var nameEl = btn.querySelector('.wpc-proj-name');
if (nameEl) nameEl.textContent = projectLabel(c);
if (!pop.hidden) render();
};
return wrap;
}
function loadProjects(switcher) {
// ProjectData.list() already hits the API and falls back to its local cache
// when offline, so there's no second request to make here.
var p;
try {
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
} catch (e) { p = null; }
if (!p) {
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; });
}
Promise.resolve(p)
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
.catch(function () {});
}
// ── archived-project banner ────────────────────────────────────────────────
// An archived project is still readable and still deep-linkable (?project=<id>),
// but every write now 409s. With nothing on the page to say so, that reads as a
// silent failure — so the bar, the one thing every page has, carries the warning.
//
// The state comes from GET /api/projects/<id>, never from "it's missing from the
// switcher": absence also means "you have no access to it", which is a different
// message. Fails closed and silent — an error means no banner, not a broken page.
function activeProjectId() {
try {
var q = new URLSearchParams(location.search).get('project');
if (q) return q;
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
} catch (e) { return ''; }
}
function buildArchivedBanner() {
var bar = el('div', 'wpc-archived');
bar.setAttribute('role', 'status');
bar.innerHTML =
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
'Unarchive it from the Admin Console to make changes.</span>';
return bar;
}
function checkArchived(host) {
var id = activeProjectId();
if (!id || !host || !host.parentNode) return;
var pinned = false;
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (p) {
if (!p || !p.archived) return;
// The home page reconciles the stored active project against the (now
// archive-filtered) list while this request is in flight, and drops it. If
// that happened, the id we asked about is nobody's context any more —
// banner-ing it would contradict the picker one line below. A ?project=
// deep link is pinned to this page and can't be cleared out from under us.
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
ProjectData.getActiveId() !== id) return;
// Also on the document element, so the two big apps can gate their own UI
// from CSS or a boot check without a second round trip. The server stays
// the real gate; this is only there so the UI can agree with it.
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
if (document.querySelector('.wpc-archived')) return;
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
})
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
var box = el('div', 'wpc-search-box');
box.innerHTML =
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
'<span class="wpc-kbd">Ctrl K</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(box);
wrap.appendChild(pop);
var input = box.querySelector('.wpc-search-input');
var clear = box.querySelector('.wpc-clear');
var timer = null, seq = 0, items = [], activeIx = -1;
function close() { pop.hidden = true; activeIx = -1; }
function highlight() {
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
n.classList.toggle('is-active', i === activeIx);
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
});
}
// A work package lives inside the suite's Creator tab, so open the suite on
// that project with the package requested; a SOP opens the SOP tab.
function hrefFor(hit) {
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
if (hit.kind === 'wp') {
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
'&wp=' + encodeURIComponent(hit.id);
}
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
}
function go(hit) {
if (!hit) return;
if (hit.kind === 'project') {
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
if (p) { switchProject(p); return; }
}
// Set the active project only from a full record — writing a stub would
// clobber the cached project (name, number, client) other pages read. The
// ?project= param in the URL is what actually switches context.
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
location.assign(hrefFor(hit));
}
function renderResults(data) {
items = [];
var html = '';
function group(title, rows) {
if (!rows.length) return;
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
}
group('Work packages', (data.wps || []).map(function (w) {
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
esc(w.subject || '') + '</span>' +
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
group('Projects', (data.projects || []).map(function (p) {
items.push({ kind: 'project', id: p.id });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
'</button>';
}));
group('SOPs', (data.sops || []).map(function (s) {
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
if (!items.length) {
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
}
pop.innerHTML = html;
pop.hidden = false;
activeIx = items.length ? 0 : -1;
highlight();
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
});
}
function run(q) {
var mine = ++seq;
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (mine !== seq) return; // a newer keystroke already won
if (!data) { close(); return; }
renderResults(data);
})
.catch(function () {
if (mine !== seq) return;
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
pop.hidden = false;
});
}
input.addEventListener('input', function () {
var q = input.value.trim();
clear.hidden = !q;
clearTimeout(timer);
if (q.length < SEARCH_MIN) { close(); return; }
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); input.blur(); return; }
if (pop.hidden || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
});
input.addEventListener('focus', function () {
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
});
clear.addEventListener('click', function () {
input.value = ''; clear.hidden = true; close(); input.focus();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
// use). Ignored while typing in another field so it can't steal a shortcut.
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
input.focus();
input.select();
}
});
return wrap;
}
// ── mount ──────────────────────────────────────────────────────────────────
function mount() {
if (document.querySelector('.wp-chrome')) return;
var m = findMount();
if (!m) return;
var chrome = el('div', 'wp-chrome');
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
var switcher = buildProjectSwitcher();
chrome.appendChild(switcher);
chrome.appendChild(buildSearch());
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
checkArchived(m.host);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
}
// Wait for the auth guard: an unauthenticated page is about to redirect, and
// /api/search would 401 anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,7 @@
<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> <script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<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">
</head> </head>
@@ -33,7 +28,6 @@
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button> <button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button> <button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button> <button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div> </div>
@@ -48,78 +42,8 @@
<!-- SECTION NAV (jump links, built from the form cards) --> <!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div> <div class="section-nav-bar" id="section-nav"></div>
<div class="wp-layout">
<!-- WORK PACKAGE NAVIGATOR
A persistent side panel (not a hover drawer): collapse toggle, a primary action,
icon nav, then the project's packages as rows with colour-coded initial badges.
Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-top">
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="wp-nav-primary">
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
</button>
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false"></button>
<div class="wp-nav-menu" id="wp-nav-more" hidden>
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
</div>
</div>
<nav class="wp-nav-links" aria-label="Views">
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">My packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
</button>
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">All packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
</button>
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Needs attention</span>
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
</button>
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Dashboard</span>
</button>
</nav>
<div class="wp-nav-sect">
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
<span class="wp-nav-count" id="wp-nav-count"></span>
</div>
<div class="wp-nav-filter">
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
</div>
<div class="wp-nav-list" id="wp-nav-list"></div>
</aside>
<div class="main"> <div class="main">
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
<div class="card" id="kind-row" style="display:none">
<div class="sub-heading">Package Type</div>
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
<div class="radio-group" id="kind-group" style="margin-bottom:0">
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
</div>
</div>
<!-- GENERAL INFORMATION --> <!-- GENERAL INFORMATION -->
<div class="card"> <div class="card">
<div class="section-header"><div class="section-title">General Information</div> <div class="section-header"><div class="section-title">General Information</div>
@@ -150,39 +74,16 @@
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div> <div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div> <div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label> <div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
<div class="people-pick" id="pick_assignees"></div>
<input type="hidden" id="wp_assignees"></div>
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
<div class="people-pick" id="pick_distribution"></div>
<input type="hidden" id="wp_distribution"></div>
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div> <div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
<div class="field"><label>Specification Section</label> <div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
</div> </div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div> <div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
<div class="card" id="bim-card" style="display:none">
<div class="sub-heading">BIM / Model Details</div>
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
<div class="field-grid">
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
<div class="field"><label>Clash / Coordination Status</label>
<select id="wp_clash" onchange="onClashChange()"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
<div class="field-hint" id="iff-hint"></div></div>
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
</div>
</div> </div>
<!-- ASSETS (controls.dev) --> <!-- ASSETS (controls.dev) -->
<div class="card" id="asset-card"> <div class="card">
<div class="sub-heading">Assets</div> <div class="sub-heading">Assets</div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div> <div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
@@ -210,16 +111,12 @@
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button> <button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
<div class="field-grid" style="margin-top:14px"> <div class="field-grid" style="margin-top:14px">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div> <div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label> <div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
<div class="people-pick" id="pick_predecessors"></div>
<div class="field-hint" id="pred-hint"></div></div>
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
</div> </div>
</div> </div>
<!-- MATERIAL LIST --> <!-- MATERIAL LIST -->
<div class="card" id="material-card"> <div class="card">
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div> <div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div> <div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
@@ -250,7 +147,7 @@
</div> </div>
<!-- KITTING & MIMO --> <!-- KITTING & MIMO -->
<div class="card" id="mimo-card"> <div class="card">
<div class="sub-heading">Kitting & Material Movement (MIMO)</div> <div class="sub-heading">Kitting & Material Movement (MIMO)</div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Kitting Status</label> <div class="field"><label>Kitting Status</label>
@@ -342,7 +239,6 @@
</div> </div>
</div> </div>
</div>
<!-- HOLD LOG MODAL (comment 7) --> <!-- HOLD LOG MODAL (comment 7) -->
<div class="modal-overlay" id="hold-modal"> <div class="modal-overlay" id="hold-modal">

View File

@@ -7,25 +7,25 @@
body.embedded .embed-first { margin-left: auto; } body.embedded .embed-first { margin-left: auto; }
:root { :root {
--bg: #f4f4f4; --bg: #f4f5f7;
--surface: #ffffff; --surface: #ffffff;
--surface2: #f4f4f4; --surface2: #f7f8fa;
--border: #e0e0e0; --border: #e3e6ec;
--border-strong: #8d8d8d; --border-strong: #d0d5de;
--text: #161616; --text: #1a2230;
--text-muted: #525252; --text-muted: #5a6675;
--text-dim: #8d8d8d; --text-dim: #9aa3b2;
--accent: #0f62fe; --accent: #2563d6;
--accent-dim: #edf5ff; --accent-dim: #e8f0fe;
--accent-green: #198038; --accent-green: #15924f;
--accent-green-dim: #defbe6; --accent-green-dim: #e4f6ec;
--accent-amber: #8e6a00; --accent-amber: #b87100;
--accent-amber-dim: #fdf6dd; --accent-amber-dim: #fdf2e0;
--red: #da1e28; --red: #cf3b3b;
--red-dim: #fff1f1; --red-dim: #fbeaea;
--radius: 0; --radius: 5px;
--shadow: none; --shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
--shadow-lg: 0 4px 16px rgba(20,30,50,.12); --shadow-lg: 0 4px 16px rgba(20,30,50,.08);
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace; --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; --sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
} }
@@ -99,15 +99,8 @@
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); } .step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; } .step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ── /* ── MAIN ── */
The form uses the full width it's given. The work-package navigator is an .main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; }
auto-hiding overlay drawer (see below) rather than a column, so it never takes
width away from the form — which matters most when this page is embedded in the
suite's tab and every pixel is shared with the app chrome. */
.wp-layout { display: block; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
transition: padding-left .18s ease; }
.section { display: none; } .section { display: none; }
.section.active { display: block; animation: fade .25s ease; } .section.active { display: block; animation: fade .25s ease; }
@@ -133,11 +126,6 @@
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; } .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; } .field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.col1 { grid-template-columns: 1fr; } .field-grid.col1 { grid-template-columns: 1fr; }
/* On a wide screen let the two-up grids flow into 34 columns instead of
stretching two fields across the whole card. */
@media (min-width: 1200px) {
.field-grid:not(.col1):not(.col3) { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
}
.field { display: flex; flex-direction: column; gap: 6px; } .field { display: flex; flex-direction: column; gap: 6px; }
.field.span2 { grid-column: span 2; } .field.span2 { grid-column: span 2; }
@@ -225,7 +213,7 @@
.notice { .notice {
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius); background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono); padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
} }
/* ── DELIVERABLES ── */ /* ── DELIVERABLES ── */
@@ -257,9 +245,9 @@
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); } .btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } .btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
.btn-primary:hover { background: #0353e9; } .btn-primary:hover { background: #1d52b8; }
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); } .btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #0e6027; } .btn-generate:hover { background: #117a42; }
/* ── OUTPUT ── */ /* ── OUTPUT ── */
#output-section { display: none; } #output-section { display: none; }
@@ -431,7 +419,7 @@
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); } .ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700; .use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; } color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
.use-btn:hover { background:#0e6027; } .use-btn:hover { background:#0f7a40; }
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; } .sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px; .sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
padding:3px 10px; font-family:var(--mono); font-size:10px; } padding:3px 10px; font-family:var(--mono); font-size:10px; }
@@ -439,7 +427,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; } border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */ /* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; .ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; } .ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -451,7 +439,7 @@
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); } .ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; } .link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
.mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; } .mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; } .mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface); .mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; } color:var(--text-muted); cursor:pointer; }
@@ -475,7 +463,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */ /* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; } .sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); } .release-banner { max-width:1080px; margin:0 auto; padding:0 28px; }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; .release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; } display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; } .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -507,7 +495,7 @@
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; } .modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
.modal-overlay.open { display:flex; } .modal-overlay.open { display:flex; }
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; } .modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); } .modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-title { font-weight:700; font-size:15px; color:var(--text); } .modal-title { font-weight:700; font-size:15px; color:var(--text); }
.modal-body { padding:18px 20px; overflow-y:auto; } .modal-body { padding:18px 20px; overflow-y:auto; }
@@ -583,231 +571,15 @@
/* 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 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px); padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06); border-bottom:1px solid var(--border); }
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; } .section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2); .sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; } border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); } .sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small
chip on the label with the detail in a hover tooltip (site comment 8/3).
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
hover, and "this value came from the SOP" is the part people need to see. */
.field-hint.sop-hint { display: none; }
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
vertical-align:middle; cursor:help; position:relative; }
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
opacity:0; transition:opacity .12s; z-index:9999; }
.sop-chip:hover::after, .sop-chip:hover::before,
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
Multi-select over the SOP project team instead of a free-text list. */
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
font-size:12px; max-width:100%; }
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
font-size:12px; line-height:1; padding:0 1px; }
.pp-chip .pp-x:hover { color:var(--red); }
.pp-add { position:relative; }
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
.pp-menu[hidden] { display:none; }
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--text-dim); padding:7px 10px 3px; }
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
.pp-opt:hover { background:var(--surface2); }
.pp-opt input { width:15px; height:15px; cursor:pointer; }
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
A persistent side panel in the spirit of MS Planner: collapse toggle, one
primary action, icon nav, then the packages as rows with colour-coded initial
badges and a highlighted current row. It sits IN the layout (the form shifts
across) rather than hovering over the content, and collapses to a 56px icon
rail so you can still see and switch packages with it closed. */
.wp-nav {
position: fixed;
top: var(--rail-top, 48px);
left: 0;
bottom: 0;
width: var(--nav-w, 288px);
z-index: 120;
display: flex;
flex-direction: column;
background: #fbfbfc;
border-right: 1px solid var(--border);
overflow: hidden;
transition: width .16s ease;
}
body { --nav-w: 288px; }
body.wp-nav-collapsed { --nav-w: 56px; }
/* -- collapse toggle -- */
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
.wp-nav-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; padding: 0;
background: transparent; border: 1px solid transparent; border-radius: 5px;
color: var(--text-muted); cursor: pointer;
}
.wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
/* Arrow flips to point right when the panel is closed. */
body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
/* -- primary action -- */
.wp-nav-primary { position: relative; display: flex; gap: 2px; padding: 6px 10px 12px; }
.wp-nav-cta {
flex: 1 1 auto; min-width: 0;
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
height: 40px; padding: 0 14px;
background: var(--accent); color: #fff;
border: 0; border-radius: 6px 0 0 6px;
font: inherit; font-size: 14px; font-weight: 600;
cursor: pointer; white-space: nowrap;
}
.wp-nav-cta:hover { background: #0353e9; }
.wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
.wp-nav-cta-more {
flex: 0 0 auto; width: 30px; height: 40px;
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
}
.wp-nav-cta-more:hover { background: #0353e9; }
.wp-nav-menu {
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
}
.wp-nav-menu[hidden] { display: none; }
.wp-nav-menu button {
display: block; width: 100%; text-align: left; background: none; border: 0;
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer;
}
.wp-nav-menu button:hover { background: var(--surface2); }
/* -- icon nav -- */
.wp-nav-links { padding: 2px 8px 10px; display: flex; flex-direction: column; gap: 1px; }
.wp-nav-link {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 9px 10px;
background: none; border: 0; border-radius: 6px;
font: inherit; font-size: 14px; color: var(--text);
cursor: pointer; text-align: left; white-space: nowrap;
}
.wp-nav-link:hover { background: #eef0f3; }
.wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
.wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
.wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
.wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* -- section header + filter -- */
.wp-nav-sect {
display: flex; align-items: baseline; gap: 6px;
padding: 6px 18px 4px; border-top: 1px solid var(--border);
}
.wp-nav-sect-label { font-size: 12px; color: var(--text-muted); }
.wp-nav-count { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.wp-nav-filter { padding: 4px 12px 8px; }
.wp-nav-search {
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
}
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
/* -- package rows -- */
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
.wp-nav-group {
font-size: 11px; font-weight: 600; letter-spacing: .02em;
color: var(--text-dim); padding: 12px 10px 4px;
}
.wp-nav-item {
position: relative;
display: flex; align-items: center; gap: 11px;
width: 100%; padding: 7px 10px; margin-bottom: 1px;
background: none; border: 0; border-radius: 6px;
font: inherit; color: var(--text); text-align: left; cursor: pointer;
}
.wp-nav-item:hover { background: #eef0f3; }
.wp-nav-item.active { background: #e8eaed; }
/* Left accent bar on the current package, like Planner's selected plan. */
.wp-nav-item.active::before {
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 2px; background: var(--accent);
}
.wp-nav-badge {
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
color: #fff; text-transform: uppercase;
}
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-subj { display: block; font-size: 12px; color: var(--text-muted); line-height: 1.35;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-state { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; color: var(--text-dim); white-space: nowrap; }
.wp-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); flex: 0 0 auto; }
.wp-nav-dot.ok { background: var(--accent-green); }
.wp-nav-dot.open { background: var(--accent-amber); }
.wp-nav-dot.hold { background: var(--red); }
.wp-nav-empty { padding: 14px 10px; font-size: 12.5px; color: var(--text-dim); }
/* -- collapsed rail: badges and icons only -- */
body.wp-nav-collapsed .wp-nav-cta-label,
body.wp-nav-collapsed .wp-nav-cta-more,
body.wp-nav-collapsed .wp-nav-link-label,
body.wp-nav-collapsed .wp-nav-link-n,
body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,
body.wp-nav-collapsed .wp-nav-body,
body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-group { display: none; }
body.wp-nav-collapsed .wp-nav-primary { padding: 6px 10px 10px; }
body.wp-nav-collapsed .wp-nav-cta { justify-content: center; padding: 0; border-radius: 6px; }
body.wp-nav-collapsed .wp-nav-link { justify-content: center; padding: 9px 0; }
body.wp-nav-collapsed .wp-nav-item { justify-content: center; padding: 6px 0; }
body.wp-nav-collapsed .wp-nav-list { padding: 6px 6px 14px; }
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
@media (max-width: 860px) {
body { --nav-w: 56px; }
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
}
/* Sticky save bar */ /* Sticky save bar */
.sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center; .sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff; justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); } border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; } .sticky-save .sticky-status{ font-size:13px; font-weight:600; }
@@ -834,12 +606,11 @@
/* Dashboard */ /* Dashboard */
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; } .dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; } .dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; } .dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; } .dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
.dash-metric.dm-green .dm-val { color:var(--accent-green); } .dash-metric.dm-green .dm-val { color:var(--accent-green); }
.dash-metric.dm-red .dm-val { color:var(--red); } .dash-metric.dm-red .dm-val { color:var(--red); }
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; } .dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); } .dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); } .dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
@@ -849,7 +620,7 @@
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; } .dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); } .dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; } .dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; } .dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; } .dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); } .dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
@@ -858,27 +629,3 @@
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; } .dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[type=search] { flex:1; min-width:200px; } .dash-filters input[type=search] { flex:1; min-width:200px; }
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } } @media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
.hist-list { display:flex; flex-direction:column; }
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
padding:9px 2px; border-bottom:1px solid var(--border); }
.hist-item:last-child { border-bottom:none; }
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
.hist-action { font-weight:600; color:var(--text); }
.hist-detail { color:var(--accent); font-size:13px; }
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
.dash-pager .btn { padding:5px 12px; }
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }

View File

@@ -1,232 +0,0 @@
/* Localization + time formatting for the Work Package Suite.
Every date the app shows should agree, wherever it's rendered. Three sources,
most specific first:
1. the signed-in user's own preference (users.locale / users.timezone)
2. the app default set by an admin (Admin console → Localization)
3. the browser's own locale / timezone (the previous behaviour)
Why store it server-side: on a shared field tablet the browser's locale isn't
the person's, and a package due date that reads a day early because the device
sits in another zone is a real scheduling problem — not a cosmetic one.
Exposes:
wpFormatDate(v) → 3 Aug 2026 (date only)
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
wpFormatTime(v) → 14:07
wpFormatNumber(v) → locale-grouped number
wpTimeZoneLabel() → the zone in effect, for a UI hint
wpPreferences() → opens the preferences dialog
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
input (never 'Invalid Date'), so they're safe to drop into a template. */
(function () {
'use strict';
function prefs() {
var u = window.WP_USER || {};
var f = window.WP_FLAGS || {};
return {
locale: (u.locale || f.default_locale || '') || undefined,
timezone: (u.timezone || f.default_timezone || '') || undefined
};
}
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
// UTC midnight by the platform, it can render as the previous day in a western
// zone — so format these from their parts and never apply a timezone.
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function toDate(v) {
if (v == null || v === '') return null;
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
var s = String(v).trim();
if (!s) return null;
var d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
function fmt(v, opts, forceNoTz) {
var s = (typeof v === 'string') ? v.trim() : v;
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
if (!d) return '';
var p = prefs();
var o = {};
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
try {
return new Intl.DateTimeFormat(p.locale, o).format(d);
} catch (e) {
// Bad locale/zone (e.g. a preference set before tzdata was available):
// fall back to the platform default rather than showing nothing.
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
}
}
window.wpFormatDate = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
};
window.wpFormatDateTime = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
window.wpFormatTime = function (v) {
return fmt(v, { hour: '2-digit', minute: '2-digit' });
};
window.wpFormatNumber = function (v, opts) {
if (v == null || v === '' || isNaN(+v)) return '';
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
catch (e) { return String(v); }
};
window.wpTimeZoneLabel = function () {
var p = prefs();
if (p.timezone) return p.timezone;
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
catch (e) { return 'browser default'; }
};
window.wpLocaleLabel = function () {
var p = prefs();
if (p.locale) return p.locale;
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
catch (e) { return 'browser default'; }
};
// ── preferences dialog ─────────────────────────────────────────────────────
var COMMON_LOCALES = [
['', 'Browser default'],
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
['en-CA', 'English (Canada)'],
['es-MX', 'Español (México)'],
['es-US', 'Español (Estados Unidos)'],
['fr-CA', 'Français (Canada)'],
['de-DE', 'Deutsch (Deutschland)'],
['ja-JP', '日本語 (日本)'],
['ko-KR', '한국어 (대한민국)'],
['zh-TW', '中文 (台灣)']
];
// Zones the fabs and offices actually sit in, offered before the full list.
var COMMON_ZONES = [
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
];
window.wpPreferences = function () {
if (document.getElementById('wp-prefs-modal')) return;
var u = window.WP_USER || {};
var ov = document.createElement('div');
ov.id = 'wp-prefs-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.45 "IBM Plex Sans",-apple-system,' +
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;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;">Language &amp; time</div>' +
'<div style="padding:4px 18px 16px;">' +
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
'<label style="' + lbl + '">Language &amp; number format</label>' +
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
'<label style="' + lbl + '">Time zone</label>' +
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
'Calendar dates like a due date are never shifted.</div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-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-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
function msg(text, ok) {
var e = document.getElementById('wp-prefs-msg');
e.style.display = 'block'; e.textContent = text;
e.style.background = ok ? '#defbe6' : '#fff1f1';
e.style.color = ok ? '#0e6027' : '#da1e28';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
var locSel = document.getElementById('wp-prefs-locale');
var tzSel = document.getElementById('wp-prefs-tz');
var preview = document.getElementById('wp-prefs-preview');
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
}).join('');
// A stored locale that isn't in the shortlist stays selectable.
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
locSel.add(new Option(u.locale, u.locale, true, true));
}
function fillZones(all) {
var cur = u.timezone || '';
var browser = '';
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
(browser ? ' (' + browser + ')' : '') + '</option>';
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
if (rest.length) {
html += '<optgroup label="All time zones">' + rest.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
html += '<option value="' + cur + '" selected>' + cur + '</option>';
}
tzSel.innerHTML = html;
updatePreview();
}
// Preview uses the picked values, not the saved ones, so the effect is visible
// before committing.
function updatePreview() {
var l = locSel.value || undefined, z = tzSel.value || undefined;
var now = new Date();
var out;
try {
out = new Intl.DateTimeFormat(l, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', timeZone: z
}).format(now);
} catch (e) { out = 'Not supported by this browser'; }
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
String(out).replace(/[<>]/g, '') +
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
}
locSel.addEventListener('change', updatePreview);
tzSel.addEventListener('change', updatePreview);
// The picker offers exactly what the server will accept.
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(fillZones)
.catch(function () { fillZones([]); });
document.getElementById('wp-prefs-cancel').onclick = close;
document.getElementById('wp-prefs-save').onclick = function () {
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
fetch('/api/auth/preferences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
})
.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((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
if (res.j && res.j.user) window.WP_USER = res.j.user;
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
msg('Saved. Reloading so every date on the page agrees…', true);
// Dates are formatted at render time all over the app; a reload is the
// honest way to apply the change everywhere at once.
setTimeout(function () { location.reload(); }, 700);
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
})();

View File

@@ -1,90 +0,0 @@
/* Global app navigation drawer (see wp-sidenav.js).
An off-canvas panel rather than a pinned rail, at every width: the field view is a
centred 760px column read on a phone or a tablet in a glove, and a permanent
sidebar would either squeeze that column or hide on the one device that matters.
Overlay behaves identically everywhere, which is also one less layout to test.
Colours come from the dark app bar it hangs off (#161616 / Carbon Gray 100), not
from theme-light.css, so the drawer reads as an extension of the bar. */
.wp-navbtn{
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px; margin-right: 4px; padding: 0;
background: none; border: none; border-radius: 0; cursor: pointer;
color: #f4f4f4; font-family: inherit; line-height: 1;
}
.wp-navbtn:hover{ background: #353535; }
.wp-navbtn:focus-visible{ outline: 2px solid #ffffff; outline-offset: -2px; }
/* A light bar (the SOP suite / creator headers) needs the opposite ink. */
.wp-navbtn[data-bar="light"]{ color: #161616; }
.wp-navbtn[data-bar="light"]:hover{ background: #e8e8e8; }
.wp-navscrim{
position: fixed; inset: 0; z-index: 10010;
background: rgba(22,22,22,.55);
opacity: 0; transition: opacity .18s ease;
}
.wp-navscrim.is-open{ opacity: 1; }
.wp-navscrim[hidden]{ display: none; }
.wp-sidenav{
position: fixed; top: 0; left: 0; bottom: 0; z-index: 10011;
width: min(284px, 84vw);
display: flex; flex-direction: column;
background: #161616; color: #f4f4f4;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transform: translateX(-100%); transition: transform .2s ease;
box-shadow: 2px 0 16px rgba(0,0,0,.4);
overflow: hidden;
}
.wp-sidenav.is-open{ transform: translateX(0); }
/* Respect a reduced-motion preference: the drawer still opens, it just doesn't slide. */
@media (prefers-reduced-motion: reduce){
.wp-sidenav, .wp-navscrim{ transition: none; }
}
.wp-sidenav-head{
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-bottom: 1px solid #393939; flex: 0 0 auto;
}
.wp-sidenav-head .wp-logo-chip{ flex: 0 0 auto; }
.wp-sidenav-title{ font-size: 13px; font-weight: 600; line-height: 1.25; }
.wp-sidenav-title span{ display: block; font-size: 11px; font-weight: 400; color: #a8a8a8; }
.wp-sidenav-close{
margin-left: auto; width: 32px; height: 32px; padding: 0; flex: 0 0 auto;
background: none; border: none; border-radius: 0; color: #c6c6c6;
font-size: 18px; line-height: 1; cursor: pointer; font-family: inherit;
}
.wp-sidenav-close:hover{ background: #353535; color: #fff; }
.wp-sidenav-body{ flex: 1 1 auto; overflow-y: auto; padding: 6px 0 18px; }
.wp-sidenav-sect{
padding: 14px 16px 4px; font-size: 11px; font-weight: 600;
letter-spacing: .06em; text-transform: uppercase; color: #8d8d8d;
}
.wp-sidenav-link{
display: flex; align-items: center; gap: 12px; width: 100%;
/* 44px minimum: this is tapped with a work glove on. */
min-height: 44px; padding: 10px 16px;
background: none; border: none; border-left: 3px solid transparent; border-radius: 0;
color: #f4f4f4; font: inherit; font-size: 14px; text-align: left; text-decoration: none;
cursor: pointer;
}
.wp-sidenav-link:hover{ background: #353535; }
.wp-sidenav-link:focus-visible{ outline: 2px solid #ffffff; outline-offset: -2px; }
.wp-sidenav-link.is-current{ background: #262626; border-left-color: #0f62fe; font-weight: 600; }
.wp-sidenav-ico{
flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: #c6c6c6;
}
.wp-sidenav-link.is-current .wp-sidenav-ico{ color: #78a9ff; }
.wp-sidenav-label{ flex: 1 1 auto; min-width: 0; }
.wp-sidenav-label small{ display: block; font-size: 11.5px; font-weight: 400; color: #a8a8a8; }
.wp-sidenav-foot{
flex: 0 0 auto; border-top: 1px solid #393939; padding: 8px 0;
}
.wp-sidenav-who{
padding: 6px 16px 8px; font-size: 12px; color: #a8a8a8;
}
.wp-sidenav-who strong{ display: block; color: #f4f4f4; font-size: 13px; font-weight: 600; }

View File

@@ -1,221 +0,0 @@
/* Global app navigation drawer for the Work Package Suite.
The suite grew page by page and the only way between them was the browser's back
button or the home page. This is the one place that lists everywhere you can go —
a ☰ button in the app bar opening an off-canvas drawer.
ROLE GATING: the drawer only offers what the signed-in account can actually reach.
The Admin Console is admins-only, so it appears for admins only; the User Directory
is readable by everyone (that's the point of a directory), so it always appears.
Every destination re-checks server-side — this is navigation, not a permission.
PROJECT CONTEXT: links that open a project-scoped page carry the active ?project=
so the drawer doesn't silently drop the job you were looking at.
Add it to a page with:
<link rel="stylesheet" href="wp-sidenav.css">
<script src="wp-sidenav.js"></script>
after auth-guard.js. It mounts itself into whichever top bar the page has, and
skips iframes (the embedded WP creator lives inside a page that already has one). */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
// ── the map ────────────────────────────────────────────────────────────────
// `match` is what marks a link current; `project` means "carry ?project=".
// `show` is an optional gate, evaluated once the user is known.
var LINKS = [
{ section: 'Work' },
{ href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home',
sub: 'Projects & what\'s next' },
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
{ href: 'work-package-suite.html?tab=wp', match: null, icon: '▤',
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
{ href: 'work-package-suite.html?tab=dashboard', match: null, icon: '▦',
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
sub: 'Update packages on site', project: true },
{ section: 'People' },
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
sub: 'Who\'s on the project' },
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚡', label: 'Admin Console',
sub: 'Settings & diagnostics',
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
];
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
function activeProjectId() {
try {
var q = new URLSearchParams(location.search).get('project');
if (q) return q;
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
} catch (e) { return ''; }
}
// The suite page reads ?tab= and ?project=; keeping the current project on the link
// is the difference between "open the dashboard" and "open the dashboard, then pick
// the job again".
function hrefFor(item) {
if (!item.project) return item.href;
var pid = activeProjectId();
if (!pid) return item.href;
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
return item.href + sep + 'project=' + encodeURIComponent(pid);
}
// Current-page marking. The three suite tabs share one file, so they're told apart
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
function isCurrent(item) {
var path = location.pathname;
if (item.tab) {
if (!/work-package-suite\.html$/.test(path)) return false;
var tab = '';
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
return tab === item.tab;
}
return !!(item.match && item.match.test(path));
}
// ── build ──────────────────────────────────────────────────────────────────
var drawer, scrim, btn, lastFocus = null;
function buildDrawer(user) {
scrim = document.createElement('div');
scrim.className = 'wp-navscrim';
scrim.hidden = true;
scrim.addEventListener('click', close);
drawer = document.createElement('nav');
drawer.className = 'wp-sidenav';
drawer.id = 'wp-sidenav';
drawer.setAttribute('aria-label', 'Suite navigation');
drawer.setAttribute('aria-hidden', 'true');
var rows = '';
LINKS.forEach(function (item) {
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
if (item.show && !item.show()) return;
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
'<span class="wp-sidenav-label">' + esc(item.label) +
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span></a>';
});
var who = user ? (user.full_name || user.username || '') : '';
drawer.innerHTML =
'<div class="wp-sidenav-head">' +
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
'</div>' +
'<div class="wp-sidenav-body">' + rows + '</div>' +
'<div class="wp-sidenav-foot">' +
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
'<span class="wp-sidenav-label">Sign out</span></button>' +
'</div>';
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
if (typeof window.wpLogout === 'function') window.wpLogout();
});
document.body.appendChild(scrim);
document.body.appendChild(drawer);
}
function focusables() {
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
}
function open() {
if (!drawer) return;
lastFocus = document.activeElement;
scrim.hidden = false;
// Two frames: the element has to be laid out un-transitioned before the class
// that animates it lands, or it simply appears.
requestAnimationFrame(function () {
scrim.classList.add('is-open');
drawer.classList.add('is-open');
});
drawer.setAttribute('aria-hidden', 'false');
btn.setAttribute('aria-expanded', 'true');
var f = focusables();
if (f.length) f[0].focus();
}
function close() {
if (!drawer) return;
drawer.classList.remove('is-open');
scrim.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
btn.setAttribute('aria-expanded', 'false');
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
// off into the page behind the scrim.
document.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') return;
var f = focusables();
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
// ── mount ──────────────────────────────────────────────────────────────────
// The button goes at the START of the bar, before the brand: that is where a menu
// affordance is looked for, and it keeps clear of the project switcher and search
// that wp-chrome.js inserts into the middle of the same bar.
function mount() {
if (document.getElementById('wp-sidenav')) return;
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (!host) return;
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wp-navbtn';
btn.id = 'wp-navbtn';
btn.title = 'Menu';
btn.setAttribute('aria-label', 'Open navigation');
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', 'wp-sidenav');
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round"/></svg>';
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
host.insertBefore(btn, host.firstChild);
buildDrawer(window.WP_USER);
}
// Wait for the auth guard: the gated links depend on the signed-in role, and an
// unauthenticated page is about to redirect anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();

View File

@@ -14,22 +14,6 @@
# 5. sudo nginx -t && sudo systemctl reload nginx # 5. sudo nginx -t && sudo systemctl reload nginx
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Cache-Control per file type. Computed in a map rather than a nested location
# because nginx's add_header is NOT inherited into a block that declares its own —
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
# empty value makes nginx omit the header, so images and fonts stay cacheable.
#
# Code must revalidate on every load: with no Cache-Control the browser applies
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
# gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index -> index.html
}
# Redirect plain HTTP to HTTPS # Redirect plain HTTP to HTTPS
server { server {
listen 80; listen 80;
@@ -50,15 +34,6 @@ server {
root /var/www/wp-suite; # <-- web root root /var/www/wp-suite; # <-- web root
index index.html; index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }

View File

@@ -2,23 +2,6 @@
# This container sits behind an external reverse proxy that handles SSL. # This container sits behind an external reverse proxy that handles SSL.
# It listens on port 80 (plain HTTP on the internal Docker network). # It listens on port 80 (plain HTTP on the internal Docker network).
# Cache-Control per file type, computed here rather than in a nested location.
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
# those files. Computing the value here keeps every header in ONE scope. An empty value
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
#
# Code assets must revalidate on every load: with no Cache-Control at all the browser
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
# file gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index → index.html
}
server { server {
listen 80; listen 80;
server_name wp.controls.dev; server_name wp.controls.dev;
@@ -26,19 +9,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
# sharply limit what injected script could load or exfiltrate. Tighten toward
# nonce-based scripts once inline handlers are refactored.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
@@ -49,10 +19,7 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
# This container is only ever reached via the TLS-terminating external proxy_set_header X-Forwarded-Proto $scheme;
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
# here is always "http") so the API marks the session cookie Secure.
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 5m; client_max_body_size 5m;
} }
} }

View File

@@ -1,35 +0,0 @@
#!/bin/sh
# Entry point for the `backup` sidecar container's periodic loop. Runs
# db-backup.sh on a fixed interval (default: daily) -- a sleep loop instead of
# a cron daemon, kept deliberately simple so it works in a bare
# postgres:16-alpine image.
#
# Resolves db-backup.sh the same way entrypoint.sh resolves this file: prefer
# the live bind-mounted copy at /scripts (so edits don't need a rebuild), fall
# back to the copy baked into the image at build time if the mount is
# missing, empty, or stale. Resolving fresh on every loop iteration also means
# that if the mount comes back healthy later (e.g. someone fixes the host
# directory) this container picks it up on the very next run, with no
# restart needed.
set -eu
resolve() {
# $1 = script filename, e.g. db-backup.sh
if [ -f "/scripts/$1" ]; then
echo "/scripts/$1"
else
echo "/app/scripts-default/$1"
fi
}
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
# Take one backup shortly after start so a freshly-deployed stack has an
# immediate restore point instead of waiting a whole interval.
sleep 20
while true; do
DB_BACKUP="$(resolve db-backup.sh)"
sh "$DB_BACKUP" || echo "[backup] run failed; will retry next interval" >&2
sleep "$INTERVAL"
done

View File

@@ -1,19 +0,0 @@
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
# at-rest encryption of dumps.
#
# The scripts are bind-mounted live at runtime (see the `backup` service in
# docker-compose.yml) so they can be edited without a rebuild -- but they're
# ALSO baked in here as a fallback default under /app/scripts-default/.
# entrypoint.sh prefers the live mount and only falls back to this baked-in
# copy if the mount is missing, empty, or stale. That fallback is what keeps
# a broken bind mount from crash-looping the container into an unreachable
# state (see entrypoint.sh for the full story).
FROM postgres:16-alpine
RUN apk add --no-cache openssl
COPY scripts/backup-cron.sh scripts/db-backup.sh scripts/db-restore.sh /app/scripts-default/
COPY scripts/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh /app/scripts-default/*.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD []

View File

@@ -1,58 +0,0 @@
#!/bin/sh
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
#
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
# the database holds customer IP — otherwise the dump (and every offsite copy) is
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
# possible); losing it means the backups are unrecoverable.
#
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
set -eu
BACKUP_DIR="${BACKUP_DIR:-/backups}"
KEEP="${BACKUP_KEEP:-14}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
ENC="${BACKUP_ENC_PASSPHRASE:-}"
mkdir -p "$BACKUP_DIR"
ts="$(date -u +%Y%m%d-%H%M%SZ)"
if [ -n "$ENC" ]; then
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
else
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
fi
tmp="$out.partial"
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
if [ -n "$ENC" ]; then
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
| gzip -c \
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
fi
else
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
fi
fi
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
if [ "$count" -gt "$KEEP" ]; then
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
echo "[db-backup] pruning $f"
rm -f "$f"
done
fi

View File

@@ -1,33 +0,0 @@
#!/bin/sh
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
#
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
# recreates objects before loading. Take a fresh backup first if in doubt.
#
# Usage (from the project root):
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
# backup container's environment).
set -eu
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
case "$FILE" in
*.enc)
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
*)
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
esac
echo "[db-restore] done."

View File

@@ -1,43 +0,0 @@
#!/bin/sh
# Entrypoint for the `backup` sidecar. Prefers the live, bind-mounted copy of
# backup-cron.sh at /scripts (so it can be edited without a rebuild), and
# falls back to the copy baked into this image at build time if that bind
# mount is missing, empty, or stale.
#
# Why this exists: the previous entrypoint ran `/bin/sh /scripts/backup-cron.sh`
# directly. If that file wasn't there -- e.g. because the host directory
# backing the ./scripts bind mount hadn't been populated by whatever deploy
# process manages this stack -- the container failed instantly, and
# `restart: unless-stopped` retried in a tight crash loop forever: fast enough
# that the container was never "running" long enough for `docker exec` or
# Portainer's console to attach. That made the failure itself undiagnosable
# from inside the container -- you could only ever see it in the logs, and
# only by getting lucky with timing. This wrapper guarantees something always
# runs, and that the container always stays reachable, even in the worst case.
set -u
LIVE="/scripts/backup-cron.sh"
FALLBACK="/app/scripts-default/backup-cron.sh"
if [ -f "$LIVE" ]; then
echo "[entrypoint] using live scripts from /scripts (bind mount present)"
exec /bin/sh "$LIVE"
fi
echo "[entrypoint] WARNING: $LIVE not found." >&2
echo "[entrypoint] The ./scripts bind mount is missing, empty, or stale on the host." >&2
echo "[entrypoint] Check the directory backing that mount (see docker-compose.yml)." >&2
if [ -f "$FALLBACK" ]; then
echo "[entrypoint] Falling back to the scripts baked into this image at build time." >&2
echo "[entrypoint] Backups will still run, on whatever version was current when this" >&2
echo "[entrypoint] image was last built -- not any newer live edits to ./scripts." >&2
exec /bin/sh "$FALLBACK"
fi
echo "[entrypoint] FATAL: no backup-cron.sh in the bind mount or the image." >&2
echo "[entrypoint] Staying up (idle) instead of crash-looping, so this container" >&2
echo "[entrypoint] can still be reached via 'docker exec' / the Portainer console." >&2
while true; do
sleep 3600
done

View File

@@ -20,13 +20,3 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12 # AUTH_SESSION_HOURS=12
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address
# live. The one secret that must NOT be stored in the database — the SMTP
# password — is read from this environment variable instead. Leave it unset
# until you have the SMTP details; the toggle stays effectively off (queued
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password

View File

@@ -1,43 +0,0 @@
# Alembic configuration for the Work Package Suite.
# The database URL is NOT hard-coded here — env.py pulls it from the same place
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
# migrations always target the same database as the running app.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
# Use OS-native path separators on Windows dev machines.
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,63 +0,0 @@
"""Alembic environment for the Work Package Suite.
We reuse the application's own database configuration (server/db.py) so a
migration always targets the same database the app would connect to — Postgres
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
No connection string is stored in alembic.ini.
"""
import os
import sys
from logging.config import fileConfig
from alembic import context
# Make the `server` package importable no matter where alembic is invoked from
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
# so the repo root is two directories up.
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.dirname(os.path.dirname(_HERE))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
from server.db import Base, DATABASE_URL, engine # noqa: E402
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# The app resolves its URL from the environment; feed the same value to Alembic.
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
context.configure(
url=str(DATABASE_URL),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live connection, reusing the app's engine."""
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,23 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,30 +0,0 @@
"""user login lockout fields
Revision ID: 18373f14809e
Revises: 47bbe76aa749
Create Date: 2026-07-15 14:50:58.423834
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '18373f14809e'
down_revision = '47bbe76aa749'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'locked_until')
op.drop_column('users', 'failed_attempts')
# ### end Alembic commands ###

View File

@@ -1,29 +0,0 @@
"""wp archived_at
Revision ID: 47bbe76aa749
Revises: 4e094197c9aa
Create Date: 2026-07-15 12:00:14.356398
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '47bbe76aa749'
down_revision = '4e094197c9aa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
op.drop_column('work_packages', 'archived_at')
# ### end Alembic commands ###

View File

@@ -1,48 +0,0 @@
"""audit log
Revision ID: 4e094197c9aa
Revises: c6af106a04da
Create Date: 2026-07-15 10:12:52.859694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e094197c9aa'
down_revision = 'c6af106a04da'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_log',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('actor', sa.String(length=200), nullable=False),
sa.Column('action', sa.String(length=60), nullable=False),
sa.Column('entity_type', sa.String(length=40), nullable=False),
sa.Column('entity_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('summary', sa.String(length=400), nullable=False),
sa.Column('detail', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
op.drop_table('audit_log')
# ### end Alembic commands ###

View File

@@ -1,63 +0,0 @@
"""assignment + settings + notifications
Revision ID: 57dec34f11cb
Revises: ad8e6cc5de0f
Create Date: 2026-07-15 16:43:09.230419
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '57dec34f11cb'
down_revision = 'ad8e6cc5de0f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_settings',
sa.Column('key', sa.String(length=80), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('notifications',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('kind', sa.String(length=40), nullable=False),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('subject', sa.String(length=300), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('link', sa.String(length=500), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error', sa.String(length=400), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
op.drop_column('work_packages', 'assignee_id')
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
op.drop_table('notifications')
op.drop_table('app_settings')
# ### end Alembic commands ###

View File

@@ -1,48 +0,0 @@
"""project archive + default members on new projects
Two changes that ship together:
• projects.archived_at — an archived project disappears from every picker,
switcher and search and is frozen read-only. Mirrors work_packages.archived_at
(47bbe76aa749): NULL means "live", which is what every existing row gets, so
nothing changes for current data.
• users.auto_add_projects / users.auto_add_role — a PM or QA lead who belongs on
every job is added to each new project automatically. auto_add_role shares
project_members.role's value space ('' = inherit the account's own role).
Revision ID: a7c31f9e5b02
Revises: d15b8c4ef207
Create Date: 2026-08-05 10:12:47.503914
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a7c31f9e5b02'
down_revision = 'd15b8c4ef207'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('projects', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_projects_archived_at'), 'projects', ['archived_at'], unique=False)
# These two are NOT NULL and land on a table that already has rows, so
# server_default is what backfills them (nobody is auto-added until an admin
# turns it on). The defaults are deliberately LEFT IN PLACE afterwards, as every
# other migration here does (18373f14809e, b41c7ae90d52, c93f2b1d7e04,
# d15b8c4ef207): dropping one needs ALTER COLUMN, which SQLite only fakes via a
# batch_alter_table table rebuild, and dev runs on SQLite (wpsuite.db). The ORM
# supplies both values on every INSERT, so the leftover default is only ever
# read by hand-written SQL — and there it is the answer we'd want anyway.
op.add_column('users', sa.Column('auto_add_projects', sa.Boolean(),
nullable=False, server_default=sa.false()))
op.add_column('users', sa.Column('auto_add_role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'auto_add_role')
op.drop_column('users', 'auto_add_projects')
op.drop_index(op.f('ix_projects_archived_at'), table_name='projects')
op.drop_column('projects', 'archived_at')

View File

@@ -1,28 +0,0 @@
"""user token_version
Revision ID: ad8e6cc5de0f
Revises: 18373f14809e
Create Date: 2026-07-15 16:03:57.736556
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ad8e6cc5de0f'
down_revision = '18373f14809e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'token_version')
# ### end Alembic commands ###

View File

@@ -1,35 +0,0 @@
"""permissions roles + project (job function) role
Adds `users.project_role` (job function on the project — carries no permissions)
and migrates the permissions vocabulary: the legacy role 'user' becomes
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
granted explicitly from the admin console.
Revision ID: b41c7ae90d52
Revises: 57dec34f11cb
Create Date: 2026-08-03 15:12:04.118322
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b41c7ae90d52'
down_revision = '57dec34f11cb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# server_default backfills existing rows (the column is NOT NULL).
op.add_column('users', sa.Column('project_role', sa.String(length=120),
nullable=False, server_default=''))
# Legacy 'user' means exactly what 'project_user' means now.
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
def downgrade() -> None:
# Fold the new role back onto the legacy value so an older build still reads
# the table. A project_admin loses its elevated rights on downgrade.
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
op.drop_column('users', 'project_role')

View File

@@ -1,145 +0,0 @@
"""baseline schema
Revision ID: c6af106a04da
Revises:
Create Date: 2026-07-15 08:21:07.450350
This is the initial baseline. It creates the current schema on a fresh database,
and safely ADOPTS an existing database (one whose tables were created by the old
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
already present it records this revision without recreating anything. That means
`alembic upgrade head` is safe to run on both new and existing deployments — no
manual `alembic stamp` step required.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c6af106a04da'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("projects"):
# Existing pre-Alembic database — adopt it as the baseline as-is.
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('comments',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('step', sa.Integer(), nullable=True),
sa.Column('author', sa.String(length=200), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('page', sa.String(length=200), nullable=False),
sa.Column('extra', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
op.create_table('projects',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('client', sa.String(length=300), nullable=False),
sa.Column('division', sa.String(length=200), nullable=False),
sa.Column('site', sa.String(length=300), nullable=False),
sa.Column('sample', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('username', sa.String(length=120), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('project_members',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
)
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
op.create_table('sops',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('complete', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
op.create_table('work_packages',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('parent_id', sa.String(length=40), nullable=True),
sa.Column('number', sa.String(length=120), nullable=False),
sa.Column('subject', sa.String(length=400), nullable=False),
sa.Column('type', sa.String(length=120), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
op.drop_table('work_packages')
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
op.drop_table('sops')
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
op.drop_table('project_members')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_projects_number'), table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
op.drop_index(op.f('ix_comments_source'), table_name='comments')
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
op.drop_table('comments')
# ### end Alembic commands ###

View File

@@ -1,29 +0,0 @@
"""user locale + timezone preferences
Per-user display preferences. Empty means "use the app default (admin console),
then the browser". Stored server-side so they follow the person between devices —
shared field tablets are the case that matters.
Revision ID: c93f2b1d7e04
Revises: b41c7ae90d52
Create Date: 2026-08-03 16:44:10.882931
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c93f2b1d7e04'
down_revision = 'b41c7ae90d52'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'timezone')
op.drop_column('users', 'locale')

View File

@@ -1,28 +0,0 @@
"""per-project member role
Lets someone be Project Admin on one job and a normal Project User on another.
Empty string means "inherit the account's own role" (users.role), which is exactly
how every existing membership behaved, so this is a no-op for current data.
Revision ID: d15b8c4ef207
Revises: c93f2b1d7e04
Create Date: 2026-08-03 17:58:22.401118
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd15b8c4ef207'
down_revision = 'c93f2b1d7e04'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('project_members', sa.Column('role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('project_members', 'role')

File diff suppressed because it is too large Load Diff

View File

@@ -16,29 +16,7 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a 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. warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the Roles: 'admin' (may manage users) and 'user'.
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
everything a project_admin may do, plus USER ADMINISTRATION
scoped to the projects they hold the role on: they create and
manage the accounts on their own jobs without an app admin
having to do it for them. They cannot reach app settings, and
they cannot create or alter an admin / super-user account.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
""" """
import os import os
import secrets import secrets
@@ -52,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db, DATABASE_URL from .db import get_db
from . import models from . import models
log = logging.getLogger("wpsuite.auth") log = logging.getLogger("wpsuite.auth")
@@ -61,88 +39,6 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again. # How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_SUPER = "project_super_user"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
# Ordered most- to least-privileged; the console renders dropdowns in this order.
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_SUPER: "Project Super User",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
# run the users on one job and be an ordinary member of the next. '' means "inherit
# the account's own role" and is always allowed alongside these.
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for the roles allowed to delete work packages and change a completed
SOP. A super user is a project admin with user administration on top, so it is
included here — never enumerate the two roles by hand."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
# super-user role can be held per project (ProjectMember.role), so the question needs
# membership rows to answer and lives in app.py — `is_user_manager` /
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs). # Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_PREFIXES = ("/api/auth/",)
@@ -159,24 +55,13 @@ def _load_secret() -> str:
s = os.getenv("AUTH_SECRET_KEY") s = os.getenv("AUTH_SECRET_KEY")
if s: if s:
return s return s
# No key configured. In production (a real database is configured via # No secret configured: generate an ephemeral one so the app still runs in
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign # dev. Sessions won't survive a restart, and this is unsafe across multiple
# sessions with a throwaway key that silently rotates on every restart. In # workers — production must set AUTH_SECRET_KEY.
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning( log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. " "AUTH_SECRET_KEY is not set — using a random ephemeral key. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev." "Logins will reset on restart and break across multiple workers. "
"Set AUTH_SECRET_KEY in the environment for production."
) )
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)
@@ -207,7 +92,6 @@ def create_token(user: "models.User") -> str:
"sub": user.id, "sub": user.id,
"username": user.username, "username": user.username,
"role": user.role, "role": user.role,
"ver": user.token_version or 0,
"iat": now, "iat": now,
"exp": now + timedelta(hours=SESSION_HOURS), "exp": now + timedelta(hours=SESSION_HOURS),
} }
@@ -215,43 +99,11 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None. """Return the token claims if the signature and expiry are valid, else None."""
Session cookies only — a token of any other type is rejected."""
try: try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ──────────────────────────────────────────────────────────── # ── cookie helpers ────────────────────────────────────────────────────────────
@@ -311,21 +163,15 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
user = db.get(models.User, claims.get("sub")) user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user return user
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User": def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if not is_admin(user): if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user return user
# ── account helpers (shared by routes and the CLI) ────────────────────────────── # ── account helpers (shared by routes and the CLI) ──────────────────────────────
def find_user(db: Session, username: str) -> Optional["models.User"]: def find_user(db: Session, username: str) -> Optional["models.User"]:
"""Look up by username, case-insensitively (also matches on email).""" """Look up by username, case-insensitively (also matches on email)."""

View File

@@ -12,7 +12,7 @@ Connection precedence:
The schema is identical either way (SQLAlchemy handles dialect differences). The schema is identical either way (SQLAlchemy handles dialect differences).
""" """
import os import os
from sqlalchemy import create_engine, event, URL 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). # Load a local .env if present (dev convenience).
@@ -46,25 +46,6 @@ _is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
connect_args = {"check_same_thread": False} if _is_sqlite else {} 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)
if _is_sqlite:
# SQLite ships with foreign keys DISABLED and the pragma is per-connection, so
# without this every `ondelete="CASCADE"` in models.py is silently a no-op on a
# dev database while working correctly on Postgres. That divergence is worse than
# it sounds: deleting a project left its SOPs, work packages and membership rows
# behind as orphans pointing at an id that no longer exists, and deleting a user
# left their project_members rows — and the smoke test's cascade assertion failed
# on dev while passing in production, which is the exact failure that makes a
# smoke test worth ignoring.
#
# Registered on the engine, not a session, because the pragma has to be set on
# each new DBAPI connection as the pool creates it.
@event.listens_for(engine, "connect")
def _sqlite_enforce_foreign_keys(dbapi_connection, _record):
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)

View File

@@ -30,28 +30,23 @@ def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}" return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str: def _prompt_password(provided: str | None) -> str:
pw = provided pw = provided
if not pw: if not pw:
pw = getpass.getpass("New password: ") pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ") confirm = getpass.getpass("Confirm password: ")
if pw != confirm: if pw != confirm:
sys.exit("Passwords do not match.") sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username) if len(pw) < 8:
if problem: sys.exit("Password must be at least 8 characters.")
sys.exit(problem)
return pw return pw
def cmd_create(args, role: str | None = None) -> None: def cmd_create(args, role: str | None = None) -> None:
role = role or args.role role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the if role not in ("admin", "user"):
# documented one-liners keep working; anything else has to be a current role. sys.exit("role must be 'admin' or 'user'")
if role == "user": pw = _prompt_password(getattr(args, "password", None))
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db: with SessionLocal() as db:
if auth.find_user(db, args.username): if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.") sys.exit(f"A user named '{args.username}' already exists.")
@@ -74,14 +69,13 @@ def cmd_list(args) -> None:
if not rows: if not rows:
print("No users yet. Create one with: create-admin <username>") print("No users yet. Create one with: create-admin <username>")
return return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}") print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
for u in rows: for u in rows:
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}" print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def cmd_reset_password(args) -> None: def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username) pw = _prompt_password(getattr(args, "password", None))
with SessionLocal() as db: with SessionLocal() as db:
u = auth.find_user(db, args.username) u = auth.find_user(db, args.username)
if not u: if not u:
@@ -118,8 +112,7 @@ def main() -> None:
add_create("create-admin", "create an admin account") add_create("create-admin", "create an admin account")
c = add_create("create", "create an account") c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER, c.add_argument("--role", choices=["admin", "user"], default="user")
help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts") sub.add_parser("list", help="list all accounts")

View File

@@ -9,21 +9,6 @@ The full client document for a SOP or WP is kept verbatim in a JSON `data`
column, with the most-queried fields promoted to real columns for listing and column, with the most-queried fields promoted to real columns for listing and
filtering. IDs are short strings (client- or server-generated) so the browser filtering. IDs are short strings (client- or server-generated) so the browser
can upsert without round-tripping a sequence. can upsert without round-tripping a sequence.
NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about.
Every link here is a plain column plus a ForeignKey; nothing is navigable as
`project.work_packages`. Queries are explicit selects, which suits an API that
mostly reads one scoped list at a time and never wants a lazy load firing inside
a response.
The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships,
not from ForeignKey metadata. With none declared it has no dependency edge to
follow, so if you add a parent and its child in the SAME flush it may emit the
child's INSERT first and the database will reject it. Both engines enforce foreign
keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this
is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent —
see `create_user` in app.py, which creates an account and its ProjectMember rows
together.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -48,11 +33,6 @@ class Project(Base):
division: Mapped[str] = mapped_column(String(200), default="") division: Mapped[str] = mapped_column(String(200), default="")
site: Mapped[str] = mapped_column(String(300), default="") site: Mapped[str] = mapped_column(String(300), default="")
sample: Mapped[bool] = mapped_column(Boolean, default=False) sample: Mapped[bool] = mapped_column(Boolean, default=False)
# Archived projects are hidden from every picker, switcher and search but kept
# for the record — a finished job still has to be readable years later. Unlike
# an archived work package they are also FROZEN read-only: the API refuses any
# write to the project or to anything under it until an admin unarchives it.
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
data: Mapped[dict] = mapped_column(JSON, default=dict) data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="") created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -62,9 +42,7 @@ class Project(Base):
return { return {
"id": self.id, "name": self.name, "number": self.number, "id": self.id, "name": self.name, "number": self.number,
"client": self.client, "division": self.division, "site": self.site, "client": self.client, "division": self.division, "site": self.site,
"sample": self.sample, "sample": self.sample, "created_by": self.created_by,
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
} }
@@ -114,13 +92,7 @@ class WorkPackage(Base):
subject: Mapped[str] = mapped_column(String(400), default="") subject: Mapped[str] = mapped_column(String(400), default="")
type: Mapped[str] = mapped_column(String(120), default="") type: Mapped[str] = mapped_column(String(120), default="")
status: Mapped[str] = mapped_column(String(40), default="Draft") status: Mapped[str] = mapped_column(String(40), default="Draft")
# The accountable owner (a user id), for "My Work Packages" + assignment
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Archived packages are hidden from the default lists/dashboard but kept for
# the record (years-long projects accumulate hundreds of closed WPs).
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
data: Mapped[dict] = mapped_column(JSON, default=dict) data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="") created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -130,9 +102,7 @@ class WorkPackage(Base):
return { return {
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id, "id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
"parent_id": self.parent_id, "number": self.number, "subject": self.subject, "parent_id": self.parent_id, "number": self.number, "subject": self.subject,
"type": self.type, "status": self.status, "assignee_id": self.assignee_id, "type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
"issued_at": _iso(self.issued_at),
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
} }
@@ -143,16 +113,8 @@ class WorkPackage(Base):
class User(Base): class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt """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. hash (see server/auth.py). `username` is what people sign in with; `role` is
either 'admin' (can manage users) or 'user'."""
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_super_user' | 'project_admin' |
'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String(40), primary_key=True) id: Mapped[str] = mapped_column(String(40), primary_key=True)
@@ -160,56 +122,25 @@ class User(Base):
email: Mapped[str] = mapped_column(String(200), default="") email: Mapped[str] = mapped_column(String(200), default="")
full_name: 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="") password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
# new project by hand, so flagged accounts get a ProjectMember row the moment a
# project is created. `auto_add_role` is the role they land with and shares
# ProjectMember.role's value space: '' = inherit the account's own role,
# otherwise 'project_admin' | 'project_user'.
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
auto_add_role: Mapped[str] = mapped_column(String(20), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=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) last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash.""" """Public view of a user — NEVER includes the password hash."""
return { return {
"id": self.id, "username": self.username, "email": self.email, "id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role, "full_name": self.full_name, "role": self.role, "is_active": self.is_active,
"project_role": self.project_role or "", "is_active": self.is_active,
"auto_add_projects": bool(self.auto_add_projects),
"auto_add_role": self.auto_add_role or "",
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at), "created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
} }
class ProjectMember(Base): class ProjectMember(Base):
"""Which users may access which projects, and what they may do there. A user """Which users may access which projects. A user sees/operates on a project
sees/operates on a project only if a row links them to it (admins bypass this only if a row links them to it (admins bypass this entirely). One row per
entirely). One row per (user, project) pair. (user, project) pair."""
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
one job and a normal Project User on another, or a Project Super User (who
administers that job's user accounts) on one job only. Empty means "inherit the
account's own role" (User.role), which is how every existing row behaves.
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
__tablename__ = "project_members" __tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),) __table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
@@ -220,7 +151,6 @@ class ProjectMember(Base):
project_id: Mapped[str] = mapped_column( project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
) )
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -246,74 +176,5 @@ class Comment(Base):
} }
class AuditLog(Base):
"""Append-only history: who changed what, when. Rows are written inside the
same transaction as the change they describe (see server/app.py: log_event),
so the trail can't drift from the data. `detail` holds a compact JSON summary
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
Not a ForeignKey to any entity on purpose — the log must survive the deletion
of the thing it describes (you still want "who deleted WP01, and when")."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
"entity_type": self.entity_type, "entity_id": self.entity_id,
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
the SMTP password are NOT stored here — they come from the environment."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email
sender processes it only when email notifications are enabled AND SMTP is set —
otherwise it's recorded as 'skipped'. See server/notify.py."""
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
email: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
subject: Mapped[str] = mapped_column(String(300), default="")
body: Mapped[str] = mapped_column(Text, default="")
link: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
error: Mapped[str] = mapped_column(String(400), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
"status": self.status, "error": self.error,
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
}
def _iso(dt: Optional[datetime]) -> Optional[str]: def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None return dt.isoformat() if dt else None

View File

@@ -1,174 +0,0 @@
"""Notifications: admin-configurable email + an outbox.
Email notifications are OFF by default and controlled from the admin console (a
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP
number and a deep link, not the work-package contents.
"""
import os
import smtplib
import uuid
import logging
from email.message import EmailMessage
from typing import Optional
from sqlalchemy.orm import Session
from . import models
log = logging.getLogger("wpsuite.notify")
SETTINGS_KEY = "notifications"
DEFAULTS = {
"email_enabled": False, # master toggle — OFF until SMTP is sorted
"smtp_host": "",
"smtp_port": 587,
"smtp_use_tls": True,
"smtp_username": "",
"from_addr": "",
"from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
# with it off, the SOP creator hides the BIM section entirely and every SOP is
# install-only, so no project can be put on the BIM path by accident.
"bim_enabled": False,
# Localization defaults for dates, times and numbers. Empty = use each
# browser's own locale / timezone. A user's own preference wins over these.
"default_locale": "", # BCP47, e.g. en-US
"default_timezone": "", # IANA, e.g. America/Chicago
}
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults + whether self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS)
if row and row.value:
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
return s
def save_settings(db: Session, patch: dict) -> dict:
cur = get_settings(db)
for k in DEFAULTS:
if k in patch and patch[k] is not None:
cur[k] = patch[k]
row = db.get(models.AppSetting, SETTINGS_KEY)
if row:
row.value = cur
else:
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
db.commit()
return cur
def public_settings(db: Session) -> dict:
"""Settings safe to return to the admin UI — no secrets."""
s = get_settings(db)
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
return s
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr"))
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
"""Send one email via SMTP. Raises on any failure (caller records it)."""
if not to_addr:
raise ValueError("no recipient email")
msg = EmailMessage()
from_name = s.get("from_name") or ""
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
host = s["smtp_host"]
port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv:
if s.get("smtp_use_tls", True):
srv.starttls()
if user:
srv.login(user, pw)
srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
commit — the caller commits with its own transaction. Returns the row."""
s = get_settings(db)
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
n = models.Notification(
id="ntf_" + uuid.uuid4().hex[:12],
user_id=user.id, email=user.email or "", kind=kind,
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
link=link[:500], status="pending" if deliverable else "skipped",
)
db.add(n)
return n
def deliver(notif_id: str) -> None:
"""Background task: send one pending notification, on its own DB session."""
from .db import SessionLocal
db = SessionLocal()
try:
n = db.get(models.Notification, notif_id)
if not n or n.status != "pending":
return
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
n.status = "skipped"
db.commit()
return
try:
send_email(s, n.email, n.subject, n.body)
n.status = "sent"
n.sent_at = models.utcnow()
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
n.status = "failed"
n.error = str(e)[:400]
log.warning("notification %s failed to send: %s", notif_id, e)
db.commit()
finally:
db.close()

View File

@@ -1,16 +1,9 @@
# Pinned to exact versions for reproducible builds — no silent dependency drift fastapi>=0.110
# on every `docker compose up --build`. To update: bump a version here on purpose, uvicorn[standard]>=0.29
# run `pip-audit` against the result, and test. For supply-chain integrity, the gunicorn>=21.2
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with sqlalchemy>=2.0
# `pip install --require-hashes`). psycopg[binary]>=3.1
fastapi==0.138.1 pydantic>=2.6
uvicorn[standard]==0.49.0 python-dotenv>=1.0
gunicorn==26.0.0 bcrypt>=4.1 # password hashing
sqlalchemy==2.0.51 PyJWT>=2.8 # signed session tokens
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)

View File

@@ -79,11 +79,8 @@ def main():
if st != 200: if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1 print(f"ABORT: /api/health returned {st}"); return 1
# --clean: remove any prior demo projects (cascade removes their SOP + WPs). # --clean: remove any prior demo projects (cascade removes their SOP + WPs)
# archived=all because /api/projects hides archived projects by default — an st, projects = call("GET", "/api/projects")
# archived DEMO project is still a DEMO project, and --clean has to find it.
# (Deleting one is still allowed; only writes to its contents are frozen.)
st, projects = call("GET", "/api/projects?archived=all")
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")] demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
if args.clean: if args.clean:
for p in demos: for p in demos:

View File

@@ -5,24 +5,6 @@ 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 NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq. release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
AUTHENTICATION
Every /api/ route except /api/health requires a session (auth_gate in
server/app.py), so the script signs in first and keeps the session cookie for
the rest of the run. Credentials come from the environment by preference, so a
password never has to appear in a command line or shell history:
export WP_SMOKE_USER=smoketest
export WP_SMOKE_PASSWORD=''
python3 server/smoketest.py https://wp-suite.company.local
…or pass --user / --password explicitly.
Use an ADMIN account. The script creates a project and deletes it again at the
end, and deleting one takes Project Admin on that project (require_project_admin);
a plain project_user can create a project but not clean it up. The script checks
the signed-in role up front and warns if it is too low, rather than letting you
discover it in the cleanup step.
USAGE USAGE
# Against the deployed site (through the NGINX proxy): # Against the deployed site (through the NGINX proxy):
python3 server/smoketest.py https://wp-suite.company.local python3 server/smoketest.py https://wp-suite.company.local
@@ -31,24 +13,16 @@ USAGE
python3 server/smoketest.py https://wp-suite.company.local --insecure python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly): # From inside the api container (hits FastAPI directly):
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \ docker compose exec api python /app/server/smoketest.py http://localhost:8000
python /app/server/smoketest.py http://localhost:8000
# Leave the demo project in the database so you can open it in the UI: # 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 python3 server/smoketest.py https://wp-suite.company.local --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000 The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit code 0 = all checks passed, 1 = one or more failed.
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
could not start (unreachable host, missing or rejected credentials). 2 is kept
distinct on purpose: "I could not test this" is not the same answer as "this is
broken", and conflating them is what made an unauthenticated version of this
script report a wall of failures against a perfectly healthy stack.
""" """
import argparse import argparse
import http.cookiejar
import json import json
import os
import ssl import ssl
import sys import sys
import urllib.error import urllib.error
@@ -66,18 +40,6 @@ def check(name, cond, detail=""):
BASE = "" BASE = ""
CTX = None CTX = None
# One opener for the whole run, carrying the cookie jar that holds the session
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
# session used to be dropped on the floor and every data route answered 401.
OPENER = None
def build_opener(ctx=None):
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers)
def call(method, path, body=None): def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status.""" """Returns (status_code, parsed_body). Never raises on HTTP status."""
@@ -88,7 +50,7 @@ def call(method, path, body=None):
headers={"Content-Type": "application/json", "Accept": "application/json"}, headers={"Content-Type": "application/json", "Accept": "application/json"},
) )
try: try:
with OPENER.open(req, timeout=20) as r: with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
raw = r.read().decode(); status = r.status raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code raw = e.read().decode(); status = e.code
@@ -99,95 +61,33 @@ def call(method, path, body=None):
return status, parsed return status, parsed
def abort(msg, hint=""):
"""Could not run — distinct from 'ran and found problems'. See exit codes above."""
print(_c("\nABORT", "31") + " " + msg)
if hint:
print(hint)
print()
return 2
def main(): def main():
global BASE, CTX, OPENER global BASE, CTX
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test") ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000", ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (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("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)") ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
"so it stays out of shell history)")
args = ap.parse_args() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX)
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n") print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
# Refuse to start without credentials rather than running headlong into 401s.
if not args.user or not args.password:
missing = " and ".join(
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
return abort(
f"no credentials — {missing} not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n"
" meaningful to test without one. Set them and re-run:\n\n"
" export WP_SMOKE_USER=<admin-account>\n"
" export WP_SMOKE_PASSWORD=''\n\n"
" Or pass --user/--password. Use an admin account: the run creates a project\n"
" and deletes it again, and the delete needs Project Admin on it.")
project_id = None project_id = None
# Guards the sign-out in `finally`. Without it an ABORT on a rejected login still
# ran the logout checks, which "passed" — a session that never existed is trivially
# refused after logout — and printed PASS lines underneath an abort message.
logged_in = False
try: try:
# 1) Health — API is up and reachable through the proxy. Exempt from auth, # 1) Health — API is up and reachable through the proxy.
# so this also isolates "host unreachable" from "credentials rejected".
try: try:
st, body = call("GET", "/api/health") st, body = call("GET", "/api/health")
except urllib.error.URLError as e: except urllib.error.URLError as e:
return abort(f"cannot reach {BASE}/api/health — {e}", print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
" Is the stack up (docker compose ps) and the URL correct?") " 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, check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}") f"status={st} body={body}")
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides # 2) Create a project (writes to the projects table).
# every request after this one.
st, body = call("POST", "/api/auth/login",
{"username": args.user, "password": args.password})
if st != 200:
detail = body.get("detail") if isinstance(body, dict) else body
hint = (" The account may be locked: the API locks an account for a while after\n"
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
" so re-running with the wrong password makes this worse, not better.\n"
" Check the password, then wait out the lockout window."
if st in (401, 403, 423, 429) else
" Unexpected status from the login endpoint — check the API logs.")
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
logged_in = True
check("login issues a session", st == 200)
# 3) Prove the session actually travels — this is the check whose absence let
# an unauthenticated version of this script look like a broken stack.
st, me = call("GET", "/api/auth/me")
who = (me or {}).get("user", {}) if isinstance(me, dict) else {}
check("session is accepted on an authenticated route",
st == 200 and who.get("username", "").lower() == args.user.lower(),
f"status={st} body={me}")
role = who.get("role", "?")
print(f" ..... signed in as {who.get('username', args.user)} (role: {role})")
if role not in ("admin", "project_super_user", "project_admin"):
print(_c(" NOTE", "33") + f" '{role}' cannot archive or delete a project, so the "
"archive checks and the\n cleanup step will fail and a stray test project "
"will be left behind.\n Re-run with an admin account for a clean pass.")
# 4) Create a project (writes to the projects table).
st, proj = call("POST", "/api/projects", { st, proj = call("POST", "/api/projects", {
"name": "ZZ Smoke Test Project", "number": "SMOKE-001", "name": "ZZ Smoke Test Project", "number": "SMOKE-001",
"client": "Internal QA", "division": "Controls", "site": "Test Host", "client": "Internal QA", "division": "Controls", "site": "Test Host",
@@ -196,14 +96,14 @@ def main():
project_id = proj.get("id") if isinstance(proj, dict) else None project_id = proj.get("id") if isinstance(proj, dict) else None
check("create project", st == 200 and bool(project_id), f"status={st}") check("create project", st == 200 and bool(project_id), f"status={st}")
# 5) Read it back + confirm it's in the list (SQL round-trip). # 3) Read it back + confirm it's in the list (SQL round-trip).
st, got = call("GET", f"/api/projects/{project_id}") 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}") check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
st, lst = call("GET", "/api/projects") st, lst = call("GET", "/api/projects")
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst), 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 '?'}") f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
# 6) Create a SOP linked to the project. # 4) Create a SOP linked to the project.
st, sop = call("POST", "/api/sops", { st, sop = call("POST", "/api/sops", {
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001", "project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
"complete": True, "created_by": "smoketest", "complete": True, "created_by": "smoketest",
@@ -216,7 +116,7 @@ def main():
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}") 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}") check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
# 7) Create a Work Package with one OPEN constraint (not release-ready). # 5) Create a Work Package with one OPEN constraint (not release-ready).
st, wp = call("POST", "/api/wps", { st, wp = call("POST", "/api/wps", {
"project_id": project_id, "sop_id": sop_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
@@ -228,11 +128,11 @@ def main():
wp_id = wp.get("id") if isinstance(wp, dict) else None wp_id = wp.get("id") if isinstance(wp, dict) else None
check("create work package", st == 200 and bool(wp_id), f"status={st}") check("create work package", st == 200 and bool(wp_id), f"status={st}")
# 8) The AWP release gate: issuing with an open constraint must be REFUSED (409). # 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
st, refused = call("POST", f"/api/wps/{wp_id}/issue") 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}") check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
# 9) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued). # 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
call("POST", "/api/wps", { call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id, "id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
@@ -246,16 +146,16 @@ def main():
f"status={st}") f"status={st}")
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at"))) check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
# 10) Status transition endpoint. # 8) Status transition endpoint.
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"}) 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}") check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
# 11) Metrics aggregate for the project (Python aggregation over SQL rows). # 9) Metrics aggregate for the project (Python aggregation over SQL rows).
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}") 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, check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
f"status={st} metrics={m}") f"status={st} metrics={m}")
# 12) Comment / feedback write + read. # 10) Comment / feedback write + read.
st, c = call("POST", "/api/feedback", { st, c = call("POST", "/api/feedback", {
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id, "type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"}) "text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
@@ -264,36 +164,12 @@ def main():
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments), check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
f"status={st}") f"status={st}")
# 13) WPs filter by project. # 11) WPs filter by project.
st, wps = call("GET", f"/api/wps?project_id={project_id}") 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}") check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
# 14) Archiving a project: it leaves the default list, stays reachable with
# archived=all, and freezes read-only — then unarchiving restores all three.
# The freeze is the whole point of the feature, so it is asserted, not assumed.
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
check("archive project", st == 200 and arch.get("archived") is True, f"status={st}")
st, lst = call("GET", "/api/projects")
check("archived project drops out of the default list",
st == 200 and not any(p.get("id") == project_id for p in lst), f"status={st}")
st, lst = call("GET", "/api/projects?archived=all")
check("archived project is still there with archived=all",
st == 200 and any(p.get("id") == project_id for p in lst), f"status={st}")
st, refused = call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "edited while archived", "type": "Conduit Install",
"status": "Scheduled", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing to an archived project is refused (409)", st == 409, f"status={st} body={refused}")
st, unarch = call("POST", f"/api/projects/{project_id}/archive", {"archived": False})
check("unarchive project", st == 200 and unarch.get("archived") is False, f"status={st}")
st, _ = 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": "In Progress", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing succeeds again once unarchived", st == 200, f"status={st}")
finally: finally:
# 15) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE). # 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
if project_id and not args.keep: if project_id and not args.keep:
st, _ = call("DELETE", f"/api/projects/{project_id}") st, _ = call("DELETE", f"/api/projects/{project_id}")
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}") check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
@@ -303,15 +179,6 @@ def main():
elif project_id and args.keep: elif project_id and args.keep:
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.") print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
# 16) Sign out. Exercises the logout endpoint, and means a run does not end
# holding a live session — which matters when this is run from a shared
# jump host or a CI worker. Only if we got one: see `logged_in`.
if logged_in:
st, _ = call("POST", "/api/auth/logout")
check("logout clears the session", st == 200, f"status={st}")
st, _ = call("GET", "/api/auth/me")
check("session is refused after logout (401)", st == 401, f"status={st}")
# ── summary ──────────────────────────────────────────────────────────────── # ── summary ────────────────────────────────────────────────────────────────
total = len(_PASS) + len(_FAIL) total = len(_PASS) + len(_FAIL)
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.") print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")

View File

@@ -1,456 +0,0 @@
#!/usr/bin/env python3
"""Front-end check for the Work Package Suite — runs the pages in a real browser.
server/smoketest.py proves the API works. This proves the PAGES work: that they
boot without a JavaScript error, that the role-dependent renderings are what they
should be, and that the layout rules the console pages depend on are in effect.
Those are the things no amount of static analysis can settle, and the reason this
exists is that they went unverified once — see the git history for KNOWN-ISSUES 3.
Self-contained by default: it creates a throwaway SQLite database, seeds a fixture
(two projects, one admin, one Project Super User, one plain member, an account
spanning both jobs), starts its own uvicorn, drives headless Edge or Chrome over
the DevTools Protocol, and tears all of it down. Your real database is never
touched. Stdlib only — no pip, matching server/smoketest.py.
python tests/browser_check.py # everything, self-contained
python tests/browser_check.py --keep-server # leave the server up to poke at
WP_BROWSER=/path/to/chrome python tests/browser_check.py
Sessions are established by minting a token with the app's own auth.create_token()
and setting it as the wp_session cookie — the same cookie the server would issue,
without scripting the login form.
Exit codes: 0 all checks passed · 1 one or more failed · 2 could not run (no
browser found, or the server would not start). 2 is distinct on purpose: "I could
not test this" is not the same answer as "this is broken".
"""
import argparse
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
PW = "CorrectHorseBattery9"
_PASS, _FAIL = [], []
def _c(s, code):
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
def chk(name, cond, extra=""):
if cond:
_PASS.append(name)
print(" " + _c("PASS", "32") + " " + name)
else:
_FAIL.append(name)
print(" " + _c("FAIL", "31") + " " + name + (f" {extra}" if extra else ""))
return bool(cond)
def abort(msg, hint=""):
print(_c("\nABORT", "31") + " " + msg)
if hint:
print(hint)
print()
return 2
# ── fixture ───────────────────────────────────────────────────────────────────
def seed(db_path):
"""Build the throwaway database. Returns {username: session token}.
The shape matters, in two ways:
• `mix` belongs to BOTH projects while `sue` administers only Job A, which is
what makes an out-of-scope, read-only row appear in the directory — the case
the role exists to get right.
• `bob` is on Job B alone, so he is invisible to `sue` entirely. Without
someone in that position the admin and the super user would see the same
number of rows and the scoping assertion would prove nothing."""
os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
os.environ.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
from server.db import SessionLocal, Base, engine
from server import models, auth
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
def mk(username, role):
db.add(models.User(id="user_" + username, username=username,
email=f"{username}@example.test", full_name=username.title(),
password_hash=auth.hash_password(PW), role=role))
mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
mk("pat", auth.ROLE_PROJECT_USER) # Job A only
mk("mix", auth.ROLE_PROJECT_USER) # both jobs -> read-only to sue
mk("bob", auth.ROLE_PROJECT_USER) # Job B only -> invisible to sue
mk("sam", auth.ROLE_PROJECT_SUPER) # peer super user
mk("legacy", "user") # pre-roles spelling
db.add(models.Project(id="projA", name="Job A", number="A-1", client="Internal QA"))
db.add(models.Project(id="projB", name="Job B", number="B-1", client="Internal QA"))
# Parents before children: no relationship() means the ORM has no flush
# order to follow, and foreign keys are enforced. See models.py.
db.flush()
for i, (uid, pid, role) in enumerate([
("user_sue", "projA", ""), ("user_pat", "projA", ""), ("user_mix", "projA", ""),
("user_mix", "projB", ""), ("user_bob", "projB", ""),
("user_sam", "projA", ""), ("user_legacy", "projA", ""),
]):
db.add(models.ProjectMember(id=f"pm{i}", user_id=uid, project_id=pid, role=role))
# Job A gets a complete SOP and two packages. Without a SOP the field view's
# GET /api/sops/latest correctly answers 404 ("No SOP found") and the browser
# logs it as an error — a false alarm in a page-boot check.
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
complete=True,
data={"governance": {"disciplines": ["Mechanical", "Electrical"]}}))
db.flush()
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
db.add(models.WorkPackage(
id=wid, project_id="projA", sop_id="sopA", number=num, subject=subj,
status=status, type="Conduit Install",
data={"disciplines": ["Electrical"], "hours": "40",
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}]}))
db.commit()
return {u.username: auth.create_token(u)
for u in db.query(models.User).all()}
def start_server(port, db_path):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _ in range(160):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
return proc
except Exception:
if proc.poll() is not None:
return None
time.sleep(0.25)
proc.kill()
return None
# ── the checks ────────────────────────────────────────────────────────────────
USERS_READY = "!!document.querySelector('#users-table table, #users-table .note:not(:empty)')"
def run(page, base, tok):
def visit(user, path, wait_for=None):
page.clear_cookies()
page.set_cookie("wp_session", tok[user])
return page.goto(base + path, wait_for=wait_for)
# ── users.html as an administrator ────────────────────────────────────────
print("\nUser Directory — as an administrator")
visit("root", "/users.html", USERS_READY)
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("auth resolved to the admin account", page.eval("(window.WP_USER||{}).role") == "admin")
chk("the directory is visible",
page.eval("getComputedStyle(document.getElementById('users-main')).display") != "none")
chk("manager table renders 9 columns",
page.eval("document.querySelectorAll('#users-table thead th').length") == 9,
page.eval("document.querySelectorAll('#users-table thead th').length"))
chk("an admin sees every account in the fixture (7)",
page.eval("document.querySelectorAll('#users-table tbody tr').length") == 7,
page.eval("document.querySelectorAll('#users-table tbody tr').length"))
chk("rows are one line tall (the regression the runbook warns about)",
page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');"
"return r ? r.getBoundingClientRect().height : 999})()") < 44,
page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');"
"return r ? Math.round(r.getBoundingClientRect().height) : -1})()"))
chk("the table does not overflow its card",
page.eval("(()=>{const t=document.querySelector('#users-table');"
"return t.scrollWidth <= t.clientWidth + 1})()"))
chk("the page never scrolls sideways",
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
chk("create form is offered",
page.eval("getComputedStyle(document.getElementById('create-card')).display") != "none")
chk("an admin may grant all four roles",
page.eval("document.querySelectorAll('#nu-role option').length") == 4,
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)"))
chk("job-function list is populated",
page.eval("document.querySelectorAll('#nu-project-role option').length") == 15)
chk("scope banner names the Administrator role",
"Administrator" in (page.eval("document.getElementById('scope-banner').textContent") or ""))
chk("permissions dropdowns render per row",
page.eval("document.querySelectorAll('#users-table tbody select.role-select').length") >= 8)
# Your own row: permissions locked so you cannot demote yourself, job function
# still editable. Asserted on the two cells, not "no select in the row".
ROW = ("const r=[...document.querySelectorAll('#users-table tbody tr')]"
".find(r=>r.querySelector('.me-tag'));")
def own(q):
return "(()=>{" + ROW + "if(!r)return false;const c=r.cells[3];return " + q + "})()"
chk("your own permissions cell is locked, not a dropdown",
page.eval(own("!c.querySelector('select') && !!c.querySelector('.tag')")))
chk("...and wears the Administrator pill", page.eval(own("!!c.querySelector('.tag.admin')")))
chk("...while your job function stays editable",
page.eval("(()=>{" + ROW + "return !!r && !!r.cells[4].querySelector('select')})()"))
page.eval("[...document.querySelectorAll('#users-table tbody button')]"
".find(b=>/project/i.test(b.textContent)).click()")
time.sleep(0.9)
page.ws.drain(0.5)
chk("project-access dialog opens", page.eval("!!document.getElementById('proj-modal')"))
chk("...and lists projects to tick",
page.eval("document.querySelectorAll('#proj-list input[type=checkbox]').length") >= 1)
page.key("Escape")
chk("...and Escape closes it", page.eval("!document.getElementById('proj-modal')"))
# ── users.html as a Project Super User ────────────────────────────────────
print("\nUser Directory — as a Project Super User (Job A only)")
visit("sue", "/users.html", USERS_READY)
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
banner = page.eval("document.getElementById('scope-banner').textContent") or ""
chk("scope banner names the Project Super User role", "Project Super User" in banner, banner[:120])
chk("...and names the project they administer", "Job A" in banner, banner[:120])
# 6 of the 7: everyone on Job A, plus the admin (who reaches every project), but
# not `bob`, who is on Job B alone.
chk("only in-scope accounts are listed (6 of 7)",
page.eval("document.querySelectorAll('#users-table tbody tr').length") == 6,
page.eval("document.querySelectorAll('#users-table tbody tr').length"))
chk("...and an account on a job they cannot see is absent entirely",
page.eval("!/\\bbob\\b/.test(document.getElementById('users-table').textContent)"))
chk("accounts on other jobs are read-only",
page.eval("document.querySelectorAll('#users-table tbody tr.is-locked').length") >= 1)
chk("...and the reason is readable on hover",
page.eval("[...document.querySelectorAll('#users-table tbody tr.is-locked [title]')]"
".some(el=>/administer/i.test(el.title))"))
chk("a peer super user shows its own colour-coded pill",
page.eval("document.querySelectorAll('#users-table tbody .tag.super').length") >= 1)
chk("a super user may grant only the two roles below their own",
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value).join(',')")
== "project_admin,project_user",
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)"))
chk("create form demands a project",
"*" in (page.eval("document.getElementById('nu-projects-label').textContent") or ""))
chk("their single project is pre-ticked",
page.eval("document.querySelectorAll('#nu-project-list input:checked').length") == 1)
# ── users.html as an ordinary member ──────────────────────────────────────
print("\nUser Directory — as an ordinary Project User")
visit("pat", "/users.html", USERS_READY)
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("read-only directory renders 6 columns",
page.eval("document.querySelectorAll('#users-table thead th').length") == 6,
page.eval("document.querySelectorAll('#users-table thead th').length"))
chk("no create form",
page.eval("getComputedStyle(document.getElementById('create-card')).display") == "none")
chk("no action controls anywhere in the table",
page.eval("document.querySelectorAll('#users-table tbody button, "
"#users-table tbody select').length") == 0)
chk("no scope banner claiming rights",
(page.eval("document.getElementById('scope-banner').textContent") or "").strip() == "")
chk("colleagues' emails are reachable as mailto links",
page.eval("document.querySelectorAll('#users-table tbody a[href^=mailto]').length") >= 1)
# ── field.html and the navigation drawer ──────────────────────────────────
print("\nField view — navigation drawer")
visit("pat", "/field.html", "!!document.getElementById('wp-navbtn')")
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("hamburger is mounted in the app bar",
page.eval("!!document.querySelector('.wp-appbar #wp-navbtn')"))
chk("drawer starts hidden from assistive tech",
page.eval("document.getElementById('wp-sidenav').getAttribute('aria-hidden')") == "true")
chk("drawer is off-screen when closed",
page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().right") <= 1,
page.eval("Math.round(document.getElementById('wp-sidenav').getBoundingClientRect().right)"))
page.click("#wp-navbtn")
chk("clicking it opens the drawer",
page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
chk("...fully on-screen",
page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().left") >= -1)
chk("...with the scrim shown", page.eval("!document.querySelector('.wp-navscrim').hidden"))
chk("...and aria-expanded flipped",
page.eval("document.getElementById('wp-navbtn').getAttribute('aria-expanded')") == "true")
chk("Field View is marked as the current page",
(page.eval("(document.querySelector('.wp-sidenav-link.is-current .wp-sidenav-label')||{})"
".textContent") or "").startswith("Field View"))
chk("...and exposed to assistive tech as such",
page.eval("document.querySelectorAll('.wp-sidenav-link[aria-current=page]').length") == 1)
chk("Admin Console is hidden from a non-admin",
page.eval("![...document.querySelectorAll('.wp-sidenav-link')]"
".some(a=>/Admin Console/.test(a.textContent))"))
chk("User Directory is offered to everyone",
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
".some(a=>/User Directory/.test(a.textContent))"))
chk("tap targets are at least 44px tall",
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
".every(a=>a.getBoundingClientRect().height >= 44)"))
chk("focus moved into the drawer",
page.eval("document.getElementById('wp-sidenav').contains(document.activeElement)"))
page.key("Escape")
chk("Escape closes it",
not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
page.click("#wp-navbtn")
page.click(".wp-navscrim")
chk("clicking the scrim closes it",
not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
visit("pat", "/field.html?project=projA", "!!document.getElementById('wp-sidenav')")
chk("the drawer carries the active project on project-scoped links",
page.eval("(()=>{const l=[...document.querySelectorAll('.wp-sidenav-link')]"
".filter(a=>/work-package-suite|field\\.html/.test(a.getAttribute('href')||''));"
"return l.length>0 && l.every(a=>/project=projA/.test(a.getAttribute('href')))})()"))
chk("...and leaves non-project pages alone",
page.eval("!/project=/.test(document.querySelector"
"('.wp-sidenav-link[href^=\"users.html\"]').getAttribute('href'))"))
chk("the field view lists the project's work packages",
page.eval("document.querySelectorAll('#wp-list .wp-card').length") == 2,
page.eval("document.querySelectorAll('#wp-list .wp-card').length"))
chk("the drawer sits above the app bar",
page.eval("(()=>{const z=n=>+getComputedStyle(n).zIndex||0;"
"return z(document.getElementById('wp-sidenav')) > "
"z(document.querySelector('.wp-appbar'))})()"))
visit("root", "/field.html", "!!document.getElementById('wp-sidenav')")
chk("Admin Console appears for an admin",
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
".some(a=>/Admin Console/.test(a.textContent))"))
# ── admin.html: the console.css extraction ────────────────────────────────
# console.css was lifted out of admin.html's inline <style> to be shared with
# the directory. A rule lost in that move shows up here, not on the new page.
print("\nAdmin Console — shared console.css still in effect")
visit("root", "/admin.html",
"!!document.querySelector('#projects-table table, #projects-table .note')")
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("console is revealed for an admin",
page.eval("getComputedStyle(document.getElementById('admin-main')).display") != "none")
chk("console.css is loaded",
page.eval("[...document.styleSheets].some(s=>(s.href||'').endsWith('console.css'))"))
chk("shared tokens resolve (--ctl)",
(page.eval("getComputedStyle(document.documentElement).getPropertyValue('--ctl')")
or "").strip() == "32px")
chk("cards keep their white surface and hairline border",
page.eval("(()=>{const c=getComputedStyle(document.querySelector('.card'));"
"return c.backgroundColor==='rgb(255, 255, 255)' && c.borderTopWidth==='1px'})()"))
chk("card headings keep the uppercase accent treatment",
page.eval("(()=>{const h=getComputedStyle(document.querySelector('.card h2'));"
"return h.textTransform==='uppercase' && h.color==='rgb(15, 98, 254)'})()"))
chk("dense tables keep their sticky header and 13px body",
page.eval("(()=>{const t=document.querySelector('#projects-table table');if(!t)return false;"
"return getComputedStyle(t.querySelector('th')).position==='sticky' && "
"getComputedStyle(t).fontSize==='13px'})()"))
chk("project rows are one line tall",
page.eval("(()=>{const r=document.querySelector('#projects-table tbody tr');"
"return r ? r.getBoundingClientRect().height : 999})()") < 44)
chk("buttons keep the square Carbon shape",
page.eval("getComputedStyle(document.querySelector('.card button')).borderRadius") == "0px")
chk("user administration is gone from the console",
page.eval("!document.getElementById('users-table')"))
chk("...replaced by a link to the directory",
page.eval("!!document.querySelector('a[href=\"users.html\"]')"))
chk("the page never scrolls sideways",
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
visit("pat", "/admin.html", "true")
time.sleep(0.6)
chk("a non-admin sees the Admins-only notice",
page.eval("getComputedStyle(document.getElementById('admin-denied')).display") != "none")
chk("...and none of the console",
page.eval("getComputedStyle(document.getElementById('admin-main')).display") == "none")
def main():
ap = argparse.ArgumentParser(description="Work Package Suite front-end browser check")
ap.add_argument("--base-url", help="test an already-running server instead of starting one")
ap.add_argument("--keep-server", action="store_true",
help="leave the throwaway server and database up afterwards")
args = ap.parse_args()
exe = cdp.find_browser()
if not exe:
return abort("no headless-capable browser found.",
" Install Microsoft Edge or Google Chrome, or point WP_BROWSER at one.\n"
" Nothing was tested — this is not a failure of the app.")
print(f"\nWork Package Suite — front-end browser check\nBrowser: {exe}")
tmpdir = tempfile.mkdtemp(prefix="wpsuite-browser-check-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
if args.base_url:
base = args.base_url.rstrip("/")
else:
port = cdp.free_port()
base = f"http://127.0.0.1:{port}"
server = start_server(port, db_path)
if server is None:
return abort("the test server would not start.",
" Try: python -m uvicorn server.app:app --port 8000\n"
" and re-run with --base-url http://127.0.0.1:8000")
print(f"Target: {base}")
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok)
finally:
page.close()
browser.close()
except RuntimeError as e:
return abort(str(e))
finally:
if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}")
print(" Sign in as root / " + PW)
else:
if server:
# Wait for it to actually exit before deleting the database out from
# under it: on Windows the open SQLite file blocks the rmtree, and
# ignore_errors=True means that failure is silent — which is how six
# abandoned temp directories accumulated the first time round.
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
# seed() built an engine in THIS process too, and its pool holds the
# SQLite file open until disposed — the second reason a temp directory
# survived a run that reported success.
try:
from server.db import engine
engine.dispose()
except Exception:
pass
import shutil
for _ in range(10):
shutil.rmtree(tmpdir, ignore_errors=True)
if not os.path.exists(tmpdir):
break
time.sleep(0.3)
if os.path.exists(tmpdir):
print(f" note: could not remove {tmpdir} — delete it by hand")
total = len(_PASS) + len(_FAIL)
print(f"\n{'-' * 54}\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 — the pages boot and render as intended.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,329 +0,0 @@
"""Minimal Chrome DevTools Protocol client. Stdlib only — no pip, no Selenium.
Enough CDP to load a page in a headless browser as a signed-in user, capture any
JavaScript that failed, and interrogate the rendered DOM. Same no-dependency rule
as server/smoketest.py, for the same reason: these tools have to run on a plain
Python install on whatever machine is to hand.
The WebSocket bits are hand-rolled because there is no stdlib ws client and
http.client cannot upgrade: handshake, masked client frames out, unmasked in.
Used by tests/browser_check.py. Nothing in the app imports this.
"""
import base64
import json
import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import urllib.request
# Where to find a headless-capable browser. Edge ships with Windows, so it is
# first; Chrome is accepted too. WP_BROWSER overrides everything.
_CANDIDATES = [
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
"/usr/bin/microsoft-edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
]
def find_browser():
"""Path to a usable browser, or None. Check this before running: a missing
browser is 'could not run', not 'the app is broken'."""
env = os.getenv("WP_BROWSER")
if env:
return env if os.path.exists(env) else None
for p in _CANDIDATES:
if os.path.exists(p):
return p
for name in ("msedge", "google-chrome", "chromium", "chrome"):
found = shutil.which(name)
if found:
return found
return None
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class WS:
"""One WebSocket connection, speaking CDP's request/response + event mix."""
def __init__(self, url, timeout=25):
assert url.startswith("ws://"), url
hostport, _, path = url[5:].partition("/")
host, _, port = hostport.partition(":")
self.sock = socket.create_connection((host, int(port or 80)), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
self.sock.sendall((
f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n").encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise EOFError("handshake closed")
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
if b" 101 " not in head.split(b"\r\n")[0]:
raise RuntimeError("upgrade refused: " + head.decode(errors="replace")[:200])
self.buf = rest
self._id = 0
self.events = []
def _send_frame(self, payload: bytes):
mask = os.urandom(4)
n = len(payload)
h = bytearray([0x81])
if n < 126:
h.append(0x80 | n)
elif n < 1 << 16:
h.append(0x80 | 126); h += struct.pack(">H", n)
else:
h.append(0x80 | 127); h += struct.pack(">Q", n)
h += mask
self.sock.sendall(bytes(h) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def _read(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError("socket closed")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _recv_frame(self):
while True:
h = self._read(2)
op, ln = h[0] & 0x0F, h[1] & 0x7F
if ln == 126:
ln = struct.unpack(">H", self._read(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", self._read(8))[0]
data = self._read(ln)
if op == 1:
return json.loads(data.decode())
if op == 8:
raise EOFError("browser closed the connection")
if op == 9:
self._send_frame(b"") # ping -> pong
def call(self, method, params=None, timeout=25):
self._id += 1
mine = self._id
self._send_frame(json.dumps({"id": mine, "method": method,
"params": params or {}}).encode())
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._recv_frame()
if msg.get("id") == mine:
if "error" in msg:
raise RuntimeError(f"{method}: {msg['error']}")
return msg.get("result", {})
if "method" in msg:
self.events.append(msg)
raise TimeoutError(method)
def drain(self, seconds=0.4):
"""Collect pending events without blocking on a reply."""
end = time.time() + seconds
self.sock.settimeout(0.15)
try:
while time.time() < end:
try:
msg = self._recv_frame()
except (socket.timeout, TimeoutError):
break
if "method" in msg:
self.events.append(msg)
finally:
self.sock.settimeout(25)
def close(self):
try:
self.sock.close()
except OSError:
pass
class Browser:
"""A headless browser process and its debugging port.
Owns teardown, which is the fiddly part: a browser spawns a tree of renderer
and GPU processes, and killing the process we launched leaves the rest behind
(one careless run left 98 strays). So we kill the tree AND sweep anything still
holding our unique profile directory — matching on that path, never on the
process name, so a real browser the user has open is never touched.
"""
# Launching is occasionally flaky: the process we start can hand off to another
# instance and exit rc=0 without ever binding the port, especially if a previous
# run left processes behind. Retrying with a fresh profile and port clears it.
ATTEMPTS = 3
def __init__(self, exe=None, port=None):
self.exe = exe or find_browser()
if not self.exe:
raise RuntimeError("no headless-capable browser found (set WP_BROWSER)")
last = ""
for attempt in range(1, self.ATTEMPTS + 1):
self.port = port if (port and attempt == 1) else free_port()
self.profile = tempfile.mkdtemp(prefix="wpsuite-cdp-")
self.proc = subprocess.Popen(
[self.exe, "--headless=new", f"--remote-debugging-port={self.port}",
f"--user-data-dir={self.profile}", "--remote-allow-origins=*",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
"--disable-extensions", "--disable-sync",
"--window-size=1400,1000", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(160):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{self.port}/json/version", timeout=1) as r:
json.load(r)
return
except Exception:
if self.proc.poll() is not None:
last = f"exited rc={self.proc.returncode} without binding the port"
break
time.sleep(0.25)
else:
last = "never bound the debugging port"
self.close()
time.sleep(1.5) # let the old tree finish dying
raise RuntimeError(f"browser would not start after {self.ATTEMPTS} attempts ({last})")
def page(self):
return Page(self.port)
def close(self):
pid = self.proc.pid
try:
self.proc.kill()
except OSError:
pass
if sys.platform == "win32":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Sweep any orphan that still has our profile open. Scoped to the temp
# profile path, so it cannot match a browser window the user opened.
leaf = os.path.basename(self.profile)
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "
f"'*{leaf}*' }} | ForEach-Object {{ try {{ Stop-Process -Id "
"$_.ProcessId -Force -ErrorAction Stop } catch {} }"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
shutil.rmtree(self.profile, ignore_errors=True)
class Page:
"""One headless tab, with JS-error capture and a DOM query helper."""
def __init__(self, port):
self.ws = WS(self._page_ws(port))
for domain in ("Page.enable", "Runtime.enable", "Log.enable", "Network.enable"):
self.ws.call(domain)
@staticmethod
def _page_ws(port):
for _ in range(40):
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for t in json.load(r):
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
return t["webSocketDebuggerUrl"]
time.sleep(0.25)
raise RuntimeError("no page target")
def set_cookie(self, name, value, domain="127.0.0.1", path="/"):
self.ws.call("Network.setCookie", {"name": name, "value": value,
"domain": domain, "path": path})
def clear_cookies(self):
self.ws.call("Network.clearBrowserCookies")
def goto(self, url, wait_for=None, timeout=20):
"""Navigate, then poll `wait_for` (a JS expression) until it is truthy.
The pages fetch their own data after load, so waiting on the load event
alone races the thing under test."""
self.ws.events.clear()
self.ws.call("Page.navigate", {"url": url})
deadline = time.time() + timeout
while time.time() < deadline:
self.ws.drain(0.25)
if any(e["method"] == "Page.loadEventFired" for e in self.ws.events):
break
if wait_for:
while time.time() < deadline:
try:
if self.eval(wait_for) is True:
break
except Exception:
pass
self.ws.drain(0.2)
self.ws.drain(0.4)
return self
def eval(self, expr):
r = self.ws.call("Runtime.evaluate", {
"expression": expr, "returnByValue": True, "awaitPromise": True})
if "exceptionDetails" in r:
raise RuntimeError("JS threw: " + json.dumps(r["exceptionDetails"])[:300])
return r.get("result", {}).get("value")
def click(self, selector, settle=0.5):
self.eval(f"document.querySelector({selector!r}).click()")
time.sleep(settle)
self.ws.drain(0.2)
def key(self, name, settle=0.4):
self.eval(f"document.dispatchEvent(new KeyboardEvent('keydown',{{key:{name!r}}}))")
time.sleep(settle)
def js_errors(self):
"""Everything that means 'this page did not boot cleanly': uncaught
exceptions, console.error calls, and browser-logged errors.
Icon and manifest probes are ignored — they are not code faults. The URL is
kept in the message because a bare '404 (Not Found)' is undiagnosable, and
some log entries arrive with no url field at all."""
out = []
for e in self.ws.events:
m, p = e["method"], e.get("params", {})
if m == "Runtime.exceptionThrown":
d = p.get("exceptionDetails", {})
txt = d.get("exception", {}).get("description") or d.get("text", "")
out.append("uncaught: " + str(txt).split("\n")[0])
elif m == "Runtime.consoleAPICalled" and p.get("type") == "error":
bits = " ".join(str(a.get("value", a.get("description", "")))
for a in p.get("args", []))
out.append("console.error: " + bits[:200])
elif m == "Log.entryAdded":
entry = p.get("entry", {})
if entry.get("level") != "error":
continue
url, text = entry.get("url", "") or "", str(entry.get("text", ""))
if any(s in url or s in text
for s in ("favicon", "manifest.webmanifest", "icon-")):
continue
out.append(f"log: {text[:160]}" + (f" [{url}]" if url else " [no url]"))
return out
def close(self):
self.ws.close()