Compare commits
59 Commits
b44afa7672
...
feat/waves
| Author | SHA1 | Date | |
|---|---|---|---|
| 2842ec996c | |||
| b2a083ac7c | |||
| 0b5ab59518 | |||
| a6c3fbfe50 | |||
| 2de76d52e6 | |||
| 8e863ae7d0 | |||
| 6cde6e3f60 | |||
| 358469531c | |||
| 850b78972b | |||
| 0652fa732d | |||
| 75ac930d0c | |||
| df20b8f18d | |||
| cc64c88c3e | |||
| dc13f9b0e3 | |||
| d6eae0d846 | |||
| d7d1e93dd8 | |||
| f023192b74 | |||
| 78d1942a0e | |||
| 45aff9c423 | |||
| 290c9b078c | |||
| 72b10283fc | |||
| 77f8f9f800 | |||
| 73da684b99 | |||
| c74289aa0d | |||
| 7ed3cbec4c | |||
| a9e5ee3892 | |||
| 0ee35ae4ed | |||
| 044862acba | |||
| 31c548318b | |||
| 8f117680b0 | |||
| 6034c08bad | |||
| 17cabbd032 | |||
| 222c0b1c29 | |||
| e31234beef | |||
| 6057d05b98 | |||
| 64eac0cbbb | |||
| 8f280d4bd1 | |||
| a8e28bf3ab | |||
| 29c4cd313e | |||
| 8fe7b25cd8 | |||
| 560f0cb3cc | |||
| 24f60151e5 | |||
| 031dc6b995 | |||
| 0f28a27441 | |||
| 16afc56c0a | |||
| 8efe624d5d | |||
| 24476c86a6 | |||
| cc761c8f7d | |||
| fb89b1f6e1 | |||
| 8663d81af3 | |||
| 8cf8c0f882 | |||
| 2a5f6b3549 | |||
| 454bfa0fe1 | |||
| 23ee0b052f | |||
| 771672273d | |||
| 6201fcfb4a | |||
| 0dcea8d725 | |||
| b83f2fd8d5 | |||
| 7ef1fcdd96 |
@@ -93,7 +93,7 @@ checks. In addition, for any task touching the frontend:
|
||||
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
||||
gloved-hands surface and is where the worst rendering was found.
|
||||
3. Capture before and after screenshots into the PR.
|
||||
4. Run the existing smoke test. It signs in; `server/seed_demo.py` does not, which is S13.
|
||||
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
|
||||
|
||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||
failed and why.
|
||||
|
||||
355
DEPLOY-runbook-2026-09-03.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Deploy runbook: WP Suite Okta cutover (wave 10)
|
||||
|
||||
**For:** IT / whoever administers the Docker host and Portainer
|
||||
**From:** m.mabrey@prime-controls.com
|
||||
**Revised:** 2026-09-03. First version of this runbook.
|
||||
**Expected duration:** 20-30 minutes, including the backup and the live sign-in check
|
||||
**Expected downtime:** under a minute, while containers are recreated
|
||||
|
||||
This is a separate runbook from `DEPLOY-runbook-2026-08-04.md`, not a revision of it.
|
||||
That one still applies to its own deploy. Read this one in full before starting.
|
||||
It has a step that the earlier one does not: this deploy removes the local password
|
||||
system entirely, and one part of that removal cannot be undone by the usual means.
|
||||
See "What this deploy changes" below.
|
||||
|
||||
---
|
||||
|
||||
## 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/`) | `________________` |
|
||||
| A real Okta account assigned to the app integration, for the live check in Step 4 | `________________` |
|
||||
|
||||
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
|
||||
|
||||
Sign-in changes from a local username/password to Okta OIDC, completely. Not a
|
||||
toggle, not a fallback. Three things make this more than a routine deploy:
|
||||
|
||||
1. **Five new environment variables are required**, and without them nobody can
|
||||
sign in: `OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`,
|
||||
`OKTA_REDIRECT_URI`, and optionally `OKTA_IDENTITY_CLAIM`. This is a genuine
|
||||
change from how deploys usually go here. Do not skip Step 1.5.
|
||||
|
||||
2. **A migration drops the `password_hash` column** (`1d60a608bb51`), and it is
|
||||
**one-way in practice.** Its `downgrade()` re-adds the column, but empty.
|
||||
The real password hashes are gone the moment this commits, and no `alembic
|
||||
downgrade` brings them back. If this deploy needs to be undone after that
|
||||
point, going back to the old local-password code does not work on its own.
|
||||
See the Rollback section, Case C. This is why Step 1's backup is not
|
||||
optional the way it sometimes reads in other runbooks.
|
||||
|
||||
3. **There is no break-glass path**, by design (recorded decision D16). If Okta
|
||||
is unreachable or misconfigured after this deploy, the app is unreachable for
|
||||
everyone, admins included, until Okta is fixed. That is expected behavior,
|
||||
not a bug to roll back from. See Rollback, Case A/B, before assuming
|
||||
something is broken.
|
||||
|
||||
The visible change for people using the app: the login page becomes "Sign in
|
||||
with Okta" instead of a username/password form. Nothing else in the app's
|
||||
day-to-day behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Expected output of the first command before this deploy: `a1b8c6d4e2f9 (head)`.
|
||||
If it shows anything else, stop and check with me before continuing. This
|
||||
runbook assumes that starting point.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Back up the database
|
||||
|
||||
On the Docker host:
|
||||
|
||||
```bash
|
||||
docker exec wp_db_backup /scripts/db-backup.sh
|
||||
```
|
||||
|
||||
Expected output ends with a line like:
|
||||
|
||||
```
|
||||
[db-backup] wrote 1.4M /backups/wpsuite-20260903-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.
|
||||
|
||||
This backup matters more than usual for this deploy. Once the migration in
|
||||
Step 3 commits, this file becomes the *only* way to get local password hashes
|
||||
back, for any reason. Treat it as the point you would restore to, not routine
|
||||
housekeeping.
|
||||
|
||||
- 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 1.5: Confirm the Okta app integration is actually ready
|
||||
|
||||
Do this before redeploying, not after. Everything here is checked in Okta's own
|
||||
admin console and in the values that will go into the stack's environment
|
||||
variables. Nothing touches the WP Suite host yet.
|
||||
|
||||
1. The Okta app integration exists (Sign-in method: OIDC, Authorization Code,
|
||||
Application type: Web Application), and the account listed in the fill-in
|
||||
table above is assigned to it.
|
||||
2. `OKTA_REDIRECT_URI` matches a "Sign-in redirect URI" registered on that app
|
||||
integration **exactly**: scheme, host, and path, including whether it ends
|
||||
in `/api/auth/okta/callback`.
|
||||
3. `OKTA_ISSUER`, `OKTA_CLIENT_ID`, and `OKTA_CLIENT_SECRET` are the values from
|
||||
that same app integration, not a different one.
|
||||
4. If your Okta configuration puts the directory identity somewhere other than
|
||||
the `preferred_username` claim, `OKTA_IDENTITY_CLAIM` is set to the right
|
||||
claim name. If unsure, leave it unset; `preferred_username` is the default.
|
||||
|
||||
Add all five to the stack's **Environment variables** in Portainer now, before
|
||||
Step 2. `OKTA_CLIENT_SECRET` should be handled the same way `AUTH_SECRET_KEY`
|
||||
already is: not typed anywhere it will be logged.
|
||||
|
||||
If any of items 1-3 above are not yet confirmed, stop here and get them
|
||||
confirmed first. A wrong redirect URI or an unassigned account will not corrupt
|
||||
anything, but it does mean nobody signs in after this deploy until it is fixed.
|
||||
See Rollback, Case B, which is the ordinary way that gets fixed and does not
|
||||
involve the database at all.
|
||||
|
||||
---
|
||||
|
||||
## 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 pick up new code, and will not pick
|
||||
up the environment variables added in Step 1.5 either.
|
||||
|
||||
---
|
||||
|
||||
## 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 migration cleanly:
|
||||
|
||||
```bash
|
||||
docker logs wp_api --tail 40
|
||||
```
|
||||
|
||||
You are looking for an Alembic `Running upgrade a1b8c6d4e2f9 -> 1d60a608bb51`
|
||||
line followed by gunicorn starting up, and no traceback. The API refuses to
|
||||
start if a migration fails, so a restarting `wp_api` container means it failed.
|
||||
Go to Rollback, Case B, and read the "did the migration commit" note there
|
||||
before doing anything to the database.
|
||||
|
||||
Confirm the database landed on the new revision:
|
||||
|
||||
```bash
|
||||
docker exec wp_api alembic -c server/alembic.ini current
|
||||
```
|
||||
|
||||
Expected: `1d60a608bb51 (head)`. **Once you see this, you have passed the
|
||||
point of no return described above.** The backup from Step 1 is now the only
|
||||
way back to a working local-password system, if that is ever needed.
|
||||
|
||||
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 Okta sign-in actually works, live
|
||||
|
||||
This is the step that matters most for this deploy. A clean container start
|
||||
does not by itself prove sign-in works, and there is currently no startup log
|
||||
line that confirms Okta config is good (logged separately as a follow-up, not
|
||||
fixed as part of this runbook). The only real proof is a live sign-in.
|
||||
|
||||
1. Open the site's normal URL in a private/incognito window. It should land on
|
||||
`login.html` with a "Sign in with Okta" button, not a username/password
|
||||
form.
|
||||
2. Click it. You should be redirected to your organization's actual Okta
|
||||
sign-in page (the real Okta domain from `OKTA_ISSUER`, not this app's own
|
||||
domain).
|
||||
3. Sign in with the account from the fill-in table. You should land back on
|
||||
the WP Suite site, signed in.
|
||||
4. If this is the account's first-ever sign-in, it is now JIT-provisioned as a
|
||||
regular user (`project_user`). To make it an admin, on the Docker host:
|
||||
|
||||
```bash
|
||||
docker exec -it wp_api python -m server.manage_users promote <username> --role admin
|
||||
```
|
||||
|
||||
This only works on an account that has already signed in once through Okta.
|
||||
It promotes an existing row; it does not create one. That is deliberate
|
||||
(recorded decision D16): there is no other admin-bootstrap path.
|
||||
|
||||
If step 2 or 3 fails (redirected to an Okta error page, redirected back to
|
||||
`login.html` with an error, or nothing happens), this is almost always a
|
||||
configuration problem from Step 1.5, not a code or database problem. Go to
|
||||
Rollback, Case B, before considering anything more drastic.
|
||||
|
||||
Also confirm the API is reachable through the proxy and the redirect itself is
|
||||
wired up:
|
||||
|
||||
```bash
|
||||
curl -s https://<site-url>/api/health # -> {"ok": true}
|
||||
curl -sI https://<site-url>/api/auth/okta/login | grep -i ^location # -> your Okta authorize URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Hard-reload once in a browser, then sanity-check
|
||||
|
||||
Press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service
|
||||
worker; a normal reload can serve the previous version.
|
||||
|
||||
1. Signed in as the account from Step 4, the home page offers to select or
|
||||
create a project, same as before.
|
||||
2. Open **Admin Console** as the promoted admin account. The user table shows
|
||||
the account you just signed in with. There is no password column, no
|
||||
"reset password" action anywhere in the UI.
|
||||
3. Open a project and confirm a work package can be opened and edited
|
||||
normally. Sign-in is the only thing this deploy changes, so the rest of
|
||||
the app should look untouched.
|
||||
|
||||
**Deploy complete.** Please report back: the Step 0 output, the backup
|
||||
filename from Step 1, and confirmation that Step 4's live sign-in worked.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Read this before assuming a rollback is needed. Cases A and B below do **not**
|
||||
touch the database and are the far more likely outcome of something going
|
||||
wrong here. Okta configuration is fiddly and easy to get slightly wrong. Case
|
||||
C is the severe, destructive one, and should be a last resort, not a first
|
||||
reaction.
|
||||
|
||||
### Case A: nginx won't start, or containers won't come up at all
|
||||
|
||||
Same as any other deploy: the database is untouched by container start-up
|
||||
failures. In Portainer, redeploy the stack pinned to the **previous Git
|
||||
commit** recorded in Step 0, then re-run Step 3.
|
||||
|
||||
```bash
|
||||
docker logs nginx_webserver --tail 100
|
||||
docker logs wp_api --tail 100
|
||||
```
|
||||
|
||||
Send me whichever of those is relevant.
|
||||
|
||||
### Case B: containers are up, but Okta sign-in doesn't work
|
||||
|
||||
This is a configuration problem, not a data problem, and does **not** need a
|
||||
code rollback or a database restore. Check, in order:
|
||||
|
||||
1. Is `wp_api`'s log showing anything at all when a sign-in is attempted?
|
||||
`docker logs wp_api --tail 100`.
|
||||
2. Do the five `OKTA_*` values in the stack's environment variables actually
|
||||
match the Okta app integration (Step 1.5)? A copy-paste error in
|
||||
`OKTA_CLIENT_SECRET` or a redirect URI that's off by a trailing slash are
|
||||
the two most common causes.
|
||||
3. Is the account assigned to the Okta app integration? An unassigned account
|
||||
gets denied by Okta itself, before it ever reaches this app.
|
||||
4. If a specific person can't sign in but others can, check
|
||||
`OKTA_IDENTITY_CLAIM`. The claim it reads may not carry that person's
|
||||
directory identity in the format expected. Confirm with security which
|
||||
claim Okta is actually issuing.
|
||||
|
||||
Fix the environment variable(s) in Portainer, then redeploy (Pull and redeploy
|
||||
is fine; the migration already applied and does not run again). No backup
|
||||
restore, no code rollback.
|
||||
|
||||
If it's still not working after checking all four, send me the `wp_api` log
|
||||
from item 1 along with which of items 2-4 you already ruled out.
|
||||
|
||||
### Case C: the decision is made to abandon Okta and restore local-password sign-in
|
||||
|
||||
This is the case the point-of-no-return warning in "What this deploy changes"
|
||||
is about. Only reach for this if Case A and B do not apply. That is, Okta
|
||||
itself is working correctly but a decision has been made to go back to the old
|
||||
system entirely.
|
||||
|
||||
**This cannot be done with a code rollback alone.** The old code expects a
|
||||
real `password_hash` on every user row. After Step 3 commits, that column is
|
||||
either gone or (if `alembic downgrade` is run) present but empty. Either way,
|
||||
nobody's stored password survived, including admins'. The only way to get a
|
||||
working local-password system back is to restore the full database from the
|
||||
Step 1 backup, which also rolls back every other change made since that
|
||||
backup: new work packages, comments, uploaded files, everything.
|
||||
|
||||
**Do not do this without confirming with me first.** If it is confirmed, 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`. After restoring, redeploy pinned to the Git commit recorded in
|
||||
Step 0, since the restored database matches the old schema, not this one.
|
||||
|
||||
Reach me at m.mabrey@prime-controls.com.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not run `docker compose down -v`. The `-v` flag deletes the `pgdata`
|
||||
volume and with it the entire database.
|
||||
- `docker exec <container-name>` is used throughout rather than
|
||||
`docker compose ...`, because a Portainer-managed Git stack's compose project
|
||||
lives under Portainer's own data directory and usually isn't reachable from
|
||||
an ad hoc SSH session the same way.
|
||||
- There is currently no startup log line confirming Okta config is valid
|
||||
(`okta_auth.describe()` exists but nothing calls it yet, logged as a
|
||||
follow-up). Step 4's live sign-in is the real verification until that's
|
||||
wired in.
|
||||
- Full background documentation: `DEPLOYMENT.md` and `server/README.md` in the
|
||||
repository. `docs/waves/decisions-2026-09-03.md` (D16, D17) records why
|
||||
there is no break-glass path and why this runbook exists as its own document.
|
||||
145
DEPLOYMENT.md
@@ -52,27 +52,53 @@ POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
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:
|
||||
# REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone
|
||||
# is. 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>
|
||||
|
||||
# OPTIONAL — D18 (2026-09-23): a session slides on activity (AUTH_IDLE_MINUTES,
|
||||
# default 30) capped by a hard ceiling from original sign-in regardless of
|
||||
# activity (AUTH_SESSION_HOURS, default 8). Both are proposed defaults, not
|
||||
# confirmed against this tenant's Okta SSO session policy — if Okta's own
|
||||
# session outlives either one, re-auth here is likely a fast silent redirect
|
||||
# rather than a real login screen. Full explanation in server/.env.example.
|
||||
# AUTH_IDLE_MINUTES=30
|
||||
# AUTH_SESSION_HOURS=8
|
||||
|
||||
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
|
||||
# path (D15/D16). There is no local password anywhere in this app to fall back
|
||||
# to, so without these nobody can sign in at all. Get them from the Okta app
|
||||
# integration (sign-in method OIDC - Authorization Code, Web Application):
|
||||
#
|
||||
# OKTA_ISSUER the authorization server, e.g.
|
||||
# https://your-org.okta.com/oauth2/default
|
||||
# OKTA_CLIENT_ID \_ from the app integration
|
||||
# OKTA_CLIENT_SECRET /
|
||||
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
|
||||
# registered on the app integration, e.g.
|
||||
# https://wp-suite.company.local/api/auth/okta/callback
|
||||
#
|
||||
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
|
||||
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
|
||||
# the API starts without these, it just refuses every sign-in and says so in
|
||||
# `docker compose logs api` (server/okta_auth.py's describe()).
|
||||
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
|
||||
OKTA_CLIENT_ID=<from the Okta app integration>
|
||||
OKTA_CLIENT_SECRET=<from the Okta app integration>
|
||||
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
|
||||
|
||||
# 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.
|
||||
# OPTIONAL — SMTP password for WP-assignment email. 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_*`
|
||||
@@ -87,6 +113,7 @@ Generate a strong password with `openssl rand -base64 32`.
|
||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` /
|
||||
> `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
|
||||
@@ -151,25 +178,34 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
|
||||
archive round trip → cascade cleanup → sign-out). 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.
|
||||
session — but there is no local password to sign in with (D15/D16), and Okta
|
||||
requires a real browser to complete, which this script cannot do. So instead
|
||||
of an HTTP login, it mints a session directly the same way `okta_callback()`
|
||||
does after Okta hands back an identity, which means **it has to run somewhere
|
||||
that can read the same `AUTH_SECRET_KEY` and reach the same database as the
|
||||
server under test** — inside the `api` container, or local dev against your
|
||||
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
|
||||
workstation the way the old password-based version could.
|
||||
|
||||
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
|
||||
through Okta once first, or pre-create it from the User Directory — and must
|
||||
be an **admin**: the run creates a project and deletes it again, and 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
|
||||
export WP_SMOKE_USER=<admin-account>
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
|
||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
# From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and
|
||||
# hits FastAPI directly. This is the normal way to run it in production:
|
||||
docker compose exec -e WP_SMOKE_USER api \
|
||||
python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Local dev, against the app you're running yourself:
|
||||
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
|
||||
python3 server/smoketest.py http://localhost:8000
|
||||
|
||||
# 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.
|
||||
# --user overrides $WP_SMOKE_USER if you'd rather be explicit.
|
||||
```
|
||||
|
||||
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
|
||||
@@ -257,7 +293,7 @@ users on the same project see the same server-stored SOP and Work Packages.
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` |
|
||||
| `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) |
|
||||
@@ -275,8 +311,9 @@ 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`,
|
||||
`GET /api/wps/metrics` ·
|
||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
|
||||
Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip),
|
||||
`POST /api/auth/logout`, `GET /api/auth/me`, admin user management 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`.
|
||||
@@ -350,27 +387,31 @@ TLS / From address and flips the master toggle.
|
||||
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
|
||||
### Sign-in and admin bootstrap (Okta)
|
||||
|
||||
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).
|
||||
There is no local password anywhere in this app — no "Forgot password," no
|
||||
reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above
|
||||
is purely for WP-assignment notification emails.
|
||||
|
||||
- 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.
|
||||
Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access
|
||||
control is **who is assigned to the app integration in Okta** — see step 2's
|
||||
`OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for
|
||||
the full flow. The first admin has to sign in through Okta once (landing as an
|
||||
ordinary `project_user`, auto-provisioned), then get promoted from a shell:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users promote alice --role admin
|
||||
```
|
||||
|
||||
This is deliberate, not an oversight: a hand-typed username at account-creation
|
||||
time risks a second, orphaned row if it doesn't exactly match what Okta sends,
|
||||
so the CLI promotes an existing Okta-provisioned row rather than creating one
|
||||
blind (D16). Every admin after the first can be promoted from the User
|
||||
Directory page — no shell access needed.
|
||||
|
||||
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
|
||||
unreachable for everyone, including admins, until Okta is restored — the same
|
||||
posture the abandoned LDAPS design took, carried forward deliberately (D16).
|
||||
|
||||
## Permissions roles
|
||||
|
||||
@@ -382,7 +423,7 @@ 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_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, 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 |
|
||||
|
||||
@@ -399,9 +440,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
|
||||
* **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
|
||||
* **Account changes need EXCLUSIVE scope.** 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
|
||||
|
||||
@@ -12,6 +12,7 @@ Close an entry by deleting it in the same commit that fixes it.
|
||||
|---|-------|----------|--------|--------|
|
||||
| 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 |
|
||||
| 3 | Export is not one merged PDF; drawings ride along as a list | Low | 2026-08-20 | Open — decided |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,3 +162,35 @@ save/issue controls, or add a boot check in each app that disables them and show
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Export is not one merged PDF; drawings ride along as a list
|
||||
|
||||
**Files:** `html/wp-creation-app.js` (the T9.1 export walk), `CR-008`
|
||||
**Decided:** 2026-08-20, by Nick — "add this to known issues."
|
||||
|
||||
### What is wrong
|
||||
|
||||
CR-008 asked for the work package "as one document." What shipped (T9.1)
|
||||
renders every section inline — including images — and lists PDF drawing
|
||||
attachments with links, rather than merging their pages into a single PDF.
|
||||
|
||||
### What it costs
|
||||
|
||||
A crew printing the package gets the form and the inline images in one pass,
|
||||
but linked PDF drawings are separate opens/prints. For field hand-offs that
|
||||
want literally one file, someone stitches it manually.
|
||||
|
||||
### Why it is still open
|
||||
|
||||
Real PDF merging needs either a server-side PDF library (a new dependency and
|
||||
a render pipeline for arbitrary uploaded PDFs) or a client-side one (heavy,
|
||||
and the creator is deliberately dependency-free). The recommendation made at
|
||||
T9.1 — inline images + listed PDFs — was accepted as the shipped behaviour.
|
||||
|
||||
### What closing it takes
|
||||
|
||||
A server-side merge endpoint (e.g. pypdf) that concatenates the rendered
|
||||
package with each attached PDF, streamed back as one download; plus a size
|
||||
ceiling consistent with D8's upload limits. One task, one new dependency.
|
||||
|
||||
@@ -27,20 +27,48 @@ services:
|
||||
POSTGRES_HOST: db
|
||||
# Optional full-URL override (must be URL-encoded if used).
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
||||
# and the API refuses to start in production without it (see server/auth.py).
|
||||
# Signs login session cookies, AFTER Okta has confirmed who someone is.
|
||||
# REQUIRED — compose fails fast if it's unset, and the API refuses to
|
||||
# start in production without it (see server/auth.py).
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
|
||||
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
|
||||
# every sign-in and says so in the startup log (server/okta_auth.py
|
||||
# describe()). See server/.env.example for what each one is and how to
|
||||
# get it from the Okta app integration.
|
||||
OKTA_ISSUER: ${OKTA_ISSUER:-}
|
||||
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
|
||||
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
|
||||
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
|
||||
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
|
||||
# applies when the env var is UNSET, and compose setting it to an empty
|
||||
# string here is not the same thing as leaving it unset. An empty value
|
||||
# would make the API look for a claim literally named "", which is
|
||||
# never present, so EVERY sign-in would 503. Mirror the same default
|
||||
# here instead, so an operator who leaves .env's copy commented out gets
|
||||
# the real default, not a broken one.
|
||||
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
|
||||
# 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:-}
|
||||
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
||||
# which backs the asset picker in the work package creator. Leave unset and
|
||||
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
||||
# Use a db_datareader login: the app only ever SELECTs.
|
||||
MICRON_DB_URL: ${MICRON_DB_URL:-}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
# Reaching the Micron database means leaving this compose project, and
|
||||
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
||||
# container ONLY — the database and backup containers stay sealed. Detach it
|
||||
# again if you are not using the Micron asset picker.
|
||||
- outbound
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
@@ -98,4 +126,12 @@ networks:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
internal: true # no route off the host for anything on this network alone
|
||||
outbound:
|
||||
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
||||
# above removes the gateway entirely, which blocks not just the internet but
|
||||
# the LAN and the VPN too — so the api container needs this second network to
|
||||
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
||||
# remain on `internal` only and still have no way off the host.
|
||||
# Detach it from api if you are not using the Micron asset picker.
|
||||
driver: bridge
|
||||
73
docs/reference/accessibility-audit.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Accessibility audit — C1 + S8 (T9.5, 2026-08-19)
|
||||
|
||||
Approved Aug 14 2026 (C1): any component rebuilt ships accessible or it is not
|
||||
done. This document records the audit at the end of wave 9 against the wave 0
|
||||
baseline, per CLAUDE.md's rules. Every number below is re-measured by a probe
|
||||
on every run — the citations name which one.
|
||||
|
||||
## The metrics
|
||||
|
||||
| Metric | Wave 0 baseline | Now | Target | Verified by |
|
||||
|---|---|---|---|---|
|
||||
| `<div>` / `<span>` with `onclick` | 12 / 2 | **0** | 0 | `helptip_check.py` (grep, comments stripped) |
|
||||
| `.help-tip` unreachable by keyboard | 15 (18 by wave 6) — re-measured at T9.5 start: **20** | **0** | 0 | `helptip_check.py` (driven with real keys and taps) |
|
||||
| `aria-live` regions | 0 | ≥1 per toast system and banner (login, both toasts, release banner, autosave indicator, list-import reports, field toast) | ≥1 each | `a11y_check.py`, `warning_check.py`, `creator_dialogs_check.py` |
|
||||
| Text below 4.5:1 | present | none found on the audited surfaces | 0 | `a11y_check.py` (creator sweep), `frame_check.py` BL-013 note |
|
||||
| `outline: none` without replacement | present | **0** (grep with replacement detection) | 0 | `helptip_check.py` |
|
||||
| Native dialogs | 79 | **21** | 0 or documented | `creator_dialogs_check.py` prints the count; see the gap below |
|
||||
|
||||
**The count went up before it went down, exactly as the task predicted:** the
|
||||
wave 6 exit counted 18 unreachable help-tips; at the start of T9.5 there were
|
||||
**20** (T6.x and wave 7/8 tasks reused the component as designed). All 20 are
|
||||
buttons now — the fix is in the component (`help.js` upgrades every badge at
|
||||
load and exposes `helpTipUpgrade()` for late renders), so a badge added
|
||||
tomorrow is born reachable.
|
||||
|
||||
## The documented gap — 21 native dialogs
|
||||
|
||||
`admin.js` (6), `users.js` (10), `index.html` (5). These are the operator
|
||||
consoles and the launcher — surfaces **no S1 task ever named** (S1's two
|
||||
halves were the wizard, T5.8, and the creator, T7.9; both measure 0). They are
|
||||
admin-only or low-frequency flows, every one a genuine confirm-before-destroy.
|
||||
Logged as **BL-024** for conversion to the T7.9 dialog kit rather than done
|
||||
here: converting three more pages inside the audit task is the drive-by
|
||||
CLAUDE.md forbids.
|
||||
|
||||
## The help-tip component (S8)
|
||||
|
||||
- The badge is a `<button>` with `aria-label`, `aria-expanded`, and a
|
||||
`:focus-visible` ring from the shared `--cds-focus` token.
|
||||
- The tooltip is one `role="tooltip"` bubble, viewport-clamped on both axes —
|
||||
which also ended BL-001: the old CSS `::after` escaping its badge was the
|
||||
creator's last 390px overflow.
|
||||
- Paths: keyboard (focus shows, Escape hides), touch (tap toggles, tap
|
||||
elsewhere closes), pointer (hover shows). Driven at 390px by
|
||||
`helptip_check.py`.
|
||||
- The injected styles now use theme tokens; the block previously carried four
|
||||
raw hexes of the kind S5 counted.
|
||||
|
||||
## Keyboard-only primary flow
|
||||
|
||||
Sign in → pick a project → SOP wizard → create a work package → issue it.
|
||||
Covered by probes that dispatch **real CDP key events** (synthetic
|
||||
`KeyboardEvent`s never reach native activation — the wave 5 lesson, recorded
|
||||
in `form_structure_check.py`):
|
||||
|
||||
| Leg | Probe |
|
||||
|---|---|
|
||||
| Sign in | `server/smoketest.py` (form submit), `login.html` roles verified in `a11y_check.py` |
|
||||
| Launcher → project | `launcher_check.py` (B3, keyboard section) |
|
||||
| SOP wizard steps | `stepper_check.py` (A4/S9: ten real buttons, keyboard operable) |
|
||||
| Creator sections + save | `form_structure_check.py` §7 (Tab/Enter/Space on rail and headings), `creator_dialogs_check.py` (validation focus order) |
|
||||
| Issue | `hold_check.py` (the status control end to end) |
|
||||
|
||||
## Per-page results
|
||||
|
||||
| Page | Interactive elements | Announcements | Focus | Notes |
|
||||
|---|---|---|---|---|
|
||||
| login.html | native form controls | `role="alert"`/`role="status"` (the app's reference pattern) | visible | the pattern every other page copies |
|
||||
| index.html (launcher) | buttons/links | status line announced | visible | 5 native dialogs → BL-024 |
|
||||
| work-package-suite.html (wizard) | 0 div/span handlers; library entries are buttons (T9.5) | `wp-toast` role-differentiated | T3.4 ring | 0 native dialogs |
|
||||
| wp-creation-index.html (creator) | 0 div/span handlers; chips are buttons (T9.5) | toast + release banner + field errors, all live regions | ring on all 120+ focusables (`a11y_check`) | 0 native dialogs |
|
||||
| field.html | buttons throughout, 44px targets | `role="status"`/`alert` toast | visible | offline drawings reachable (files_check) |
|
||||
| admin.html / users.html | buttons | banners | visible | 16 native dialogs → BL-024 |
|
||||
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 260 KiB |
BIN
docs/reference/baseline/after-wave9/admin-390.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
docs/reference/baseline/after-wave9/creator-390.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
docs/reference/baseline/after-wave9/field-390.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/reference/baseline/after-wave9/launcher-390.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/reference/baseline/after-wave9/login-390.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/reference/baseline/after-wave9/sop-390.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/reference/baseline/after-wave9/users-390.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 98 KiB |
115
docs/reference/completion.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Completion — the R2 plan, reconciled (T9.7, 2026-08-19)
|
||||
|
||||
All 65 items accounted for: the 55 in `IMPLEMENTATION.md` §6 and the 10 in
|
||||
`docs/waves/decisions-2026-08-18.md`. Delivery references are task ids; the
|
||||
branch is local-only by instruction (one task = one commit, ids in every
|
||||
commit message), so the commit history IS the PR trail. Verification counts
|
||||
name the probe that re-checks the item on every run.
|
||||
|
||||
## Change requests
|
||||
|
||||
| Item | Status | Delivered by | Notes / deviations |
|
||||
|---|---|---|---|
|
||||
| CR-001 | built | T6.1 (`generalinfo_check` 49) | |
|
||||
| CR-002 | built | waves 1–2, field toggles T5.7; **applied to the export at T9.1** | the Micron samples now name `costCode:false, acumaticaTask:false` |
|
||||
| CR-003 | built | T6.2 | three priorities, escalating order |
|
||||
| CR-004 | built | T6.3 (`locations_check`, `rollup_check`) | paths, not labels |
|
||||
| CR-005 | built | T5.4 | upload path; **B100 list still not supplied** (§8) |
|
||||
| CR-006 | built | T5.7 (`sections_check` 95) | hidden, never deleted — pinned |
|
||||
| CR-007 | built | T7.7 (`files_check` 36) | D8 numbers enforced server-side; offline verified against a killed server |
|
||||
| CR-008 | built | T9.1 (`export_check` 20) | **deviation raised, not decided:** merge-vs-list — recommendation is inline images + listed PDFs; merged-PDF output needs Nick |
|
||||
| CR-009 | built | T8.1 (`kitting_check`) | statuses adopted as proposed; kitting off for Micron EUV by toggle |
|
||||
| CR-010 | built | T8.2 | owner on the PACKAGE (confirmed Aug 18), account-backed, orphan-safe |
|
||||
| CR-011 | built | T8.3 (`kitting_notify_check` 17) | coalesced; T7.6 gate reused; sink-verified |
|
||||
| CR-012 | built | T8.4 | shared location lists + detail field; prints and mails |
|
||||
| CR-013 | built | T8.5 (`mreq_check` 19) | lightweight scope exactly; fences grepped |
|
||||
| CR-014 | built | T7.6 (`qa_gate_check` 40) | **deviation, stated in the commit:** the email body omits location/scope — the done-when's no-customer-IP rule outranked the Do-paragraph; fuller body needs Nick |
|
||||
| CR-015 | built | T7.3 (`hold_check` 50) | root cause stated (both halves); regression test specific to clear-last-constraint |
|
||||
| CR-016 | built | T5.7 | |
|
||||
| CR-017 | guarded | T9.2 | present, optional, rolls up; BL-023 logs the productivity factor |
|
||||
| CR-018 | built | T6.4 (`rollup_check` 63) | |
|
||||
|
||||
## Findings and structural items
|
||||
|
||||
| Item | Status | Delivered by | Notes |
|
||||
|---|---|---|---|
|
||||
| F1–F5 | built | waves 1–3 | |
|
||||
| F6 | built, one number open | T7.2 (5,399 → 1,954px) | **BL-022:** 2.17 screens vs the strict 2.0 encoding of “roughly two” — product call, check stays red |
|
||||
| S1 | built | T5.8 wizard, T7.9 creator (`creator_dialogs_check` 20) | 79 → 21 dialogs; the 21 live on surfaces no S1 task named — **BL-024** |
|
||||
| S2–S5, S9–S12 | built | waves 2–5 | |
|
||||
| S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` |
|
||||
| S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project |
|
||||
| S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** |
|
||||
| S13 | built | T1.6 (re-verified 2026-08-20) | this row said "open / does not sign in" until Aug 20 - a records error: T1.6 fixed it in wave 1 (it reuses smoketest's login) and the wave-1 exit box was simply never ticked. Verified live: sign-in, seed, `--clean` |
|
||||
| A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract |
|
||||
| A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail |
|
||||
| A3–A5, A7 | built | waves 1–6 | localization re-verified through T7.10's admin edits (`cards_check`) |
|
||||
| A6 | built | T7.5 (`triage_check` 16) | |
|
||||
| B1–B5 | built | waves 2–5 | |
|
||||
| B6 | built | T7.8 (`sticky_bar_check` 12) | |
|
||||
| B7 | built | T7.1 (`frame_check` 38) | **deviation, stated in the commit:** the creator became its own page rather than merging into the parent — measured trade (0 collisions vs 21+9) |
|
||||
| C1 | audited | T9.5 (`accessibility-audit.md`) | every metric probe-backed |
|
||||
| C2 | audited | T9.6 (`mobile_check` 24) | screenshots committed beside the wave 0 baseline |
|
||||
| C3 | built | wave 3 | |
|
||||
| C4 | built | wave 4 interim, T9.9 full (`color_check` 4) | zero literals outside `theme-light.css` |
|
||||
|
||||
## The August 18 decisions
|
||||
|
||||
| Item | Status | Delivered by |
|
||||
|---|---|---|
|
||||
| D1 | built | T7.1 (sample controls visible; consolidated at T9.4 per the S7 reconciliation) |
|
||||
| D2 | built | T7.6 (QA group on the SOP wizard) |
|
||||
| D3 | built | T7.2 (rail + collapse; the "at rest" amendment recorded) |
|
||||
| D4 | built | T7.3 (Urgent surfaces the audited path; the override names what it crosses) |
|
||||
| D5 | built | T7.10 (one analytics core; admin report) |
|
||||
| D6 | built | T8.6 (material list, the CR-005 pattern, one shared component) |
|
||||
| D7 | built | T9.8 (`archived_check` 15) |
|
||||
| D8 | built | T7.7 (5MB / PDF+image / 2GB, 80% warning) |
|
||||
| D9 | built | T7.6 (Field View text pill at 390px) |
|
||||
| D10 | built | T7.6 / reused T8.3 (stored setting, admin-only, audited, sink-verified) |
|
||||
| D11 | built | merge of `origin/Micron-Assets` + integration, Aug 20 (`assets_check`); see `decisions-2026-08-20.md` |
|
||||
|
||||
## Out of scope, confirmed unbuilt
|
||||
|
||||
- **Parts catalog / live inventory / warehouse integration** — `mreq_check` and
|
||||
`materials_check` grep the model and the diff for stock/inventory/price
|
||||
fields on every run; none exist. D6's uploaded list is project-scoped data
|
||||
entry, not a catalog.
|
||||
- **Asset database integration** — SUPERSEDED by D11 on Aug 20: Cody Schaefer's
|
||||
Micron asset picker (read-only catalog lookup, `origin/Micron-Assets`) merged
|
||||
and adapted to the R2 creator. The assets section stays a CR-006 toggle
|
||||
(off on the Micron sample). This line was true when written.
|
||||
- **CxAlloy integration** — CR-014 is notification-only, as the task footnote
|
||||
ordered; the platforms block stores names and URLs, nothing calls them.
|
||||
- **P6 activity import** — CR-001 renders the two fields; nothing imports.
|
||||
|
||||
## Outstanding inputs (IMPLEMENTATION.md §8, restated)
|
||||
|
||||
- Nate's spreadsheet and the master material workbook: **still not supplied.**
|
||||
D6 built the upload path so their arrival is a paste, not a build.
|
||||
- The real B100 floor/area list: **still not supplied.** CR-005's upload is
|
||||
ready for it; every seeded value remains obviously fake.
|
||||
- SMTP host/credentials for production mail: the gate ships off; the password
|
||||
is env-only. Nothing on this branch has sent a real email.
|
||||
|
||||
## For the next revision
|
||||
|
||||
- **BL-020** — the wizard→creator navigation prompts to leave (T4.3's guard
|
||||
doing its job on what is now a page exit); product call on suppression.
|
||||
- **BL-021** — `project_sop_team()` reads a path `pushSOP` never writes; the
|
||||
critical-reopen mail has never reached the PM/CM. One line, needs its own
|
||||
sink verification.
|
||||
- **BL-022** — F6's "roughly two screens": 2.17 vs the strict 2.0. Bless it or
|
||||
name the chrome to trim.
|
||||
- **BL-023** — the productivity factor (actual ÷ estimated); data already
|
||||
aggregated, placement needs Nick.
|
||||
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
|
||||
ready for them.
|
||||
- Product questions raised in commit messages, awaiting answers: hold
|
||||
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
|
||||
merged-PDF export (T9.1).
|
||||
- Acceptance criteria that turned out wrong, for the next plan's calibration:
|
||||
F6's height bar collided with D3's own chosen design (amended once, then
|
||||
left red rather than moved again); A2's "tab count badge" predated D3
|
||||
removing tabs (the rail carried it); S1's "0 dialogs" never named the
|
||||
console pages that held a quarter of them.
|
||||
@@ -247,7 +247,7 @@ Wave 4 added three more, each written because its task's done-when could not be
|
||||
anything that already existed:
|
||||
|
||||
```bash
|
||||
python tests/aggregates_check.py # B4 — do the counts come from the server? 16 checks
|
||||
python tests/aggregates_check.py # B4 — do the counts come from the server? 17 checks
|
||||
python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks
|
||||
python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
|
||||
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
|
||||
@@ -280,10 +280,12 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio
|
||||
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
|
||||
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
|
||||
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
|
||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks
|
||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
|
||||
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
|
||||
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
|
||||
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
|
||||
# tests/usage_check.py (D5) removed at T11.6 - it tested the per-browser
|
||||
# analytics core and admin report, both retired in favour of CR-019's
|
||||
# server-side Activity & usage card (see docs/waves/decisions-2026-09-17.md).
|
||||
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
|
||||
```
|
||||
|
||||
@@ -292,7 +294,7 @@ Wave 8 adds these:
|
||||
```bash
|
||||
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
|
||||
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks
|
||||
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
|
||||
```
|
||||
|
||||
@@ -301,6 +303,19 @@ Wave 9 adds these:
|
||||
```bash
|
||||
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
|
||||
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
|
||||
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
||||
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 14 checks
|
||||
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
|
||||
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
|
||||
python tests/color_check.py # C4 - zero literals outside theme-light 5 checks
|
||||
```
|
||||
|
||||
The August 20 integration adds:
|
||||
|
||||
```bash
|
||||
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
|
||||
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
|
||||
python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
|
||||
@@ -961,3 +961,51 @@ Column `#` is the line number in `html/theme-light.css`. Read down each column p
|
||||
**120 declarations.** Lines 3–116 are the Carbon g10 set; lines 170–175 are the app-shell
|
||||
group added by the suite. Many share a literal by design — that is Carbon's v10→v11 alias
|
||||
layer, not the `S5` defect. See the note at the end of §3.
|
||||
|
||||
## Icons (S6 / T9.3)
|
||||
|
||||
One system: **monochrome text-presentation glyphs**, chosen because the suite
|
||||
is classic-script vanilla HTML with no bundler — an SVG sprite or icon font is
|
||||
a new asset pipeline, while text glyphs render through the same font stack as
|
||||
the words beside them. The trade accepted: text glyphs vary slightly by font,
|
||||
but never flip into per-platform colour artwork the way emoji do, which is the
|
||||
failure S6 names. **No emoji anywhere in the UI**; `tests/icon_check.py`
|
||||
sweeps every page for emoji-range codepoints and the U+FE0F emoji-presentation
|
||||
selector on every run.
|
||||
|
||||
One meaning per glyph, one glyph per meaning:
|
||||
|
||||
| Meaning | Glyph | Codepoint | Notes |
|
||||
|---|---|---|---|
|
||||
| done / ok / success | ✓ | U+2713 | replaced emoji ✅ |
|
||||
| close / remove / failure | ✕ | U+2715 | replaced emoji ❌ |
|
||||
| needs attention / warning | ⚠ | U+26A0 | text presentation |
|
||||
| blocked / on hold | ⊘ | U+2298 | replaced emoji ⛔ |
|
||||
| edit with a logged reason | ✎ | U+270E | replaced emoji 🔒; same meaning on sign-off overrides |
|
||||
| revert / refresh | ↺ / ↻ | U+21BA / U+21BB | direction distinguishes undo from reload |
|
||||
| settings / admin | ⚙ | U+2699 | replaced ⚡ on the side nav |
|
||||
| export / download | ⤓ | U+2913 | |
|
||||
| import / upload | ⤒ | U+2912 | |
|
||||
| key point (help copy) | ◆ | U+25C6 | replaced emoji ⭐ |
|
||||
| reference link | ▸ | U+25B8 | replaced emoji 📁 |
|
||||
| info tooltip badge | ⓘ | U+24D8 | the `.help-tip` component (S8/T9.5) |
|
||||
| back / navigation | ← ‹ › → | U+2190 U+2039 U+203A U+2192 | |
|
||||
| views (nav) | ◔ ▤ ▦ ▧ | U+25D4 U+25A4 U+25A6 U+25A7 | one per view, never reused |
|
||||
| drag handle | ⣿ | U+283F | sequence reordering |
|
||||
| home | ⌂ | U+2302 | side nav |
|
||||
| search | ⌕ | U+2315 | app bar |
|
||||
| print / save PDF | ⎙ | U+2399 | |
|
||||
| power / sign out | ⏻ | U+23FB | side nav |
|
||||
| sort direction | ▲ ▼ | U+25B2 U+25BC | column headers, paired |
|
||||
| in the QA queue | ◉ | U+25C9 | the CR-014 banner |
|
||||
| clock / language & time | ◷ | U+25F7 | replaced ⌚ U+231A, which is emoji-presentation by default |
|
||||
| menu (off-canvas) | ☰ | U+2630 | app bar |
|
||||
| people / directory | ☺ | U+263A | side nav; text presentation |
|
||||
| field view | ⚒ | U+2692 | side nav; text presentation |
|
||||
| password / key | ⚿ | U+26BF | side nav |
|
||||
|
||||
Dropped rather than mapped: ⚡ on action buttons (the label carries the
|
||||
action), 📷 on “Add photo” (labelled), 📄/🖼 on file rows (the filename is the
|
||||
label; a pictogram repeating “this is a file” said nothing). Every remaining
|
||||
glyph sits beside visible text or carries an accessible name — none is the
|
||||
only carrier of meaning.
|
||||
|
||||
@@ -54,7 +54,7 @@ deliberately deferred.
|
||||
|
||||
## Found during implementation
|
||||
|
||||
### BL-001 — The creator overflows horizontally at 1440px
|
||||
### BL-001 — CLOSED at T9.5 — The creator overflows horizontally (1440px, then 390px)
|
||||
|
||||
- **Found during:** T0.2
|
||||
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
|
||||
@@ -123,6 +123,13 @@ deliberately deferred.
|
||||
owns this entry now. `tests/form_structure_check.py` reports the measurement on
|
||||
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
|
||||
cannot be closed by silence.
|
||||
- **CLOSED, T9.5.** The `S8` rebuild replaced the escaping CSS `::after` tooltip
|
||||
with a viewport-clamped bubble element, and the creator measures
|
||||
**scrollWidth 390 vs clientWidth 390** at a 390px viewport. `frame_check.py`'s
|
||||
pin flipped: it now asserts the ABSENCE of overflow, so a regression reopens
|
||||
this entry loudly. Three causes in this entry's lifetime - the user-menu run
|
||||
(fixed by `T1.2`), the injected `--nav-w` (spent by `T7.1`), the tooltip
|
||||
(fixed here) - each found only because the measurement kept running.
|
||||
|
||||
### BL-002 — `outline: none` appears three times in the wizard sheet, not once
|
||||
|
||||
@@ -150,7 +157,7 @@ deliberately deferred.
|
||||
- **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets;
|
||||
`C1`'s audit at `T9.5` confirms it app-wide.
|
||||
|
||||
### BL-004 — `help.js` ships a 52-colour palette in a different design language
|
||||
### BL-004 — CLOSED at T9.9 — `help.js` ships a 52-colour palette in a different design language
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/help.js:79` (the injected `<style>`)
|
||||
@@ -165,8 +172,9 @@ deliberately deferred.
|
||||
work, not a non-issue.
|
||||
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
|
||||
`docs/reference/tokens.md` §1.
|
||||
- **CLOSED, T9.9:** the help centre's palette collapsed onto theme-light tokens; color_check.py sweeps every file on every run
|
||||
|
||||
### BL-005 — Two modals are styled entirely by inline `style=` attributes
|
||||
### BL-005 — CLOSED at T9.9 — Two modals are styled entirely by inline `style=` attributes
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150`
|
||||
@@ -177,6 +185,7 @@ deliberately deferred.
|
||||
- **Why not now:** they are markup built by JS, not a stylesheet, so they are outside `T3.2`'s
|
||||
four-sheet surface. Both dialogs are rebuilt as accessible components under `C1`.
|
||||
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
|
||||
- **CLOSED, T9.9:** both JS-built dialog kits (auth-guard, wp-format) and project-data's badges read tokens; zero literals remain
|
||||
|
||||
### BL-006 — Seventeen half-pixel font sizes
|
||||
|
||||
@@ -212,7 +221,7 @@ deliberately deferred.
|
||||
it in four waves, and it is measured every run now instead of once. Carried to
|
||||
`T7.2`.
|
||||
|
||||
### BL-008 — There is a second brand blue: `#2563d6`
|
||||
### BL-008 — CLOSED at T9.9 — There is a second brand blue: `#2563d6`
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257`
|
||||
@@ -224,8 +233,9 @@ deliberately deferred.
|
||||
- **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same
|
||||
conversation as the green action buttons.
|
||||
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a field fill. See `docs/reference/tokens.md` §8-E.
|
||||
- **CLOSED, T9.9:** the second brand blue is deleted - .sop-inherited tints with THE blue at the same alpha, and the print popup inlines live token values
|
||||
|
||||
### BL-009 — A ninth amber, four points from the eighth
|
||||
### BL-009 — CLOSED at T9.9 — A ninth amber, four points from the eighth
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `html/field.html:35` (`.pill.warn`)
|
||||
@@ -236,6 +246,7 @@ deliberately deferred.
|
||||
- **Why not now:** merging it moves a rendered colour, which `T3.2` forbids. `T3.2` named it
|
||||
`--wp-status-warning-text-alt` so it is visible rather than hidden in a hex.
|
||||
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a status pill. See `docs/reference/tokens.md` §8-K.
|
||||
- **CLOSED, T9.9:** --wp-status-warning-text-alt is deleted; its one consumer (field.html warn pill) uses the real amber
|
||||
|
||||
### BL-010 — 829 raw spacing, type and radius values remain inside rules
|
||||
|
||||
@@ -253,7 +264,7 @@ deliberately deferred.
|
||||
- **Suggested wave or follow-up:** `T5.x` and `T7.1`, where these pages are re-laid-out and the
|
||||
values are being chosen again anyway. See `docs/reference/tokens.md` §6b and §11.
|
||||
|
||||
### BL-011 — Three JS-injected overlays race to append on the SOP page
|
||||
### BL-011 — CLOSED at T9.9 — Three JS-injected overlays race to append on the SOP page
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav`
|
||||
@@ -266,8 +277,9 @@ deliberately deferred.
|
||||
- **Why not now:** invisible to users, and the fix is ordering in three separate scripts, which
|
||||
is a change with no observable benefit while `T7.1` is still going to move this code.
|
||||
- **Suggested wave or follow-up:** wave 9, if it is still true after `T7.1`.
|
||||
- **CLOSED, T9.9:** the sync badge's holder mounts at DOMContentLoaded, so the three overlays land in script order deterministically
|
||||
|
||||
### BL-012 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
|
||||
### BL-012 — CLOSED at T9.9 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440`
|
||||
@@ -280,6 +292,7 @@ deliberately deferred.
|
||||
covers what the diff was being asked to prove, and covers it better.
|
||||
- **Suggested wave or follow-up:** wave 9, alongside `C2`. Either freeze the clock in the
|
||||
fixture or exclude the live regions from capture — otherwise every later wave re-learns this.
|
||||
- **CLOSED, T9.9:** baseline_shots.py freezes Date and Math.random per document; two consecutive admin captures measured byte-identical
|
||||
|
||||
### BL-013 — The creator's inputs have no visible focus ring at all
|
||||
|
||||
@@ -323,7 +336,7 @@ deliberately deferred.
|
||||
- **Why not now:** out of `A5`'s stated scope, and `A4`/`S9` rebuild the stepper.
|
||||
- **Suggested wave or follow-up:** `T7.x`, with the stepper rebuild.
|
||||
|
||||
### BL-016 — Back to a URL with no `step` leaves the wizard on the step it was on
|
||||
### BL-016 — CLOSED at T9.9 — Back to a URL with no `step` leaves the wizard on the step it was on
|
||||
|
||||
- **Found during:** T5.1
|
||||
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
|
||||
@@ -340,6 +353,7 @@ deliberately deferred.
|
||||
unreviewable.
|
||||
- **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the
|
||||
current behaviour with a named check so the fix has a test waiting for it.
|
||||
- **CLOSED, T9.9:** a step-less wizard URL is step 1 (parseInt || 1); stepper_check's pin flipped with the fix, as the entry planned
|
||||
|
||||
### BL-017 — The native-dialog baseline metric counts prose
|
||||
|
||||
@@ -357,7 +371,7 @@ deliberately deferred.
|
||||
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
|
||||
target against the stripped figure.
|
||||
|
||||
### BL-018 — The Work Package tab's gate is the last localStorage-derived status
|
||||
### BL-018 — CLOSED at T9.9 — The Work Package tab's gate is the last localStorage-derived status
|
||||
|
||||
- **Found during:** T5.3
|
||||
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
|
||||
@@ -389,8 +403,9 @@ deliberately deferred.
|
||||
**imports `set_sop` from `sections_check.py`** rather than writing a fifth
|
||||
copy, so the workaround is in one place and disappears when the fixture is
|
||||
fixed. Four probes is enough evidence: `T9.9` owns it.
|
||||
- **CLOSED, T9.9:** the false-complete write requires the {sop,state} shape, and browser_check.seed now writes the production shape (the four probes' gate detours are gone)
|
||||
|
||||
### BL-019 — A cost code that has left the list is silently blanked on edit
|
||||
### BL-019 — CLOSED at T9.9 — A cost code that has left the list is silently blanked on edit
|
||||
|
||||
- **Found during:** T5.6
|
||||
- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by
|
||||
@@ -411,6 +426,7 @@ deliberately deferred.
|
||||
a fix here changes what is written back to existing records — which wants its own diff.
|
||||
- **Suggested wave or follow-up:** wave 9. The fix is the four lines already written for
|
||||
`gov_wosize`.
|
||||
- **CLOSED, T9.9:** a stored cost code with no matching option is kept as an option (the gov_wosize pattern), so the round-trip preserves it
|
||||
|
||||
### BL-014 — Four controls fall back to the browser's default focus ring
|
||||
|
||||
@@ -431,7 +447,7 @@ deliberately deferred.
|
||||
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
|
||||
same way rather than inheriting this entry's wording.
|
||||
|
||||
### BL-020 — Switching from the SOP wizard to the creator can now prompt to leave
|
||||
### BL-020 — CLOSED (decided 2026-08-20: keep it) — the wizard-exit prompt stays
|
||||
|
||||
- **Found during:** T7.1
|
||||
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
|
||||
@@ -458,7 +474,7 @@ deliberately deferred.
|
||||
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
|
||||
the prompt stops being a surprise.
|
||||
|
||||
### BL-021 — `project_sop_team()` reads a path `pushSOP` never writes
|
||||
### BL-021 — CLOSED 2026-08-20 (`project_sop_team()` reads nested-first; `critical_reopen_check` 11, sink-verified)
|
||||
|
||||
- **Found during:** T7.6
|
||||
- **Where:** `server/app.py`, `project_sop_team()`
|
||||
@@ -475,7 +491,7 @@ deliberately deferred.
|
||||
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
|
||||
the `tests/qa_gate_check.py` sink pattern.
|
||||
|
||||
### BL-022 — F6's "roughly two screen heights": 2.17 against a strict 2.0
|
||||
### BL-022 — CLOSED 2026-08-20 (strict 2.0; the chrome compressed to 1,784px = 1.98 screens; form_structure_check 51/51 for the first time)
|
||||
|
||||
- **Found during:** T7.2, re-measured at the wave 7 exit
|
||||
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
|
||||
@@ -494,7 +510,7 @@ deliberately deferred.
|
||||
becomes a small T9 task. The strict check stays red so the question cannot be
|
||||
forgotten.
|
||||
|
||||
### BL-023 — Productivity factor: actual against estimated hours
|
||||
### BL-023 — CLOSED into D12 (decided 2026-08-20: the dashboard) — see decisions-2026-08-20.md
|
||||
|
||||
- **Found during:** T9.2 (logged as that task's done-when requires)
|
||||
- **Where:** future — dashboard / rollups
|
||||
@@ -502,8 +518,147 @@ deliberately deferred.
|
||||
hours exist on every package; nothing yet compares them. A productivity
|
||||
factor (actual ÷ estimated, rolled up by discipline / building / type the way
|
||||
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
|
||||
The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`)
|
||||
already carry both sums, so this is a presentation task, not a data one.
|
||||
`/api/wps/metrics` already carries both sums, so this is a presentation
|
||||
task, not a data one. (Corrected at D12: the entry originally credited
|
||||
`/api/projects/{id}/summary` too, which carries no hours at all.)
|
||||
- **Why not now:** new scope — needs its own item id per the working rules, and
|
||||
a product conversation about where it displays and who reads it.
|
||||
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
||||
|
||||
### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
|
||||
|
||||
- **Found during:** T9.5 (the audit's dialog count)
|
||||
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
|
||||
- **What:** the app-wide native dialog count fell 79 → 21 across `S1`'s two
|
||||
tasks (`T5.8` wizard, `T7.9` creator). The remainder sit on surfaces no `S1`
|
||||
task ever named — admin-only or low-frequency flows, every one a genuine
|
||||
confirm-before-destroy. The T7.9 dialog kit (`wpConfirmDialog`/
|
||||
`wpPromptDialog`) is built and proven; conversion is mechanical.
|
||||
- **Why not now:** converting three more pages inside the audit task is the
|
||||
drive-by CLAUDE.md forbids; the audit's job was to measure and document.
|
||||
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
|
||||
|
||||
### BL-025 — CLOSED 2026-08-20 (tint rebased onto THE blue; color_check greps space-free spellings)
|
||||
|
||||
- **Found during:** the 2026-08-20 transparency fix (undefined-token sweep)
|
||||
- **Where:** `help.js`, the help-centre search input's `:focus` rule:
|
||||
`box-shadow:0 0 0 2px rgba(37,99,214,.15)`
|
||||
- **What:** BL-008 removed the second brand blue (#2563d6 = rgb 37,99,214) and
|
||||
`color_check` greps both spellings — but only inside `theme-light.css`, and
|
||||
only with spaces (`37, 99, 214`). This space-free rgba consumer slid past
|
||||
both nets. C4's recorded exception legitimately allows rgba **alphas** as
|
||||
opacity recipes, so this is not a token-rule defect; it is the wrong BASE
|
||||
colour under the alpha. The correct tint is THE blue: `rgba(15,98,254,.15)`.
|
||||
- **Why not now:** noticed in passing during an unrelated fix; one-line change
|
||||
plus widening `color_check`'s grep to space-free spellings deserves its own
|
||||
entry rather than a drive-by.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||
widened so it cannot recur.
|
||||
|
||||
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
|
||||
|
||||
- **Found during:** the 2026-08-21 outage triage (the question that started it)
|
||||
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
|
||||
- **What:** the app carries no record of what code it is running. `/api/health`
|
||||
returns `{"ok": true}` and nothing identifies the deployed commit, so
|
||||
answering "is the live site on the latest code?" took fingerprinting
|
||||
(probing for files/routes that only exist after certain merges) in the
|
||||
middle of an outage. The fix: bake the git SHA into the image at build time
|
||||
(`ARG GIT_SHA`), return it from `/api/health`
|
||||
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
|
||||
diagnostics card. Then currency is one glance against `git log -1`.
|
||||
- **Why not now:** new scope — needs its own item id per the working rules
|
||||
(D13 is the natural next), and it touches the image build, which deserves a
|
||||
deploy alongside someone with host access.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
|
||||
probe check that /api/health carries a version field.
|
||||
|
||||
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
|
||||
|
||||
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
|
||||
migration crash-looped the api container)
|
||||
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
|
||||
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
|
||||
production runs Postgres, and the dialects disagree exactly where it hurts:
|
||||
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
|
||||
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
|
||||
until the table was created by hand. The hotfix (64eac0c) fixed that one
|
||||
instance and pinned the Boolean-default class in `materials_check`; the
|
||||
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
|
||||
step — render `alembic upgrade --sql` for the postgresql dialect and read it
|
||||
before restarting (offline, needs no live DB; this render would have shown
|
||||
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
|
||||
for the postgresql dialect on each run and fails on anything the dialect
|
||||
rejects or on known-bad patterns.
|
||||
- **Why not now:** the outage is resolved and the one known instance is fixed
|
||||
and pinned; the systematic guard is its own small task, not a hotfix rider.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
|
||||
(both are "deploys should be boring" work).
|
||||
|
||||
### BL-028 — `okta_auth.describe()` is never called
|
||||
|
||||
- **Found during:** T10.9 (writing the deploy runbook's live-verification step)
|
||||
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere)
|
||||
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER
|
||||
ACTIVE..."` or report missing config at a glance, and both
|
||||
`server/.env.example` and `server/README.md` tell an operator to "check the
|
||||
startup log line" for it. Nothing prints it. `app.py` never imports or calls
|
||||
`describe()` at process start, so that log line does not exist and an operator
|
||||
following the docs will not find it.
|
||||
- **Why not now:** a runbook is documentation, not server code; wiring a
|
||||
startup log call is a real (if small) change to `app.py` and wants its own
|
||||
diff and its own verification, not a rider on T10.9.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass. One call
|
||||
(`logger.info(okta_auth.describe())` near startup) plus updating
|
||||
`DEPLOY-runbook-2026-09-03.md` Step 4 to check the log line once it exists.
|
||||
|
||||
### BL-029 — `users.failed_attempts` / `users.locked_until` are vestigial
|
||||
|
||||
- **Found during:** T10.6
|
||||
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`)
|
||||
- **What:** both columns exist to support local-password lockout, which T10.4
|
||||
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing
|
||||
increments them anymore — dead columns, not a bug, but schema drift from the
|
||||
D15 cutover.
|
||||
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration
|
||||
and belongs with the rest of the local-password cleanup, not folded into a
|
||||
docs task.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other
|
||||
post-cutover schema tidy-up.
|
||||
|
||||
### BL-031 — A raw `rgba()` shadow survives in the creator's stylesheet
|
||||
|
||||
- **Found during:** T10.8 (the full suite could finally run through Docker; the
|
||||
dev sandbox never had a headless browser to run it in before)
|
||||
- **Where:** `html/wp-creation-styles.css:929`, `.asset-results { ... box-shadow:0
|
||||
8px 24px rgba(20,30,50,.18); }`
|
||||
- **What:** `generalinfo_check.py` asserts the whole file carries no raw colour
|
||||
literal (comments excluded) as part of its CR-003 priority-colour check, and
|
||||
this one line fails it: 48/49. Confirmed via `git show HEAD` that the literal
|
||||
is already committed, byte-identical, unrelated to wave 10 — it is the Micron
|
||||
asset picker's dropdown shadow, which arrived with the `origin/Micron-Assets`
|
||||
merge (D11, 2026-08-20) and was never swept by `C4`'s token pass (`T9.9`
|
||||
closed before D11 merged). The fix is a straight swap:
|
||||
`theme-light.css:217` already declares `--wp-shadow-menu: 0 8px 24px
|
||||
rgba(20, 30, 50, .18)`, the identical value — this line should read
|
||||
`box-shadow:var(--wp-shadow-menu);`.
|
||||
- **Why not now:** unrelated to the Okta wave; fixing a D11-era CSS literal
|
||||
inside T10.8 (auth verification) is exactly the drive-by `CLAUDE.md` forbids.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with `C4`'s other
|
||||
leftovers. One-line fix, `generalinfo_check.py` already pins it (49/49 once
|
||||
fixed).
|
||||
|
||||
### BL-030 — `DEPLOY-login-portal.md` is fully stale
|
||||
|
||||
- **Found during:** T10.9
|
||||
- **Where:** `DEPLOY-login-portal.md` (repo root)
|
||||
- **What:** the original username/password login rollout doc. References
|
||||
`bcrypt`, `create-admin --password`, `AUTH_SECRET_KEY` as the only secret,
|
||||
and a login form — none of which describe the app since T10.4/T10.5. Someone
|
||||
handed this to IT today would be told to do things that no longer work.
|
||||
- **Why not now:** no task currently owns deploy-doc cleanup as a category;
|
||||
deleting or archiving a doc is a product/records call (`CLAUDE.md`'s "removed
|
||||
fields are hidden, not deleted" spirit likely applies to docs too, but that is
|
||||
worth confirming rather than assuming).
|
||||
- **Suggested wave or follow-up:** next housekeeping pass — needs Nick on
|
||||
whether to delete, archive, or rewrite it as historical record.
|
||||
|
||||
102
docs/waves/decisions-2026-08-20.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Decisions — August 20, 2026
|
||||
|
||||
One item. Like the August 18 set, it is a **new item** with its own `D` id, not a
|
||||
reinterpretation of an existing one.
|
||||
|
||||
---
|
||||
|
||||
## D11 — The Micron asset picker merges into the R2 creator
|
||||
|
||||
- **Arrived as:** `origin/Micron-Assets` (`7ef1fcd`, Cody Schaefer, Aug 18) — written
|
||||
against pre-R2 `main`, integrated here by Nick's instruction on Aug 20.
|
||||
- **Amends:** the R2 completion record's "Asset database integration — out of scope,
|
||||
confirmed unbuilt" line, which was true when written and stops being true here.
|
||||
- **Surface:** `html/` (creator), `server/` (`assets_db.py`, `/api/assets`),
|
||||
`docker-compose.yml`, `requirements.txt`.
|
||||
|
||||
### What the branch brought
|
||||
|
||||
A read-only lookup onto the Micron asset catalog (a SQL Server instance outside this
|
||||
repo): the whole catalog is fetched once per creator page load through `/api/assets`
|
||||
and searched in memory; picked assets are stored on the package tagged
|
||||
`source:'catalog'` with the DB's own casing; anything not in the catalog is added by
|
||||
hand and visibly tagged manual. CSV import and Excel column paste bulk-add with the
|
||||
same matching. Unconfigured (`MICRON_DB_URL` unset) and unreachable are first-class
|
||||
states that degrade to manual entry — the suite runs without Micron wired up.
|
||||
|
||||
### What integration changed (and why)
|
||||
|
||||
The branch predates waves 5–9, so it used surfaces R2 replaced. Each adaptation keeps
|
||||
Cody's behaviour and moves it onto the R2 idiom:
|
||||
|
||||
1. **Six `alert()` calls → the T7.9 dialog kit and toast.** The creator ships zero
|
||||
native dialogs (`creator_dialogs_check` pins the count). File-handling errors use
|
||||
`toast(msg,'alert')` exactly as the drawings uploader and comment import do;
|
||||
the instructional message and the import summary use the kit, which gained the
|
||||
one-button `wpAlertDialog` shape it was always going to need (BL-024 wants it too).
|
||||
2. **The export block** moved inside T9.1's sectioned `add('assets', …)` frame, so the
|
||||
CR-006 assets toggle keeps governing it. Content is Cody's: two columns, Asset ID +
|
||||
Note, no controls.dev link column.
|
||||
3. **`initAssetPicker()`** joined the R2 `bootData()` loads rather than replacing them.
|
||||
4. **`role="status"`** on the picker's source note, so loading → ready/absent/error
|
||||
announces (C1, the login.html pattern).
|
||||
5. Everything else landed as written: his `⤒` import glyph is already the S6-mapped
|
||||
U+2912, `.material-actions` is the creator's own class, and the styles block
|
||||
declares no colour literal (`color_check` re-verifies).
|
||||
|
||||
### Recorded properties, restated as constraints
|
||||
|
||||
- **Read-only, structurally.** `assets_db.py` contains one SELECT and no other
|
||||
statement; there is no POST route. `assets_check` greps this on every run.
|
||||
- **Credentials are env-only** (`MICRON_DB_URL`), matching the SMTP password rule.
|
||||
Driver errors are logged server-side and never propagated to the browser, because
|
||||
a malformed URL's error text can quote password fragments.
|
||||
- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
|
||||
manual mode; nothing in the suite requires the catalog to exist.
|
||||
|
||||
---
|
||||
|
||||
# The evening decisions (same day)
|
||||
|
||||
Six answers from Nick, given in one message. Recorded verbatim in intent; each
|
||||
names the item it settles. One new item id is assigned (D12); everything else
|
||||
amends or closes an existing question.
|
||||
|
||||
## The answers
|
||||
|
||||
1. **BL-022 — "strict 2.0."** F6/D3's "roughly two screen heights" means
|
||||
**2.0**, not 2.17. The overage is chrome (~154px: the context bar, the
|
||||
release banner's spacing, header/toolbar padding), so this becomes a build
|
||||
task: compress the chrome without deleting what other items placed
|
||||
deliberately (A2's one-warning banner and the SOP identity strip STAY —
|
||||
they get denser, not removed). `form_structure_check`'s red check flips
|
||||
green by the page actually fitting, not by moving the bar.
|
||||
|
||||
2. **Hold from Draft/Scheduled — "no, leave as is."** The hold branch stays
|
||||
reachable from any status. T7.3's raised question is closed; the shipped
|
||||
behaviour is the decided behaviour.
|
||||
|
||||
3. **CR-014 email bodies — links back to the system; customer context is
|
||||
allowed, confidential documents are not.** The T7.6-era rule ("no customer
|
||||
IP in emails") is refined: naming the customer, the project, the package
|
||||
and where the work happens is fine; what must never be embedded is
|
||||
confidential document CONTENT (drawings, attachments, scope text). Every
|
||||
work-package email carries a deep link back to the package in the system.
|
||||
Build task, sink-verified.
|
||||
|
||||
4. **CR-008 merged-PDF export — known issue, not a build.** The export keeps
|
||||
inline images + listed PDF attachments. Recorded as KNOWN-ISSUES.md §3 so
|
||||
the limitation is a commitment, not a surprise.
|
||||
|
||||
5. **BL-023 → D12 — the productivity factor gets a spot on the dashboard.**
|
||||
Placement delegated ("find a spot on the dashboard"). New item id **D12**:
|
||||
actual ÷ estimated hours, from data the rollup endpoints already carry.
|
||||
|
||||
6. **BL-020 — "keep it."** The unsaved-work prompt on leaving the wizard
|
||||
stays. Closed as decided-keep; no build.
|
||||
|
||||
Plus: **"do what's left on the housekeeping"** — BL-021 (the
|
||||
critical-reopen recipient bug), BL-024 (the 21 console/launcher dialogs onto
|
||||
the shared kit), BL-025 (the last second-blue tint + the widened check), and
|
||||
S13 (seed_demo sign-in) are approved to build now, one commit each, on
|
||||
`feat/wp-suite-r3-housekeeping`.
|
||||
75
docs/waves/decisions-2026-09-02.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Decisions — September 2, 2026
|
||||
|
||||
One item, and it retires two decided-and-built items rather than amending them.
|
||||
|
||||
---
|
||||
|
||||
## D15 — Authentication moves to Okta OIDC; D13 and D14 are retired before deployment
|
||||
|
||||
- **Amends:** retires `D13` (LDAPS simple bind against `prime.local`) and `D14` (the CLI
|
||||
authenticates against the domain). Both were decided and reaffirmed August 21 2026,
|
||||
built across nine tasks (`T10.1`–`T10.9`), and verified against the live domain. Neither
|
||||
reached production. Approved by Nick Siegfried.
|
||||
- **Surface:** `server/auth.py`, `server/app.py` (`login()`), `html/login.html`,
|
||||
`html/login.js`, `html/auth-guard.js`. `server/ldap_auth.py` does not carry forward —
|
||||
there is no LDAPS bind in the new design, not even as a fallback.
|
||||
- **Wave:** 10. The label is reused fresh: the LDAPS work that previously answered to
|
||||
"wave 10" was built on `feat/ldaps-directory-auth`, which is deleted rather than merged,
|
||||
and never appeared in `IMPLEMENTATION.md`'s wave table. It carries no claim on the
|
||||
number.
|
||||
|
||||
### What D13/D14 were, for the record
|
||||
|
||||
The branch carrying them is deleted, not merged, so their decision record
|
||||
(`docs/waves/decisions-2026-08-21.md`) no longer exists on any branch. Preserved here so
|
||||
the reasoning isn't lost along with it:
|
||||
|
||||
D13 chose a direct LDAPS simple bind to `ldaps://prime.local:636` as the sign-in
|
||||
mechanism: no password stored, a successful bind was the authentication, accounts were
|
||||
provisioned just-in-time from the directory, and roles stayed local. D14 moved the CLI
|
||||
onto the same bind, removing `create-admin`/`create`. Both were built, tested
|
||||
(1284/1288 checks, Aug 24), and screenshotted at 390px and 1440px. Neither ever deployed —
|
||||
`wp.controls.dev` still runs the pre-D13 local-password login as of this decision.
|
||||
|
||||
### The decision
|
||||
|
||||
Skip LDAPS entirely. Authentication becomes an Okta OIDC authorization-code flow,
|
||||
replacing local passwords directly — the same full replacement D13 intended, just via
|
||||
Okta instead of a domain bind. No LDAPS bind exists in this design at any point.
|
||||
|
||||
Four things carry forward from D13 unchanged, because they were never LDAPS-specific to
|
||||
begin with:
|
||||
|
||||
1. **No password is stored.** The app never sees a credential of any kind; Okta owns
|
||||
authentication entirely.
|
||||
2. **Accounts are provisioned just-in-time.** A first successful Okta sign-in with no
|
||||
matching local `users` row creates one, at the default role. The matching logic that
|
||||
was going to key off a directory search instead keys off an OIDC identity claim.
|
||||
3. **Roles stay local.** Okta, and AD behind it, supplies identity only. This app decides
|
||||
what an identity may do. Restated because it is the one rule the whole access-control
|
||||
design depends on — see `BL-029` and the governance discussion that followed it.
|
||||
4. **Existing accounts keep their roles** on first Okta login, exactly as D13's criterion
|
||||
4 read for LDAPS.
|
||||
|
||||
### Why this, and not LDAPS first and Okta second
|
||||
|
||||
`BL-029` (recorded on this branch as `BL-027` before the renumbering forced by main's
|
||||
independent use of that number) already laid out why OIDC beats the LDAPS bind on three
|
||||
counts: this app never sees a password, MFA comes from Okta rather than needing to be
|
||||
built, and the domain-lockout hazard that forced `AUTH_MAX_ATTEMPTS` down to 2 disappears,
|
||||
because failed attempts land on Okta rather than on a bind this app makes. D13 was decided
|
||||
before it was known the company already runs an Okta tenant. Once that was confirmed
|
||||
(security's scoping reply, September 2026), shipping LDAPS first and replacing it with
|
||||
Okta days later would mean building and deploying the weaker mechanism on purpose. Going
|
||||
straight to Okta avoids that.
|
||||
|
||||
### What still needs answering before this is buildable
|
||||
|
||||
Open from the security scoping thread, not yet closed:
|
||||
|
||||
- Which OIDC claim carries the AD `sAMAccountName` equivalent (`preferred_username`,
|
||||
`upn`, or a custom claim) — asked of security, answer pending.
|
||||
- The exact redirect/callback URI once the hostname situation is reconfirmed
|
||||
(`https://wp.controls.dev/api/auth/okta/callback` proposed).
|
||||
- The `Business Technology Group` pilot in Okta, requested for initial testing, with
|
||||
normal Okta session/MFA behavior rather than a stricter per-app rule.
|
||||
122
docs/waves/decisions-2026-09-03.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Decisions — 2026-09-03
|
||||
|
||||
## D16: Okta admin bootstrap, break-glass posture, and the real scope of T10.4
|
||||
|
||||
Raised during hazard review for T10.4 (remove the local password path). D15 settled
|
||||
*that* local passwords go away and Okta OIDC is the sole replacement; it did not settle
|
||||
how an admin account gets named once there is no password to set, or what happens if
|
||||
Okta itself is unreachable. Both are decided here.
|
||||
|
||||
### Admin bootstrap
|
||||
|
||||
`server/manage_users.py` stays the bootstrap tool — its own docstring already says so
|
||||
("the `/api/auth/users` endpoint needs an existing admin, so you have to bootstrap one
|
||||
here") — but it changes from *creating* an account to *promoting* one:
|
||||
|
||||
- An operator with shell/DB access on the server runs it against an account that
|
||||
already signed in through Okta once and was JIT-provisioned by T10.3 (landing at
|
||||
`project_user`, per that task). The command sets `role = admin` on that existing row
|
||||
by username.
|
||||
- It does **not** create a `User` row from scratch and does not touch `password_hash`
|
||||
(the column is gone after T10.4's migration).
|
||||
|
||||
Rejected: minting a brand-new admin row by hand-typed username. `OKTA_IDENTITY_CLAIM`'s
|
||||
exact format is still unconfirmed by security (open item carried from D15/wave-10.md).
|
||||
A hand-typed username that doesn't exactly match what Okta actually sends produces a
|
||||
second, orphaned account instead of promoting the real one. Promoting an
|
||||
already-JIT-provisioned row sidesteps that entirely — it never has to guess the future
|
||||
claim value.
|
||||
|
||||
Ongoing (non-bootstrap) admin naming needs no new work: `html/users.js` already has a
|
||||
live role dropdown (`roleSelect`, gated by server-supplied `grantable_roles`) that lets
|
||||
an existing admin promote any other account, including one JIT-provisioned via Okta.
|
||||
That path is unrelated to the password removal and keeps working unchanged.
|
||||
|
||||
### Break glass
|
||||
|
||||
No break-glass path, by design. If Okta is unreachable or misconfigured, the app is
|
||||
unreachable for everyone, including admins, until Okta is restored.
|
||||
|
||||
This matches the precedent already on record for the abandoned LDAPS design (D13/D14):
|
||||
"LDAPS is the *only* path, no local fallback, no break-glass." Carried forward
|
||||
deliberately rather than assumed to still apply, given Okta's failure modes differ from
|
||||
an internal LDAP bind — considered and confirmed, not defaulted into.
|
||||
|
||||
Rejected: a toggleable emergency local login gated behind an env flag. It would
|
||||
reintroduce a stored local credential, exactly what D15 exists to eliminate, for a
|
||||
scenario (Okta down) judged less likely and less costly than the standing risk of a
|
||||
forgotten emergency backdoor.
|
||||
|
||||
The server-shell CLI (`manage_users.py`, promoting an existing row) is not a formal
|
||||
break-glass mechanism — it cannot help if no account has ever signed in through Okta —
|
||||
but it is the same trust tier as "someone with SSH/container access to prod could
|
||||
already edit the database directly," and it costs no new engineering.
|
||||
|
||||
### T10.4 scope correction
|
||||
|
||||
Hazard review found real call sites of `hash_password` / `verify_password` /
|
||||
`password_problem` that the original T10.4 bullet in `wave-10.md` didn't name and that
|
||||
break the moment those functions are deleted:
|
||||
|
||||
- `create_user()` and `admin_reset_password()` in `server/app.py` (the admin console's
|
||||
"add user" and "reset password" routes).
|
||||
- `html/users.js`'s "add user" form (`nu-password` field) and "Reset password" button.
|
||||
- `server/manage_users.py`'s `create`, `create-admin`, and `reset-password` subcommands
|
||||
(see bootstrap section above for its replacement).
|
||||
- `tests/browser_check.py` and `tests/launcher_check.py`, which call
|
||||
`auth.hash_password()` to seed fixture rows.
|
||||
|
||||
Also found: `server/smoketest.py` and `server/seed_demo.py` authenticate via
|
||||
`POST /api/auth/login`, which T10.4 removes, and CLAUDE.md's own verification section
|
||||
names both scripts as required checks. Fix folded into T10.4 rather than deferred to
|
||||
T10.7: both scripts switch to minting a session with `auth.create_token()` and setting
|
||||
the cookie directly, the same technique `tests/browser_check.py` already uses instead
|
||||
of scripting a login form. No live Okta tenant needed, and T10.4 no longer depends on
|
||||
T10.7's timing.
|
||||
|
||||
`wave-10.md`'s T10.4 bullet is updated to reflect this full scope.
|
||||
|
||||
## D17: A dedicated rollback-aware runbook for the Okta cutover deploy
|
||||
|
||||
Raised while preparing to close out wave 10: T10.4's migration
|
||||
(`server/alembic/versions/1d60a608bb51_drop_local_password.py`) drops the
|
||||
`password_hash` column, and its `downgrade()` re-adds the column with
|
||||
`server_default=''`. That restores the schema, not the data — the real bcrypt hashes
|
||||
are destroyed the moment `upgrade()`'s `op.drop_column` commits, and no amount of
|
||||
`alembic downgrade` brings them back. `DEPLOY-runbook-2026-08-04.md`, the existing
|
||||
precedent for how a deploy of this repo is handed to IT, has no equivalent case in its
|
||||
own Rollback section — its migrations are additive or reversible, so nothing there
|
||||
warns an operator that this one is different.
|
||||
|
||||
Three options were on the table: write this runbook now; also document a staged
|
||||
deploy sequence (ship T10.5's Okta-live-alongside-password state first, verify real
|
||||
sign-ins, then ship T10.4's column drop as a separate follow-up deploy); or log the
|
||||
hazard to `backlog.md` and stop there. Decided: write the runbook only. Staged
|
||||
sequencing is real risk reduction but is a second deploy plan on top of a wave that is
|
||||
otherwise a single cutover (D15's "full replacement," not a toggle) — worth
|
||||
proposing on its own if IT wants it, not worth building unasked. Backlog-only was
|
||||
rejected because the hazard is concrete and dated (this wave, this migration), not a
|
||||
someday item.
|
||||
|
||||
### What the runbook has to do differently from the 2026-08-04 precedent
|
||||
|
||||
- Name the five `OKTA_*` environment variables as newly required for this deploy —
|
||||
the precedent's own "no new environment variables" note does not apply here and
|
||||
restating it unchanged would be actively wrong.
|
||||
- Treat the pre-deploy backup (`docker exec wp_db_backup /scripts/db-backup.sh`) as
|
||||
the only way back once `1d60a608bb51` commits, not as routine due diligence.
|
||||
- Separate two failure modes that look similar but are not: an Okta app integration
|
||||
that is misconfigured after a clean deploy (redirect URI, client secret, an
|
||||
unassigned test account) is fixable in place — fix the config, redeploy the `api`
|
||||
service, no data at risk, migration already applied and stays applied. Deciding to
|
||||
abandon Okta and restore local-password code is the severe case — the empty
|
||||
`password_hash` column means the old code has nothing to check a password against,
|
||||
so the only way back is the destructive backup restore (`DEPLOY-runbook-2026-08-04.md`
|
||||
Case C's own procedure, reused here). Conflating these two would send an operator
|
||||
straight to a destructive restore for a problem that a config fix would have solved.
|
||||
- Fold in D16's no-break-glass posture: if Okta is down or misconfigured, the app is
|
||||
down for everyone, including admins, by design — not a defect to roll back from.
|
||||
|
||||
Filed as **T10.9** in `wave-10.md` rather than folded into T10.6 (already merged and
|
||||
verified) or T10.8 (UI/test verification, a different kind of check). New scope found
|
||||
after the task that raised it closed gets a new ID, per `CLAUDE.md`.
|
||||
340
docs/waves/decisions-2026-09-17.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Decisions — September 17, 2026
|
||||
|
||||
Three items. Like `D11`, `D15`, `D16` and `D17`, these are new scope raised after
|
||||
R2 closed out (see `docs/reference/completion.md`, T9.7), not a reopening of
|
||||
anything already decided there.
|
||||
|
||||
`CR-019` and `CR-020` get `CR` ids because they are field/product-facing feature
|
||||
requests — the same kind of thing `CR-001`-`CR-018` were — not internal
|
||||
engineering calls made mid-build. `D18` gets a `D` id because it is exactly that:
|
||||
an internal security/architecture call, the same category as `D15` (Okta vs.
|
||||
LDAPS), not a feature a user asked for.
|
||||
|
||||
Requested/raised by Matt Mabrey, 2026-09-17.
|
||||
|
||||
---
|
||||
|
||||
## CR-019 — Usage and activity metrics (admin console)
|
||||
|
||||
- **Area:** Admin Console / Monitoring
|
||||
- **Priority:** proposed High — the current admin console has no reliable way to
|
||||
answer "who is using this and how much," which is the same visibility gap the
|
||||
original UX review found in `B4` (numbers that look authoritative but are not).
|
||||
- **Source:** Matt Mabrey, 2026-09-17.
|
||||
|
||||
### Why this is not already built
|
||||
|
||||
`D5`/`T7.10` already shipped a usage-analytics feature — `html/wp-usage.js`, with
|
||||
a report at `admin.js:666-699` — but it reads `localStorage`, which is scoped to
|
||||
one browser. The report's own empty state says exactly this: *"No usage recorded
|
||||
in this browser yet."* It cannot show who across the team is active or what they
|
||||
use, because each person's activity exists only on their own machine. This is the
|
||||
same class of defect `B4` named for the pipeline strip — a number that looks
|
||||
authoritative but is not — just never generalized to this feature. `CR-019` is
|
||||
therefore new server-side work, not a UI addition on top of what exists.
|
||||
|
||||
Two things already in the schema are relevant and reused rather than duplicated:
|
||||
`User.last_login_at` (one timestamp, no history) and `AuditLog` (append-only,
|
||||
already records business mutations — WP created, status changed, role changed —
|
||||
per user, with a timestamp). Neither captures navigation or feature-open events,
|
||||
which is the actual gap.
|
||||
|
||||
### Intent
|
||||
|
||||
Give admins a real, server-backed picture of who is using the suite, what parts
|
||||
of it they use, and how active they are — replacing the per-browser report as
|
||||
the thing anyone actually looks at.
|
||||
|
||||
### Decisions, made 2026-09-17
|
||||
|
||||
1. **Retention: indefinite.** No automatic purge of usage-event data. (Separate
|
||||
from `AuditLog`'s own retention, which this does not change.)
|
||||
2. **Scope: suite-wide, filterable.** Not per-project by default; the console
|
||||
provides filters (date range, project, user, tool/page) rather than scoping
|
||||
the data itself.
|
||||
3. **Export: raw view shows real identities; export supports sanitization.**
|
||||
The admin console's own tables and the CSV export both default to real
|
||||
usernames — this is an internal audit tool, not a public one. But the export
|
||||
also offers a "sanitize" toggle that replaces the actor with a **stable
|
||||
pseudonymous id** (a per-user hash, consistent across rows and across
|
||||
export runs) rather than dropping the identity field outright — so an
|
||||
external system (Power BI or similar) can still group and trend "by user"
|
||||
without ever receiving a real name. Flagged here as the recommended
|
||||
approach rather than the only one Matt confirmed in so many words: if a
|
||||
fully-anonymous (no stable id at all) export turns out to be what's
|
||||
actually wanted, that is a one-line change to the same feature, raise it
|
||||
in the PR rather than treating it as blocking.
|
||||
4. **The old per-browser report is retired**, not kept alongside the new one.
|
||||
Once `CR-019` ships, `admin.js`'s existing `usage-admin` panel (reading
|
||||
`WPUsage.load(...)` per browser) is removed rather than left next to the
|
||||
real report, where it would show a smaller, misleading number for whoever
|
||||
happens to have it open. `html/wp-usage.js` and its two call sites
|
||||
(`html/work-package-suite-app.js`'s wizard dwell-tracking, and the
|
||||
creator's equivalent) are a separate question — the *recording* code can
|
||||
stay or go independent of the *admin report* being retired, since dwell
|
||||
events were never reliably tied to a real identity anyway. Default to
|
||||
removing both unless a task finds a reason to keep the recorder; log that
|
||||
reason rather than deciding it here.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A new admin-only tab in `admin.html` (same role gate as the User Directory)
|
||||
shows: active users over a selectable date range (day/week/month), each
|
||||
user's last-active timestamp, and a breakdown of which tools/pages get
|
||||
opened and how often.
|
||||
- Server-side event capture, keyed to the authenticated session (real identity,
|
||||
not a browser-local guess) — a new table, not an extension of `AuditLog`,
|
||||
since page-open/navigation events are not business mutations and mixing them
|
||||
in would make `AuditLog` noisy for its existing, narrower purpose.
|
||||
- The console's filters cover date range, project, user, and tool/page, and
|
||||
combine (e.g., "user X, last 30 days, field view only").
|
||||
- CSV export from the console, in both raw (real usernames) and sanitized
|
||||
(stable pseudonymous id per user) modes.
|
||||
- Retention is indefinite; nothing in this item purges data.
|
||||
- View-only, refresh-on-load. No alerting — matches how the rest of the admin
|
||||
console works today; if that changes later it is new scope, not a rider on
|
||||
this item.
|
||||
- The old per-browser "Usage" report and its admin-console panel are removed
|
||||
in the same wave, not left running alongside the new one.
|
||||
- Accessible per `CLAUDE.md`'s standing `C1` rules (this is a new component,
|
||||
not a legacy one — it ships accessible or it is not done, same as
|
||||
everything else built since wave 7).
|
||||
|
||||
### Frontend/backend boundary
|
||||
|
||||
This needs server work, the same way `CR-004`/`CR-018`/`B4` did: a real table,
|
||||
a capture endpoint, an aggregation endpoint, and an export endpoint. If a task
|
||||
under this item is being built by writing to `localStorage`, it is rebuilding
|
||||
the exact defect this item exists to replace — stop and say so, per
|
||||
`CLAUDE.md`.
|
||||
|
||||
### Scheduling
|
||||
|
||||
New wave. Wave 10 (Okta) is merged, so this does not wait on anything.
|
||||
Task breakdown: `docs/waves/wave-11.md`.
|
||||
|
||||
---
|
||||
|
||||
## CR-020 — Bulk editing of users in the admin console
|
||||
|
||||
- **Area:** Admin Console / User Directory
|
||||
- **Priority:** proposed High — three of the four actions below touch access
|
||||
control (role, project assignment, active/disabled) and the fourth is a hard
|
||||
delete; getting the guardrails right matters more than getting it built fast.
|
||||
- **Source:** Matt Mabrey, 2026-09-17.
|
||||
|
||||
### Why this is not already built
|
||||
|
||||
Every user-editing action in `html/users.js` today is one row, one action:
|
||||
a role `<select>` per row, an activate/disable button per row, a
|
||||
per-user project-membership checklist opened one user at a time, and a
|
||||
per-row delete. There is no row selection in the User Directory table at all.
|
||||
Server-side, every corresponding endpoint
|
||||
(`/api/auth/users/{id}/active`, `/role`, `/project-role`, `/projects`, and
|
||||
`DELETE /api/auth/users/{id}`) takes exactly one `user_id`. Bulk editing is new
|
||||
UI (selection) and, for most actions, either a loop over the existing
|
||||
single-user endpoints or new endpoints that accept a list — the task decides
|
||||
which, per user count and transaction-safety needs.
|
||||
|
||||
**Deletion is already a hard delete today** (`delete_user`, `server/app.py:1121`
|
||||
— `db.delete(u)`, not a deactivate). Bulk delete inherits that: it is not this
|
||||
item's job to invent a soft-delete pattern that does not exist for the single
|
||||
case, but the confirmation step around it has to be sized for the fact that a
|
||||
bad multi-select now removes more than one account at once, permanently.
|
||||
|
||||
### Decisions, made 2026-09-17
|
||||
|
||||
1. **All four bulk actions are in scope:** role change, activate/disable,
|
||||
project assignment (add to / remove from a project, including the
|
||||
project-role), and delete.
|
||||
2. **Selection works two ways:** checkboxes (select-all and individual) in the
|
||||
existing User Directory table, respecting whatever filter is already
|
||||
applied (role, active/disabled, project) — and a CSV upload, for a one-off
|
||||
bulk operation against an external list (e.g., an offboarding list that
|
||||
didn't originate in this app). Both are in scope, not a choice between them.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- The User Directory table gains row checkboxes and a select-all that respects
|
||||
the current filter; a bulk-action toolbar appears once at least one row is
|
||||
selected.
|
||||
- CSV upload as an alternative to checkbox selection: a list of usernames plus
|
||||
the action to apply. Validates every row, reports rejected ones by row (bad
|
||||
username, user not found, actor lacks permission over that user) rather than
|
||||
silently skipping them — the same validate-and-report pattern `CR-005`
|
||||
established for list uploads.
|
||||
- Every existing single-user guardrail carries forward unchanged: an actor
|
||||
cannot include their own account in a bulk action that would disable, demote,
|
||||
or delete it; a super user's bulk action is scoped to only the users and
|
||||
projects `require_see_user`/`require_manage_user` already let them touch
|
||||
today (a super user cannot use a bulk action to reach a user or project
|
||||
outside what they manage, even via CSV); `grantable_roles` still gates which
|
||||
roles an actor may assign in bulk, the same as one at a time.
|
||||
- Every affected row is written to `AuditLog` individually, exactly as the
|
||||
single-user endpoints do today (one `role_changed` / `user_deleted` / etc.
|
||||
row per user) — a bulk action is many audited changes, not one opaque batch
|
||||
entry, so per-user history stays intact and readable in isolation.
|
||||
- Confirmation before applying, using the `wp-dialog` kit (`T7.9`), not a
|
||||
native `confirm()`. The dialog names exactly how many users are affected and,
|
||||
for delete specifically, lists the affected usernames before committing.
|
||||
- Partial failure is reported, not hidden: if some rows in a batch fail (scope,
|
||||
already-deleted, bad CSV row), the action applies to what it can and states
|
||||
exactly which rows failed and why. It never reports success on a batch that
|
||||
partly failed.
|
||||
- Accessible per `C1`: real controls, keyboard-operable selection and bulk-
|
||||
action toolbar, `aria-live` announcing the result.
|
||||
|
||||
### Recommended, not yet confirmed — raise in the PR if this is wrong
|
||||
|
||||
- **Bulk delete gets an extra confirmation step beyond naming the count** —
|
||||
proposed as typing a confirmation phrase (e.g. the word `DELETE`) regardless
|
||||
of how many rows are selected, since one bad multi-select now removes more
|
||||
than one account, permanently, with no soft-delete to fall back on. This is
|
||||
a recommendation, not a confirmed requirement — Matt has not signed off on
|
||||
the exact mechanism.
|
||||
- **Project assignment is add/remove, not replace-the-whole-list** — a bulk
|
||||
"add these users to project X" or "remove these users from project X"
|
||||
action, rather than a bulk action that overwrites a user's entire project
|
||||
list. Proposed because add/remove is less likely to clobber project
|
||||
memberships the actor didn't intend to touch; a replace-the-whole-list
|
||||
version is a materially different (and riskier) feature if that turns out to
|
||||
be what's actually wanted.
|
||||
|
||||
### Frontend/backend boundary
|
||||
|
||||
Selection state (which rows are checked) is fine as client-side UI state — it
|
||||
is not persisted data. Everything the bulk action actually does (role,
|
||||
active/disabled, project membership, delete) already requires server work
|
||||
today for the single-user case, and bulk does not change that: no new
|
||||
localStorage-derived state, no client-side aggregation of what changed.
|
||||
|
||||
### Scheduling
|
||||
|
||||
New wave. Independent of wave 11 (`CR-019`) — the two do not touch the same
|
||||
code and can build in either order or in parallel. Task breakdown:
|
||||
`docs/waves/wave-12.md`.
|
||||
|
||||
---
|
||||
|
||||
## D18 — Detecting and acting on Okta/AD deprovisioning
|
||||
|
||||
- **Raised by:** Matt Mabrey, 2026-09-17, in response to a direct question about
|
||||
what happens when someone's AD account is removed and Okta subsequently drops
|
||||
them.
|
||||
- **Amends:** nothing decided, closes a gap `D15`/`D16` left unaddressed. Those
|
||||
items designed how identity flows *into* this app (Okta authenticates, this
|
||||
app JIT-provisions and owns roles); neither addressed what happens when an
|
||||
identity is withdrawn. This app has never had any deprovisioning signal —
|
||||
push or pull — since Okta went live.
|
||||
|
||||
### What was found
|
||||
|
||||
Traced through `server/auth.py` and `server/okta_auth.py`: this app has no
|
||||
connection back to Okta after initial sign-in. Consequences, confirmed against
|
||||
the actual code:
|
||||
|
||||
1. A local `users` row is never touched by anything Okta-side. `is_active`
|
||||
stays `True` indefinitely unless an admin manually disables or deletes the
|
||||
account through the User Directory. There is no flag distinguishing a
|
||||
terminated employee's account from a current one.
|
||||
2. `get_current_user` (`server/auth.py:237`) re-checks `is_active` and
|
||||
`token_version` on **every request** — so a manual disable takes effect
|
||||
immediately, on the very next request. The gap is not enforcement, it's
|
||||
detection: nothing tells an admin to go flip that switch.
|
||||
3. A session already live when someone is deprovisioned keeps working, fully,
|
||||
for up to `AUTH_SESSION_HOURS` (12 hours today, `server/auth.py:63`),
|
||||
because validity is checked against this app's own JWT and DB state only,
|
||||
never against Okta.
|
||||
4. A NEW session can't be established once Okta drops the account —
|
||||
`okta_login`/`okta_callback` requires completing Okta's own sign-in, which
|
||||
Okta itself refuses. The front door closes on its own; the back door (an
|
||||
already-open session, and the stale local record) does not.
|
||||
|
||||
### Decisions, made 2026-09-17
|
||||
|
||||
1. **Build a scheduled sync against Okta's Management API**, not an Event Hook.
|
||||
This app has, since `D15`, only ever reached out to Okta — never received
|
||||
anything from it — and a polling design keeps that shape rather than
|
||||
introducing a new inbound, internet-reachable endpoint with its own
|
||||
signature-verification surface. Traded deliberately: this is
|
||||
poll-interval-late rather than real-time, which is judged acceptable for an
|
||||
HR/offboarding-driven event, not a to-the-second requirement.
|
||||
2. **Revised 2026-09-23, in response to Matt's question about idle time
|
||||
instead of a flat session length: sessions now slide on activity, with a
|
||||
hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check
|
||||
with Okta on a fixed schedule regardless of activity; a pure idle timer
|
||||
with no ceiling does the opposite — a continuously-active session would
|
||||
never force a fresh Okta check on its own, which is a worse fit for the
|
||||
exact threat this item exists to address (someone still clicking around
|
||||
after being deprovisioned). Decided: **both**.
|
||||
- `AUTH_IDLE_MINUTES` (new, default **30**): a session with no request
|
||||
for this long stops being valid. Implemented as a sliding JWT expiry —
|
||||
the token is reissued with a fresh `exp` on activity, throttled so the
|
||||
cookie isn't rewritten on literally every request.
|
||||
- `AUTH_SESSION_HOURS` (existing var, meaning changes to an **absolute
|
||||
ceiling**): no session survives past this many hours from the original
|
||||
sign-in, no matter how continuously active it is. Default changing from
|
||||
12 to a proposed **8** — flagged as a recommendation, not confirmed.
|
||||
- Both defaults, and the mechanism itself, should be sanity-checked
|
||||
against the tenant's actual Okta SSO session policy — if Okta's own
|
||||
session silently outlives either number, re-authentication here is
|
||||
likely a fast redirect, not a real re-login screen, so these numbers
|
||||
cost less than they look like they do. Confirm before treating either
|
||||
as final.
|
||||
3. **The sync only ever disables an account — it never re-enables one.** A
|
||||
rehire showing active in Okta again does not automatically restore access;
|
||||
an admin re-enabling the account is a deliberate act, consistent with
|
||||
`D16`'s posture that this app never auto-grants access on its own initiative.
|
||||
4. **Fail closed on the side of INACTION, not disablement.** This is the
|
||||
opposite failure direction from `D16`'s login-time posture ("if Okta is
|
||||
unreachable, the app is unreachable for everyone"). Here, an Okta API
|
||||
error, timeout, empty response, or anything the sync can't confidently
|
||||
parse must result in **no change to any account** that cycle, plus a
|
||||
logged failure. A sync job that treats "couldn't reach Okta" as "nobody is
|
||||
active" is a far worse outcome than a missed cycle — it would lock out the
|
||||
entire org on an Okta API hiccup. This is the single most important
|
||||
acceptance criterion in this item.
|
||||
5. **Every auto-disable is audited individually**, same as every other
|
||||
account-state change in this app: an `AuditLog` row per user, with an actor
|
||||
value that's clearly the sync job and not a person (e.g.
|
||||
`system:okta_sync`), so it reads correctly in the User Directory's history
|
||||
and is never confused with an admin's own action.
|
||||
6. **New credential required:** a read-scoped Okta API token (or an Okta
|
||||
service-app OAuth2 client), separate from the `OKTA_CLIENT_ID`/
|
||||
`OKTA_CLIENT_SECRET` pair used for sign-in. This needs provisioning by
|
||||
whoever administers the Okta tenant — the same dependency that gated the
|
||||
original OIDC rollout (D15's "security scoping reply").
|
||||
|
||||
### Recommended, not yet confirmed — raise in the PR if this is wrong
|
||||
|
||||
- **Sync interval:** proposed every 15 minutes. Frequent enough that the
|
||||
detection gap is small, infrequent enough not to hammer Okta's API or need
|
||||
special rate-limit handling. Not confirmed with IT/security.
|
||||
- **Where the job runs:** proposed as an in-process background task inside the
|
||||
existing `api` container (it already has `outbound` network access to reach
|
||||
Okta, and already holds the Okta client config) rather than a new sidecar
|
||||
container. The `backup` container (`docker-compose.yml`) is the existing
|
||||
precedent for a scheduled-interval container in this stack, if isolation
|
||||
from the `api` process is preferred instead — a reasonable alternative, not
|
||||
the recommendation.
|
||||
- **Admin visibility:** at minimum, an auto-disable is a normal, readable
|
||||
`AuditLog` entry (visible whever admin already reviews audit history, and
|
||||
naturally covered once `CR-019`'s activity view exists). Whether it should
|
||||
also trigger an email/notification to admins is a genuine open question —
|
||||
proposed as a fast-follow rather than blocking this item, since `D10`
|
||||
already established the pattern for admin-controlled email toggles this
|
||||
could reuse.
|
||||
|
||||
### Frontend/backend boundary
|
||||
|
||||
Entirely server-side and infrastructure. No new `localStorage` state, no
|
||||
frontend surface beyond what already reads `is_active` and `AuditLog` today
|
||||
(the User Directory, and eventually `CR-019`'s activity view).
|
||||
|
||||
### Scheduling
|
||||
|
||||
New wave, independent of wave 11 and wave 12 in the sense that nothing here is
|
||||
blocked by them — but note it touches the same `is_active`/account-state
|
||||
surface `CR-020`'s bulk actions touch in `server/app.py`. Not a hard
|
||||
dependency; sequence commits to avoid an avoidable merge conflict, per
|
||||
`CLAUDE.md`'s "one task per PR" spirit. Task breakdown: `docs/waves/wave-13.md`.
|
||||
85
docs/waves/notes-2026-09-23.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Session notes — 2026-09-23
|
||||
|
||||
Working notes for the `feat/waves-11-13` line (CR-019, CR-020 reserved, D18),
|
||||
written up before merge. Not a spec document — `wave-11.md`, `wave-13.md` and
|
||||
`decisions-2026-09-17.md` are the source of truth for scope and acceptance
|
||||
criteria. This is the "what actually happened building it" record.
|
||||
|
||||
## What shipped today
|
||||
|
||||
**CR-019 (wave 11) — usage/activity metrics, T11.1 through T11.6 complete:**
|
||||
|
||||
- `UsageEvent` table + migration (T11.1), a capture endpoint wired into
|
||||
`auth-guard.js` so every protected page pings it once per load, plus a
|
||||
`login` event at Okta sign-in (T11.2).
|
||||
- `GET /api/usage/summary` (T11.3) — active users by day/week/month,
|
||||
per-user last-active, per-tool breakdown, filterable by date/project/
|
||||
user/tool, all combinable.
|
||||
- `GET /api/usage/export` (T11.4) — raw and sanitized CSV. Sanitized mode
|
||||
replaces the username with an HMAC-SHA256 pseudonym (keyed with
|
||||
`auth.SECRET_KEY`), stable per user across rows and across separate
|
||||
export calls, so an external tool (Power BI etc.) can still group by
|
||||
user without ever seeing a real name.
|
||||
- A new "Activity & usage" card in the admin console (T11.5): real filter
|
||||
controls, the summary tables, both export buttons. Client-side gated
|
||||
admin-only same as the rest of the console; the API underneath is
|
||||
independently gated server-side regardless.
|
||||
- Retired the old per-browser "Usage logs" panel and `wp-usage.js`
|
||||
entirely (T11.6) — it had no reader left and was never a real data
|
||||
source for the new report anyway. The scattered `track()` call sites in
|
||||
the creator and wizard were left in place calling a documented no-op,
|
||||
rather than deleting ~45 individual call sites for the same effect.
|
||||
|
||||
Only **T11.7 (final wave verification)** is left before wave 11 is fully
|
||||
closed out — everything under it has already been verified per-task, so
|
||||
this is a consolidation pass, not new work.
|
||||
|
||||
**D18 (wave 13) — Okta/AD deprovisioning, T13.1 only:**
|
||||
|
||||
- Session lifetime changed from one flat `AUTH_SESSION_HOURS` to a sliding
|
||||
idle timeout (`AUTH_IDLE_MINUTES`, default 30) capped by a hard ceiling
|
||||
from original sign-in (`AUTH_SESSION_HOURS`, default 8, meaning changed
|
||||
from "session length" to "absolute ceiling"). Both defaults are flagged
|
||||
in `.env.example` and `DEPLOYMENT.md` as proposed, not confirmed against
|
||||
the tenant's actual Okta SSO policy.
|
||||
- **T13.2 onward (the actual Okta Management API sync job) is paused** —
|
||||
explicit call from Matt: no Okta API credential yet, come back to it
|
||||
later. Not started, not blocked on anything code-side.
|
||||
|
||||
**CR-020 (wave 12, bulk user editing):** not started. Reserved, scoped in
|
||||
`wave-12.md`, no code touched.
|
||||
|
||||
## Environment work (not itself a task, but load-bearing)
|
||||
|
||||
- Fixed a CRLF/LF mismatch that was making every tracked file look modified
|
||||
to this session's git client (`core.autocrlf true`, repo-local, no file
|
||||
content changed).
|
||||
- This machine had no Python. Installed it via `winget` (`Python.Python.3.12`)
|
||||
and set up a `.venv` in the repo with `server/requirements.txt` installed,
|
||||
specifically so `tests/baseline_shots.py` could run locally — this
|
||||
sandbox has no headless-capable browser and can't download one (network
|
||||
allowlist), so the 390px/1440px screenshot verification CLAUDE.md asks
|
||||
for had to run on Matt's own machine instead, using the browser already
|
||||
installed there (Edge).
|
||||
- Established a repeatable local verification loop for every task: throwaway
|
||||
SQLite, fake-Okta sign-in, promote to admin, `seed_demo.py` +
|
||||
`smoketest.py` (27/27 passing throughout), plus `baseline_shots.py` for
|
||||
anything touching `html/`.
|
||||
|
||||
## Standing constraints, still in effect
|
||||
|
||||
- Everything stays local on this branch line. No `git push` at any point
|
||||
today.
|
||||
- One task per PR discipline was kept even though all of it landed on one
|
||||
branch — each commit corresponds to exactly one task ID, in wave order,
|
||||
each individually verified before the next started.
|
||||
|
||||
## Before merging
|
||||
|
||||
- Run T11.7 (full wave-11 verification pass) and record it in `wave-11.md`.
|
||||
- Decide where this branch actually merges to — `feat/waves-11-13` has all
|
||||
of today's commits already; this notes branch was cut from it so it can
|
||||
fast-forward back in, or merge as its own PR if the notes should be
|
||||
reviewed separately from the code.
|
||||
- T13.2+ and all of wave 12 remain explicitly out of scope until Matt says
|
||||
otherwise.
|
||||
@@ -180,6 +180,6 @@ The script authenticates like a client; the server does not get weaker.
|
||||
|
||||
- [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots
|
||||
- [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`)
|
||||
- [ ] `S13` fixed and seeding works
|
||||
- [x] `S13` fixed and seeding works (ticked 2026-08-20: the box was missed at the wave exit; re-verified end to end - sign-in, seed, --clean)
|
||||
- [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced
|
||||
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`
|
||||
|
||||
233
docs/waves/wave-10.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Wave 10 — Okta OIDC authentication
|
||||
|
||||
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
|
||||
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared
|
||||
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
|
||||
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
|
||||
and Okta chosen instead.
|
||||
|
||||
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
|
||||
|
||||
## Tasks
|
||||
|
||||
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars
|
||||
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same
|
||||
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
|
||||
|
||||
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to
|
||||
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and
|
||||
validates the ID token. Access gating is Okta's job, not this app's: only accounts
|
||||
assigned to the app integration in Okta can reach it at all, so there is no app-side
|
||||
required-group or claim check layered on top. This is a deliberate difference from D13,
|
||||
which had to gate on a required AD group itself because an LDAPS bind alone could not
|
||||
distinguish an assigned user from any other domain account.
|
||||
|
||||
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
|
||||
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
|
||||
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
|
||||
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
|
||||
yet answered. Build with a configurable claim name and a documented default, not a
|
||||
hardcoded one, so the answer can drop in without a code change.
|
||||
|
||||
- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic
|
||||
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
|
||||
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
|
||||
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
|
||||
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
|
||||
review turned up more call sites than the original bullet named:
|
||||
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
|
||||
"add user" and "reset password" routes) — rework to drop the password field
|
||||
entirely rather than break.
|
||||
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
|
||||
matching frontend change.
|
||||
- `server/manage_users.py` — reworked per `D16` from account *creation* to
|
||||
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
|
||||
replaced by a promote-by-username command that operates on a row Okta's JIT
|
||||
provisioning (T10.3) already created, never a hand-typed new one. This is now
|
||||
the documented admin-bootstrap path — see `D16`.
|
||||
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
|
||||
`auth.hash_password()` to seed fixture rows.
|
||||
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
|
||||
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
|
||||
directly, the same technique `browser_check.py` already uses, so both scripts
|
||||
(named explicitly in `CLAUDE.md`'s verification section) keep working without
|
||||
depending on `T10.7`'s timing.
|
||||
|
||||
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js`
|
||||
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page.
|
||||
|
||||
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
|
||||
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
|
||||
config they never ended up describing (D13 never shipped, so these still describe the
|
||||
original local-password system today).
|
||||
|
||||
Built: all three rewritten — the five `OKTA_*` vars documented the same way
|
||||
`AUTH_SECRET_KEY` already was, the login-portal/self-service-reset sections replaced
|
||||
with the Okta flow and the promote-not-create admin bootstrap (D16), the smoke-test
|
||||
walkthrough updated for `WP_SMOKE_USER`-only / must-share-`AUTH_SECRET_KEY`-and-DB
|
||||
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
|
||||
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
|
||||
`env_file`, so the documented vars would silently never reach the container without
|
||||
adding them there too — found and fixed in the same commit rather than shipping docs
|
||||
for a config path that doesn't work. `OKTA_IDENTITY_CLAIM` mirrors
|
||||
`okta_auth.py`'s own default (`preferred_username`) in the compose file rather than
|
||||
defaulting to an empty string, which would 503 every sign-in.
|
||||
|
||||
Logged, not fixed here (out of scope): `users.failed_attempts`/`locked_until` are
|
||||
vestigial (still reset on every Okta sign-in, nothing increments them since local
|
||||
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section
|
||||
is a self-contained alternate quickstart that already diverged from the real root
|
||||
`docker-compose.yml` before this task and still does.
|
||||
|
||||
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
|
||||
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable.
|
||||
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing
|
||||
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
|
||||
the real Authlib client is considered. Only the two Authlib calls that touch the
|
||||
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
|
||||
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
|
||||
provisioning, the identity-claim lookup) run unmodified against the fake, same
|
||||
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
|
||||
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
|
||||
screen and are registered in `app.py` only when the fake is active at import time —
|
||||
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
|
||||
`start_server()` now takes an optional `extra_env` and sets
|
||||
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
|
||||
used: almost nothing signs in, but the one check that does should not fail
|
||||
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
|
||||
real round trip — login.html's button, the fake picker page, the fake consent
|
||||
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
|
||||
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
|
||||
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
|
||||
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
|
||||
an unknown identity, a disabled account, JIT provisioning, an existing admin
|
||||
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
|
||||
genuinely working under a non-default claim name — 22/22.
|
||||
|
||||
The dev sandbox this was built in has no headless browser and no way to install
|
||||
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
|
||||
`tests/cdp.py`'s real headless Chromium) could not be run there — only
|
||||
`okta_auth_check.py`'s HTTP-level coverage of the same mechanism. Run for real
|
||||
afterward on a machine with Docker, via a separate general-purpose tool
|
||||
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
|
||||
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
|
||||
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
|
||||
|
||||
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
|
||||
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
|
||||
clean, the last two against real headless Chromium via Docker.
|
||||
|
||||
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
|
||||
matching the rigor D13 was held to.
|
||||
|
||||
Done-when checks per task, verified against the actual code rather than
|
||||
re-reading this file's own claims: T10.1 (Authlib pinned, the four env vars,
|
||||
`is_configured()`/`describe()`), T10.2 (the login/callback routes, no
|
||||
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
|
||||
matching, JIT at the lowest role, local deprovisioning still enforced after
|
||||
Okta approves), T10.4 (zero remaining references to `bcrypt` /
|
||||
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
|
||||
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
|
||||
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
|
||||
T10.5 (`login.html` is one Okta link, no password field). All matched what
|
||||
this file already claimed — no drift found.
|
||||
|
||||
Full suite, run through the Docker test runner (all 41 files in `tests/`,
|
||||
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
|
||||
but that is a harness mismatch, not a check failure — it is a two-step
|
||||
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
|
||||
usage + exits 2 when run with no arguments, which is what a bare full-suite
|
||||
pass does to every file. `tests/generalinfo_check.py` scored 48/49 — the one
|
||||
failure is a raw `rgba()` shadow literal in `wp-creation-styles.css`,
|
||||
confirmed via `git show HEAD` to already be committed and unrelated to this
|
||||
wave (it is the Micron asset picker's dropdown shadow from the `D11` merge,
|
||||
2026-08-20, predating this wave by two weeks). Logged as `BL-031` rather than
|
||||
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
|
||||
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
|
||||
|
||||
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots
|
||||
(login, launcher, sop, creator, admin, users, field × two widths) into
|
||||
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and
|
||||
`users-390.png`/`users-1440.png` visually confirmed: the login page is a
|
||||
single "Sign in with Okta" button with no username/password form at either
|
||||
width, and the User Directory's table and "Add a user" form both carry no
|
||||
password column and no reset-password action anywhere, at either width.
|
||||
|
||||
Wave 10 is complete. The three items in "Still open" below are external
|
||||
(security team / Okta admin), not blocked on any task in this wave.
|
||||
|
||||
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8:
|
||||
`cmd_promote()` changed a user's role with no audit trail at all, unlike the
|
||||
identical role change from the web Admin Console (`app.py`'s
|
||||
`set_user_role()` → `log_event()`, action `"role_changed"`). Not a new
|
||||
privilege — anyone with Portainer/container-exec access to `wp_api` already
|
||||
has shell access to the database directly, same trust tier D16 already named
|
||||
for this command — but there was no record of who ran it or what changed.
|
||||
|
||||
Built: `cmd_promote()` now writes an `AuditLog` row with the same
|
||||
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to":
|
||||
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via":
|
||||
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec
|
||||
carries no signed-in identity to attribute the change to a real person, so
|
||||
it names the tool rather than guessing one. Verified end to end against a
|
||||
scratch SQLite database: the audit row lands with the exact expected shape,
|
||||
the role change persists, and the existing "no such user" refusal still
|
||||
exits 1 with no partial write.
|
||||
|
||||
- **T10.9 — Rollback-aware deploy runbook.** Raised after hazard review found
|
||||
`DEPLOY-runbook-2026-08-04.md`'s Rollback section has no case for a migration whose
|
||||
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
|
||||
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
|
||||
bcrypt hashes do not.
|
||||
|
||||
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure
|
||||
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names
|
||||
the five `OKTA_*` vars as newly required (the precedent's "no new environment
|
||||
variables" note does not carry over), treats the pre-deploy backup as the only way
|
||||
back once the migration commits, and splits Rollback into the fixable case (Okta app
|
||||
integration misconfigured — fix and redeploy `api`, no data at risk, migration stays
|
||||
applied) versus the severe case (abandoning Okta for local-password code — only the
|
||||
destructive backup restore gets there, reusing the 2026-08-04 runbook's own Case C
|
||||
procedure). D16's no-break-glass posture is stated plainly rather than left implicit.
|
||||
|
||||
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line
|
||||
(referenced by `DEPLOYMENT.md`/`server/README.md`) has no caller anywhere in
|
||||
`server/app.py` — nothing actually prints it at process start. The runbook's Step 4
|
||||
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
|
||||
flagged in `docs/waves/backlog.md` as a candidate fix (wiring `describe()` into
|
||||
startup) since it directly bears on deploy verifiability. `DEPLOY-login-portal.md`
|
||||
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
|
||||
not touched, no task claims it.
|
||||
|
||||
## Still open
|
||||
|
||||
- The `Business Technology Group` pilot assignment in Okta. Originally six names
|
||||
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
|
||||
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
|
||||
deliberately, pending the live sign-in confirmation below — awaiting his response to
|
||||
add the rest of the group now that it has.
|
||||
|
||||
Closed since first written: admin bootstrap and break-glass posture, previously open
|
||||
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
|
||||
|
||||
**Closed 2026-09-09, live in production:** the redirect/callback URI
|
||||
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
|
||||
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
|
||||
never actually confirmed by name in Request 50649's thread) is correct, no
|
||||
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
|
||||
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
|
||||
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
|
||||
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
|
||||
users, not the seeded test fixture), landed on `index.html` signed in, admin role and
|
||||
project access untouched. One real deploy-time snag on the way, worth recording since
|
||||
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first
|
||||
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in
|
||||
Portainer (env var names added, values never filled in) — caught via
|
||||
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
|
||||
temporarily unavailable"), not silently. A second snag after filling those in:
|
||||
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
|
||||
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
|
||||
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
|
||||
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
|
||||
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
|
||||
neither needed the backup or the database.
|
||||
257
docs/waves/wave-11.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Wave 11 — Usage and activity metrics
|
||||
|
||||
**Items:** `CR-019`
|
||||
**Depends on:** wave 10 merged (it is; this wave does not wait on anything else)
|
||||
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||
|
||||
Seven tasks, one concern each, in build order. Do not start a task whose
|
||||
dependency is not merged. `CR-020` (bulk user editing) is reserved but not
|
||||
scoped — it does not belong in this wave.
|
||||
|
||||
---
|
||||
|
||||
### T11.1 — CR-019: `usage_events` table + migration
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** nothing (first task)
|
||||
- **Blocks:** T11.2
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/models.py`, `server/alembic/versions/`
|
||||
|
||||
**Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for
|
||||
navigation/feature-open events rather than business mutations. Suggested shape:
|
||||
`id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor`
|
||||
does today for consistency), `project_id` (nullable — not every event is
|
||||
project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`,
|
||||
`wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g.
|
||||
`page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not
|
||||
touch `AuditLog` — this is a new table, not an extension of it (see the
|
||||
decision record's reasoning).
|
||||
|
||||
**Do not:** fold this into `AuditLog`. They serve different questions and mixing
|
||||
them makes the existing audit trail noisier for its existing readers.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned
|
||||
by date range constantly)
|
||||
- [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) —
|
||||
render `alembic upgrade --sql` for postgresql and read it before calling
|
||||
this done, per the class of defect `BL-027` logged
|
||||
- [ ] no change to `AuditLog`'s shape or behavior
|
||||
|
||||
---
|
||||
|
||||
### T11.2 — CR-019: capture the events
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.1
|
||||
- **Blocks:** T11.3
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** `server/app.py` (new endpoint), `html/auth-guard.js`
|
||||
|
||||
**Problem:** Six pages need this and none should implement it separately — that
|
||||
is exactly how `S4`'s "no global nav on two pages" and the four parallel token
|
||||
systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `<head>`,
|
||||
on all six protected pages (`index.html`, `field.html`, `users.html`,
|
||||
`wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already
|
||||
knows the verified user once the `wp-auth-ready` event fires. That is the one
|
||||
place this belongs.
|
||||
|
||||
**Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one
|
||||
`UsageEvent` row per call, keyed to the session (server trusts the session, not
|
||||
anything the client claims about identity). Call it once from `auth-guard.js`
|
||||
after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a
|
||||
`login` event at the point a session is actually established (reuse whatever
|
||||
`okta_callback` already does at sign-in — do not add a second source of truth
|
||||
for "did this person log in").
|
||||
|
||||
**Do not:** build a per-page capture call. If a page needs this and
|
||||
`auth-guard.js` does not cover it, fix `auth-guard.js`, not the page.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] one `page_open` event is recorded for a real sign-in on each of the six
|
||||
pages, verified per page
|
||||
- [ ] exactly one `login` event per Okta sign-in, not one per page load after
|
||||
it
|
||||
- [ ] the endpoint rejects a request with no valid session (this is server-
|
||||
enforced identity, not client-reported)
|
||||
- [ ] no page other than `auth-guard.js` calls this endpoint directly
|
||||
|
||||
---
|
||||
|
||||
### T11.3 — CR-019: aggregation endpoint with filters
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.2
|
||||
- **Blocks:** T11.4, T11.5
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/app.py`
|
||||
|
||||
**Do:** Build the read side: active-user counts by day/week/month, per-user
|
||||
last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`,
|
||||
which only ever holds one value), and a per-tool usage breakdown. Accept query
|
||||
filters: date range, project, user, tool — combinable, per the decision record.
|
||||
This is server aggregation, the same principle `B4` established for the
|
||||
pipeline strip: the browser asks for a number, the server computes it from real
|
||||
rows, nothing is derived client-side from a partial cache.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] active-user counts are correct against a seeded fixture with known dates
|
||||
- [ ] filters combine correctly (verified: user + date range + tool together
|
||||
narrows correctly, not just each alone)
|
||||
- [ ] a project filter that matches nothing returns an empty result, not an
|
||||
error or the unfiltered total
|
||||
|
||||
---
|
||||
|
||||
### T11.4 — CR-019: export, raw and sanitized
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.3
|
||||
- **Blocks:** T11.5
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/app.py`
|
||||
|
||||
**Do:** A CSV export endpoint over the same filtered query T11.3 exposes.
|
||||
Two modes: raw (real usernames, the console's default) and sanitized. Sanitized
|
||||
mode replaces the actor field with a stable pseudonymous id — a per-user hash,
|
||||
consistent across rows in the same export and across separate export runs —
|
||||
so an external system (Power BI or similar) can still group and trend "by
|
||||
user" without ever receiving a real name. Do not simply drop the identity
|
||||
column; that breaks per-user grouping downstream, which defeats the point of
|
||||
an activity export.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] raw export contains real usernames
|
||||
- [ ] sanitized export never contains a real username or email anywhere in the
|
||||
file, including in a `detail` blob if one is included
|
||||
- [ ] the same real user maps to the same pseudonymous id within one export AND
|
||||
across two separate export runs (a hash of something stable, not a
|
||||
per-request random id)
|
||||
- [ ] both modes otherwise contain identical rows for the same filter
|
||||
|
||||
---
|
||||
|
||||
### T11.5 — CR-019: admin console Activity tab
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.3, T11.4
|
||||
- **Blocks:** T11.7
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/admin.html`, `html/admin.js`
|
||||
|
||||
**Do:** New tab, same role gate as the User Directory. Filters (date range,
|
||||
project, user, tool) driving the tables from T11.3; export buttons (raw and
|
||||
sanitized) calling T11.4. Build accessible from the start per `C1` — this is a
|
||||
new component, not a legacy one carrying an old defect forward: real
|
||||
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
|
||||
count that updates without a page reload, focus visible throughout.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] the tab is reachable only by an admin (the card lives inside admin.html,
|
||||
already gated client-side by gateByRole(); the API underneath it is
|
||||
independently gated server-side by require_user_manager regardless)
|
||||
- [x] every filter is a real form control, keyboard-operable (date/select/text
|
||||
inputs and a `<button>`, no click-div)
|
||||
- [x] both export buttons produce the files T11.4 defines (verified against
|
||||
the live endpoint in T11.4's own checks, and present/wired here)
|
||||
- [x] works at 390px and 1440px — verified 2026-09-23 via
|
||||
`tests/baseline_shots.py --pages admin` run locally on Windows (this
|
||||
sandbox has no headless browser available; the script was run on the
|
||||
user's machine instead, after installing Python via winget since it
|
||||
wasn't present). Screenshots in `docs/reference/baseline/admin-390.png`
|
||||
/ `admin-1440.png`. No JS errors, no horizontal overflow at either
|
||||
width; the card rendered with real seeded data (events, by-tool,
|
||||
per-user-last-active tables) confirming the filters and summary read
|
||||
correctly, not just that the markup exists.
|
||||
|
||||
---
|
||||
|
||||
### T11.6 — CR-019: retire the per-browser Usage report
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.5
|
||||
- **Blocks:** T11.7
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
|
||||
`html/wp-usage.js` and its two call sites
|
||||
|
||||
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
|
||||
the real one exists, per the 2026-09-17 decision. Decide what happens to
|
||||
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
|
||||
equivalent): they were never reliably tied to a real identity, so they are not
|
||||
a data source the new report can adopt. Default to removing the recorder too
|
||||
unless it is still doing something useful on its own (re-read what it actually
|
||||
records before deciding — do not assume from this file alone).
|
||||
|
||||
**Do not:** leave the old panel in place "just in case." Two activity reports
|
||||
showing two different numbers is worse than one.
|
||||
|
||||
**Decision (2026-09-23):** `wp-usage.js` is removed, not kept — it had no
|
||||
reader left once the admin panel above it was removed (the "download the
|
||||
full event log" button lived only in that panel), and per the original
|
||||
decision record it was never reliably tied to a real identity, so it was
|
||||
never a candidate source for the new report either. The file itself and its
|
||||
three `<script>` includes (`admin.html`, `wp-creation-index.html`,
|
||||
`work-package-suite.html`) are gone. Its two call sites
|
||||
(`wp-creation-app.js`, `work-package-suite-app.js`) keep a local `track()`
|
||||
function as a documented no-op rather than having each of their ~45
|
||||
individual `track('event', …)` call sites deleted one at a time — that
|
||||
would be a far larger, riskier diff for the same outcome (no more data is
|
||||
recorded either way), and it keeps each call site as a marker of what was
|
||||
worth recording if usage analytics are ever rebuilt server-side. The
|
||||
dwell-timer plumbing that only ever fed `track()` (`work-package-suite-app.js`'s
|
||||
`_stepEnter`/`trackStepDwell`) was left in place for the same reason — it is
|
||||
inert now, not broken, and touching it buys nothing.
|
||||
|
||||
Also removed: `tests/usage_check.py` (tested exactly the retired feature —
|
||||
D5/T7.10's per-browser analytics core and admin report) and its line in
|
||||
`docs/reference/file-map.md`, replaced with a note pointing at this decision.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] the old `usage-admin` panel and its markup are gone from `admin.html`/
|
||||
`admin.js`
|
||||
- [x] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
|
||||
stated reason) — not left ambiguous — see above
|
||||
- [x] nothing else in the app references the removed code; grep confirms
|
||||
(only remaining hits are this file, the 2026-09-17 decision record, and
|
||||
the two explanatory code comments left at the retired call sites — all
|
||||
prose, not live references)
|
||||
|
||||
---
|
||||
|
||||
### T11.7 — CR-019: verification
|
||||
|
||||
- **Items:** `CR-019`
|
||||
- **Depends on:** T11.6
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/` + `server/`
|
||||
- **Files:** as touched above
|
||||
|
||||
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
|
||||
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
|
||||
test and `seed_demo.py`, run the full suite.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
|
||||
a failure is stated with a reason
|
||||
- [ ] screenshots committed
|
||||
- [ ] smoke test and `seed_demo.py` both still pass
|
||||
- [ ] full test suite passes
|
||||
|
||||
---
|
||||
|
||||
## Wave 11 exit criteria
|
||||
|
||||
- [ ] real, server-side activity data exists per user, indefinitely retained
|
||||
- [ ] the admin console shows it, filterable by date/project/user/tool
|
||||
- [ ] export works in both raw and sanitized form
|
||||
- [ ] the old per-browser report is gone, not duplicated
|
||||
- [ ] `CR-019` fully accounted for, no open acceptance criteria
|
||||
193
docs/waves/wave-12.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Wave 12 — Bulk editing of users
|
||||
|
||||
**Items:** `CR-020`
|
||||
**Depends on:** wave 10 merged (it is). Independent of wave 11 (`CR-019`) — no
|
||||
shared files, may build in either order or in parallel.
|
||||
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||
|
||||
Six tasks, in build order.
|
||||
|
||||
---
|
||||
|
||||
### T12.1 — CR-020: bulk endpoints
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** nothing (first task)
|
||||
- **Blocks:** T12.2, T12.4
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/app.py`
|
||||
|
||||
**Do:** Add bulk variants of the four existing single-user actions — role
|
||||
change, active/disabled, project assignment (add/remove + project-role), and
|
||||
delete. Each takes a list of `user_id`s plus the action's parameters and
|
||||
applies `require_see_user`/`require_manage_user`/`grantable_roles` **per row**,
|
||||
exactly as the single-user endpoint does today — a super user's bulk request
|
||||
cannot reach further than their existing single-user requests can. Do not skip
|
||||
the self-action guard: an actor's own account is rejected out of any batch
|
||||
that would disable, demote, or delete it, same as today.
|
||||
|
||||
Each successful row writes its own `AuditLog` entry via `log_event`, same
|
||||
action names the single endpoints already use. A row that fails is reported in
|
||||
the response (user id, reason) and does not stop the rest of the batch from
|
||||
being attempted.
|
||||
|
||||
**Do not:** invent a single opaque "bulk_action" audit entry in place of the
|
||||
per-row entries. Do not build a soft-delete path for bulk delete that doesn't
|
||||
exist for the single case — bulk delete stays a hard delete, matching
|
||||
`delete_user` today.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] each of the four bulk actions is callable with a list of user ids and a
|
||||
single set of parameters
|
||||
- [ ] a batch containing the actor's own account rejects only that row, not
|
||||
the whole batch — verified for disable, demote, and delete
|
||||
- [ ] a super user's batch that includes a user/project outside what they
|
||||
manage rejects only that row, with a stated reason
|
||||
- [ ] every successful row produces its own `AuditLog` entry, identical in
|
||||
shape to what the single-user endpoint would have written
|
||||
- [ ] a batch with some failing rows still applies to the rows that succeed,
|
||||
and the response lists exactly which rows failed and why
|
||||
|
||||
---
|
||||
|
||||
### T12.2 — CR-020: row selection in the User Directory table
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** T12.1
|
||||
- **Blocks:** T12.3
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/users.js`, `html/users.html`
|
||||
|
||||
**Do:** Add a checkbox per row and a select-all control, respecting whatever
|
||||
filter (`role`, `active`/`disabled`, project) is currently applied — select-all
|
||||
selects the filtered set, not every user in the system regardless of what's
|
||||
shown. A bulk-action toolbar appears once at least one row is checked and
|
||||
disappears at zero.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] select-all selects exactly the rows currently visible under the active
|
||||
filter, not the full unfiltered table
|
||||
- [ ] changing the filter while rows are selected does something sane and
|
||||
visible (either clears the selection or keeps it explicit which rows are
|
||||
still selected) — pick one and state it, don't leave it undefined
|
||||
- [ ] the toolbar is keyboard-reachable and only present when >=1 row is
|
||||
selected
|
||||
|
||||
---
|
||||
|
||||
### T12.3 — CR-020: bulk-action toolbar
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** T12.2
|
||||
- **Blocks:** T12.5
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/users.js`
|
||||
|
||||
**Do:** Wire the toolbar to T12.1's endpoints for role change, activate/
|
||||
disable, and project assignment (add to / remove from a project + project-
|
||||
role). Confirmation before applying uses the `wp-dialog` kit (`T7.9`) —
|
||||
`wpConfirmDialog`, not a native `confirm()` — naming exactly how many users are
|
||||
affected. On completion, report per-row results if anything failed (T12.1
|
||||
already returns this) rather than a single success/failure toast that hides a
|
||||
partial failure.
|
||||
|
||||
**Delete is built separately, in T12.5** — do not wire delete here.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] role change, activate/disable, and project assignment each work end to
|
||||
end against a multi-row selection
|
||||
- [ ] the confirmation dialog names the exact affected count before anything is
|
||||
sent
|
||||
- [ ] a batch with a partial failure shows which rows failed, not just an
|
||||
undifferentiated error
|
||||
- [ ] `aria-live` announces the outcome
|
||||
|
||||
---
|
||||
|
||||
### T12.4 — CR-020: CSV upload path
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** T12.1
|
||||
- **Blocks:** T12.6
|
||||
- **Surface:** `html/` + `server/`
|
||||
- **Files:** `html/users.js`, `server/app.py`
|
||||
|
||||
**Do:** An upload accepting a list of usernames plus the action to apply,
|
||||
following the validate-and-report pattern `CR-005` established: reject and
|
||||
report bad rows (username not found, actor lacks permission over that user)
|
||||
rather than silently dropping them. This is a second entry point onto the same
|
||||
T12.1 endpoints, not a third implementation of the bulk logic.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a CSV with a mix of valid and invalid usernames applies to the valid
|
||||
rows and reports the invalid ones by row, with a reason
|
||||
- [ ] the same permission/self-action guards from T12.1 apply here — a CSV
|
||||
cannot reach a user a checkbox-driven batch couldn't
|
||||
- [ ] duplicate usernames in one CSV are handled without double-applying or
|
||||
erroring confusingly
|
||||
|
||||
---
|
||||
|
||||
### T12.5 — CR-020: bulk delete confirmation
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** T12.3
|
||||
- **Blocks:** T12.6
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/users.js`
|
||||
|
||||
**Do:** Wire delete into the toolbar with a heavier confirmation than the other
|
||||
three actions, per the decision record's recommendation: list the affected
|
||||
usernames and require typing a confirmation phrase (e.g. `DELETE`) before the
|
||||
request is sent, regardless of how many rows are selected. This is flagged in
|
||||
the decision record as a recommendation Matt has not explicitly signed off on
|
||||
— if the PR reviewer wants a lighter or heavier mechanism, that's the moment to
|
||||
change it, not a reason to skip building a real confirmation now.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the affected usernames are listed in the confirmation dialog before
|
||||
delete is sent
|
||||
- [ ] the request is not sent until the confirmation phrase is typed correctly
|
||||
- [ ] the actor's own account, if somehow selected, is rejected with a clear
|
||||
reason rather than silently included or silently dropped
|
||||
|
||||
---
|
||||
|
||||
### T12.6 — CR-020: verification
|
||||
|
||||
- **Items:** `CR-020`
|
||||
- **Depends on:** T12.4, T12.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/` + `server/`
|
||||
- **Files:** as touched above
|
||||
|
||||
**Do:** Full verification per `CLAUDE.md`: run locally, exercise bulk role
|
||||
change, activate/disable, project assignment, CSV upload, and bulk delete at
|
||||
390px and 1440px, before/after screenshots, smoke test, `seed_demo.py`, full
|
||||
suite.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all `CR-020` acceptance criteria in `decisions-2026-09-17.md` are met or
|
||||
a failure is stated with a reason
|
||||
- [ ] screenshots committed
|
||||
- [ ] smoke test and `seed_demo.py` both still pass
|
||||
- [ ] full test suite passes
|
||||
|
||||
---
|
||||
|
||||
## Wave 12 exit criteria
|
||||
|
||||
- [ ] all four bulk actions work against both a checkbox selection and a CSV
|
||||
upload
|
||||
- [ ] every existing single-user guardrail (self-action, scope, grantable
|
||||
roles) holds under bulk use
|
||||
- [ ] bulk delete requires typed confirmation and lists affected usernames
|
||||
- [ ] partial failures are always reported, never hidden behind a blanket
|
||||
success
|
||||
- [ ] `CR-020` fully accounted for, no open acceptance criteria
|
||||
242
docs/waves/wave-13.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Wave 13 — Okta/AD deprovisioning sync
|
||||
|
||||
**Items:** `D18`
|
||||
**Depends on:** wave 10 merged (it is). Not blocked by wave 11 or wave 12, but
|
||||
shares `server/app.py` account-state surface with wave 12 (`CR-020`) — sequence
|
||||
commits to avoid an avoidable conflict.
|
||||
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||
|
||||
Six tasks, in build order. **T13.3's fail-closed behavior is the most
|
||||
important done-when list in this wave — do not relax it to ship faster.**
|
||||
|
||||
---
|
||||
|
||||
### T13.1 — D18: idle timeout with an absolute ceiling
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** nothing (first task, independent of the rest)
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/auth.py`, `server/app.py` (`auth_gate` middleware),
|
||||
`server/.env.example`, `DEPLOYMENT.md`
|
||||
|
||||
**Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
|
||||
asked whether an idle timeout would be a better fit than a flat session
|
||||
length. It is, but not by itself — see the decision record's reasoning on why
|
||||
idle-with-no-ceiling is actually a worse fit for this item's own threat model
|
||||
than a flat expiry would have been. Build both.
|
||||
|
||||
**Do:**
|
||||
|
||||
- Add `login_at` to the JWT payload in `create_token()` — the original
|
||||
sign-in time, distinct from `iat`, which becomes "when THIS token was
|
||||
issued" once tokens start getting reissued. `login_at` never changes across
|
||||
reissues; it's what the absolute ceiling is measured from.
|
||||
- Add `AUTH_IDLE_MINUTES` (default 30). `AUTH_SESSION_HOURS` stays the name
|
||||
for the absolute ceiling, default changing from 12 to a proposed 8 — update
|
||||
its docstring/comment in `auth.py` and `.env.example`, since its MEANING is
|
||||
changing (session length -> hard ceiling on top of a sliding idle window),
|
||||
not just its value.
|
||||
- In `auth_gate` (`server/app.py`), after confirming a request is
|
||||
authenticated: if `now < login_at + AUTH_SESSION_HOURS` (the ceiling hasn't
|
||||
passed), compute `new_exp = min(now + AUTH_IDLE_MINUTES, login_at +
|
||||
AUTH_SESSION_HOURS)`. If `new_exp` is meaningfully later than the current
|
||||
token's `exp` (throttle this — do not reissue on every single request, only
|
||||
when enough time has passed to be worth a new cookie; a few minutes of
|
||||
slack is fine), mint a refreshed token carrying forward `sub`/`username`/
|
||||
`role`/`ver`/`login_at` unchanged, and set it on the response.
|
||||
- If the ceiling HAS passed, do not refresh — let the existing token expire
|
||||
on its own terms (it may already be invalid, or may tick over within the
|
||||
idle window; either way, no new one is issued past the ceiling).
|
||||
- The middleware should not need a DB round trip to do this — everything
|
||||
needed (`sub`, `username`, `role`, `ver`, `login_at`) is already in the
|
||||
validated claims. `get_current_user`'s existing per-request `is_active`/
|
||||
`token_version` check is unaffected and still runs separately.
|
||||
|
||||
**Do not:** reissue the cookie on every request unconditionally — that's a
|
||||
`Set-Cookie` header on every API call for no benefit over a coarser refresh.
|
||||
Do not let the ceiling check silently vanish for tokens issued before this
|
||||
change ships — a token with no `login_at` claim should fall back to treating
|
||||
its own `iat` as `login_at`, not bypass the ceiling entirely.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a session with continuous activity stays alive past 30 minutes but is
|
||||
cut off at the `AUTH_SESSION_HOURS` ceiling regardless
|
||||
- [ ] a session with no activity for 30+ minutes is rejected on its next
|
||||
request
|
||||
- [ ] the cookie is not rewritten on every single request — verify the
|
||||
refresh is throttled, not unconditional
|
||||
- [ ] a pre-existing token with no `login_at` claim (simulating a session
|
||||
issued before this shipped) still gets a hard ceiling, via the `iat`
|
||||
fallback
|
||||
- [ ] `.env.example` and `DEPLOYMENT.md` explain both variables and flag both
|
||||
defaults as proposed, not confirmed against Okta's own session policy
|
||||
- [ ] existing session/auth tests updated for the new mechanism, not just a
|
||||
new number
|
||||
|
||||
---
|
||||
|
||||
### T13.2 — D18: Okta Management API client
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** nothing (independent of T13.1)
|
||||
- **Blocks:** T13.3
|
||||
- **Surface:** `server/`
|
||||
- **Files:** new `server/okta_sync.py` (or extend `server/okta_auth.py` —
|
||||
task's call), `server/.env.example`
|
||||
|
||||
**Do:** A small client for Okta's user-list endpoint, authenticated with a new,
|
||||
separate credential (e.g. `OKTA_API_TOKEN`) — not the OIDC client secret used
|
||||
for sign-in. Fetch the full user list (paginated per Okta's API) rather than
|
||||
one-by-one lookups per local user; this app has ~20-odd accounts today, and a
|
||||
list-and-diff is simpler and cheaper than N calls. Return each Okta user's
|
||||
identity-claim value (matching `OKTA_IDENTITY_CLAIM`, already confirmed live
|
||||
per wave 10) and status.
|
||||
|
||||
Follow the existing pattern for external credentials in this repo (`MICRON_DB_URL`,
|
||||
`SMTP_PASSWORD`): env-only, never logged, never returned to the browser in an
|
||||
error message.
|
||||
|
||||
**Do not:** reuse `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET` for this. Sign-in and
|
||||
the management API are different trust boundaries with different scopes;
|
||||
conflating them means a compromise or rotation of one affects the other
|
||||
unnecessarily.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the client authenticates with its own credential, distinct from the OIDC
|
||||
client
|
||||
- [ ] it fetches the complete Okta user list, handling pagination
|
||||
- [ ] an auth failure or malformed response raises a clear, specific error
|
||||
rather than returning an empty list indistinguishable from "everyone was
|
||||
deprovisioned" — this distinction is what T13.3 depends on
|
||||
- [ ] the credential is never logged or surfaced in any API response
|
||||
|
||||
---
|
||||
|
||||
### T13.3 — D18: the sync job
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** T13.2
|
||||
- **Blocks:** T13.4
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/okta_sync.py`, `server/app.py` (or wherever `log_event`
|
||||
lives)
|
||||
|
||||
**Do:** Compare the Okta user list (T13.2) against local `users` rows. For any
|
||||
local `is_active=True` user whose Okta identity is missing from the list, or
|
||||
present with a non-active status, set `is_active=False` and write an
|
||||
`AuditLog` row (`actor="system:okta_sync"`, action e.g.
|
||||
`user_deprovisioned_by_sync`, detail naming the Okta status found). Never
|
||||
touch a user already `is_active=False`. Never re-enable anyone.
|
||||
|
||||
**The fail-closed rule, non-negotiable:** if T13.2's client raises an error of
|
||||
any kind (network, auth, malformed response, timeout), this task takes **no
|
||||
action on any account** for that run and logs the failure clearly (server log,
|
||||
at minimum). An error is never treated as "Okta returned zero active users."
|
||||
Write a test that asserts this directly: feed the sync a failing client and
|
||||
assert zero rows changed and zero `AuditLog` entries written.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a user missing from Okta's list, or present but not active, is disabled
|
||||
with a correctly-detailed audit row
|
||||
- [ ] a user already disabled is left alone (no duplicate audit row each run)
|
||||
- [ ] an active Okta user already active locally produces no audit row (only
|
||||
changes are logged, not a clean bill of health every cycle)
|
||||
- [ ] **a simulated Okta API failure results in zero account changes and zero
|
||||
audit rows** — this is the one check that must never be skipped or
|
||||
weakened
|
||||
- [ ] the job never re-enables an account under any input
|
||||
|
||||
---
|
||||
|
||||
### T13.4 — D18: run it on a schedule
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** T13.3
|
||||
- **Blocks:** T13.6
|
||||
- **Surface:** `server/`, `docker-compose.yml`
|
||||
- **Files:** `server/app.py` (startup hook) or a new sidecar per the `backup`
|
||||
container's pattern — task's call, per the decision record's noted
|
||||
alternative
|
||||
|
||||
**Do:** Wire T13.3 to run on an interval (proposed 15 minutes, env-overridable
|
||||
— e.g. `OKTA_SYNC_INTERVAL_SECONDS`, matching `BACKUP_INTERVAL_SECONDS`'s
|
||||
naming). Default choice is an in-process background task in the `api`
|
||||
container; if a separate container is chosen instead, follow the `backup`
|
||||
service's shape (own Dockerfile or script, `internal` network plus whatever
|
||||
egress reaching Okta requires — check whether `outbound` as currently defined
|
||||
is sufficient or Okta needs a distinct allowance).
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the job runs automatically on the configured interval without manual
|
||||
invocation
|
||||
- [ ] interval is env-configurable with a sane default
|
||||
- [ ] a container restart does not produce a duplicate/overlapping run, and a
|
||||
slow cycle does not stack with the next one
|
||||
|
||||
---
|
||||
|
||||
### T13.5 — D18: admin visibility
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** T13.3
|
||||
- **Blocks:** T13.6
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `html/users.js` / `html/admin.js` (wherever audit history is
|
||||
already surfaced)
|
||||
|
||||
**Do:** Confirm an auto-disable reads clearly wherever admins already look at
|
||||
account history — the actor string (`system:okta_sync`) should be
|
||||
self-explanatory in context, not require reading server logs to understand.
|
||||
Do not build new UI beyond making sure the existing audit surface renders this
|
||||
actor sensibly. Email/notification to admins on auto-disable is a noted
|
||||
fast-follow (the decision record flags it as open, not required here) — do not
|
||||
build it in this task; log it instead if it's tempting to add.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] an auto-disabled account's audit entry is visible and legible in the
|
||||
existing admin UI without special-casing
|
||||
- [ ] nothing here silently assumes `CR-019`'s activity view exists yet — this
|
||||
must work standalone
|
||||
|
||||
---
|
||||
|
||||
### T13.6 — D18: verification
|
||||
|
||||
- **Items:** `D18`
|
||||
- **Depends on:** T13.4, T13.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/`
|
||||
- **Files:** as touched above
|
||||
|
||||
**Do:** Full verification per `CLAUDE.md`. Beyond the usual run: specifically
|
||||
re-run T13.3's fail-closed test in isolation and confirm it still passes after
|
||||
T13.4's scheduling wrapper is in place — the scheduling layer must not
|
||||
introduce a path that swallows the client's error and proceeds anyway.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all `D18` acceptance criteria in `decisions-2026-09-17.md` are met or a
|
||||
failure is stated with a reason
|
||||
- [ ] the fail-closed behavior is verified end to end through the scheduled
|
||||
wrapper, not just the bare sync function
|
||||
- [ ] smoke test and `seed_demo.py` both still pass
|
||||
- [ ] full test suite passes
|
||||
|
||||
---
|
||||
|
||||
## Wave 13 exit criteria
|
||||
|
||||
- [ ] `AUTH_SESSION_HOURS` defaults to 2, documented
|
||||
- [ ] the sync job runs on a schedule and correctly disables accounts Okta no
|
||||
longer shows as active
|
||||
- [ ] every auto-disable is individually audited with a clearly non-human actor
|
||||
- [ ] an Okta API failure of any kind changes zero accounts — verified, not
|
||||
assumed
|
||||
- [ ] the sync never re-enables an account
|
||||
- [ ] `D18` fully accounted for, no open acceptance criteria
|
||||
@@ -307,10 +307,10 @@ criteria turned out wrong, inputs still outstanding, and follow-ups logged along
|
||||
|
||||
## Wave 9 exit criteria
|
||||
|
||||
- [ ] the export matches the final structure
|
||||
- [ ] one icon system, one sample-data affordance
|
||||
- [ ] accessibility metrics hit target or are documented
|
||||
- [ ] the primary flow works at 390px
|
||||
- [ ] archived projects are readable by project admins and invisible to everyone else (`D7`)
|
||||
- [ ] the backlog has no entry still pointing at wave 9
|
||||
- [ ] every item is reconciled - all 65
|
||||
- [x] the export matches the final structure (`export_check.py`, 20 checks — required fields present, CR-002 removals absent, CR-006 suppression honoured, tablet-legible)
|
||||
- [x] one icon system, one sample-data affordance (`icon_check.py`, `sample_check.py`)
|
||||
- [x] accessibility metrics hit target or are documented (`docs/reference/accessibility-audit.md`; the one gap — 21 dialogs on surfaces no S1 task named — is BL-024)
|
||||
- [x] the primary flow works at 390px (`mobile_check.py`, 24 checks, all seven pages; screenshots committed)
|
||||
- [x] archived projects are readable by project admins and invisible to everyone else (`archived_check.py`, 15 checks)
|
||||
- [x] the backlog has no entry still pointing at wave 9 (nine closed at T9.9, each with its measurement)
|
||||
- [x] every item is reconciled — all 65 (`docs/reference/completion.md`)
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||
sticky header always has something to stick to rather than sliding up behind
|
||||
the app bar. Same rule as console.css's .tscroll. */
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
#comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
|
||||
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||
one table never ends up with two scrollbars. */
|
||||
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||
#activity-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||
overflow:visible; max-height:none; }
|
||||
|
||||
/* Comment text and audit detail are the two columns you are actually here to
|
||||
@@ -53,7 +53,7 @@
|
||||
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||
|
||||
@media (max-width:900px){
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
#comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
|
||||
max-height:none; }
|
||||
}
|
||||
</style>
|
||||
@@ -175,16 +175,38 @@
|
||||
<div id="audit-admin" class="note">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- USAGE LOGS -->
|
||||
<!-- ACTIVITY & USAGE (CR-019) -->
|
||||
<div class="card">
|
||||
<h2>Usage logs</h2>
|
||||
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard —
|
||||
sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser,
|
||||
so this reflects activity on <strong>this</strong> machine.</div>
|
||||
<h2>Activity & usage</h2>
|
||||
<div class="sub">Who is using the suite, which tools, and how often — recorded server-side on every
|
||||
sign-in and page open, kept indefinitely. Filter below, or export a CSV: raw (real usernames) for
|
||||
internal use, or sanitized (each user replaced with a stable, non-reversible id) for feeding into
|
||||
Power BI or another external reporting tool without carrying real identities.</div>
|
||||
<div class="toolbar">
|
||||
<button onclick="loadUsage()">Refresh</button>
|
||||
<label for="act-from">From</label>
|
||||
<input type="date" id="act-from" onchange="loadActivity()">
|
||||
<label for="act-to">To</label>
|
||||
<input type="date" id="act-to" onchange="loadActivity()">
|
||||
<select id="act-project" onchange="loadActivity()"><option value="">All projects</option></select>
|
||||
<input id="act-user" placeholder="Username…" oninput="loadActivity()">
|
||||
<select id="act-tool" onchange="loadActivity()">
|
||||
<option value="">All tools</option>
|
||||
<option value="launcher">Launcher</option>
|
||||
<option value="wizard">SOP wizard</option>
|
||||
<option value="creator">Work package creator</option>
|
||||
<option value="field_view">Field view</option>
|
||||
<option value="admin">Admin console</option>
|
||||
<option value="directory">User directory</option>
|
||||
</select>
|
||||
<button onclick="loadActivity()">Refresh</button>
|
||||
</div>
|
||||
<div id="usage-admin" class="note">Click refresh to load.</div>
|
||||
<div id="activity-banner" class="banner" style="display:none"></div>
|
||||
<div id="activity-admin" class="note">Loading…</div>
|
||||
<div class="toolbar" style="margin-top:var(--s3)">
|
||||
<button onclick="exportActivity(false)">Download CSV (raw)</button>
|
||||
<button onclick="exportActivity(true)">Download CSV (sanitized)</button>
|
||||
</div>
|
||||
<div id="activity-export-banner" class="banner" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- DB SNAPSHOT -->
|
||||
@@ -215,9 +237,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="console-util.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
|
||||
203
html/admin.js
@@ -24,7 +24,7 @@ function reveal(){
|
||||
loadNotifications();
|
||||
loadComments();
|
||||
loadAudit();
|
||||
loadUsage();
|
||||
loadActivity();
|
||||
}
|
||||
function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
@@ -36,13 +36,13 @@ async function checkHealth(){
|
||||
b.className='banner'; b.textContent='Checking…';
|
||||
const { status, json } = await api('GET','/api/health');
|
||||
if(status===200 && json && json.ok){
|
||||
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
||||
b.className='banner ok'; b.textContent='✓ API reachable — /api/health returned ok.';
|
||||
} else if(status===404){
|
||||
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||
b.className='banner bad'; b.textContent='✕ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||
} else if(status===0){
|
||||
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
||||
b.className='banner bad'; b.textContent='✕ Could not reach the server: '+json;
|
||||
} else {
|
||||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||||
b.className='banner bad'; b.textContent='✕ Unexpected response: HTTP '+status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
|
||||
async function seedDemo(){
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
let r = await api('GET','/api/health');
|
||||
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
|
||||
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('✕ API unreachable — fix /api/ routing first.'); return; }
|
||||
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
||||
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
|
||||
if(r.status!==200){ demoLog('✕ create project failed (HTTP '+r.status+')'); return; }
|
||||
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
||||
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||
@@ -142,20 +142,22 @@ async function seedDemo(){
|
||||
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
||||
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||
demoLog('\n✓ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||
snapshot();
|
||||
}
|
||||
async function cleanDemo(){
|
||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete demo data',
|
||||
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
|
||||
okLabel:'Delete them'}))) return;
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
||||
// 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||'')));
|
||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||||
demoLog('\n✓ Removed '+targets.length+' project(s).');
|
||||
snapshot();
|
||||
}
|
||||
|
||||
@@ -175,18 +177,31 @@ async function loadProjects(){
|
||||
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 can’t archive or delete projects here.';
|
||||
banner.textContent='✕ Your account is not an admin, so you can’t 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;
|
||||
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.className='banner bad'; banner.textContent='✕ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
_adminProjects = json;
|
||||
renderProjects();
|
||||
populateActivityProjectFilter();
|
||||
}
|
||||
|
||||
// The activity project filter reuses the same project list the Projects card
|
||||
// already fetched — no second /api/projects call just to fill a <select>.
|
||||
function populateActivityProjectFilter(){
|
||||
const sel = document.getElementById('act-project');
|
||||
if(!sel) return;
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">All projects</option>' +
|
||||
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
|
||||
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
|
||||
sel.value = cur;
|
||||
}
|
||||
|
||||
function renderProjects(){
|
||||
@@ -250,10 +265,12 @@ async function archiveProject(id, name, archived){
|
||||
'• 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;
|
||||
if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
|
||||
message:ask, okLabel:archived?'Archive':'Unarchive'}))) 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)));
|
||||
else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
|
||||
message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
|
||||
@@ -261,13 +278,16 @@ async function archiveProject(id, name, archived){
|
||||
// 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;
|
||||
if(!(await wpConfirmDialog({title:'Delete project permanently',
|
||||
message:'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.',
|
||||
okLabel:'Delete permanently'}))) 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)));
|
||||
else wpAlertDialog({title:'Delete failed',
|
||||
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// ── default members on new projects ─────────────────────────────────────────────
|
||||
@@ -284,14 +304,14 @@ async function loadDefaultMembers(){
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t change who is added to new projects.';
|
||||
banner.textContent='✕ Your account is not an admin, so you can’t change who is added to new projects.';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
banner.className='banner bad'; banner.textContent='✕ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
banner.className='banner bad'; banner.textContent='✕ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
_defMemUsers = json;
|
||||
@@ -373,7 +393,8 @@ async function setAutoAdd(id, username){
|
||||
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||
renderDefaultMembers();
|
||||
} else {
|
||||
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
wpAlertDialog({title:'Change failed',
|
||||
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
|
||||
loadDefaultMembers();
|
||||
}
|
||||
}
|
||||
@@ -457,6 +478,90 @@ function renderAudit(){
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
|
||||
// Server-side, per-user activity — distinct from the "Activity log" card above
|
||||
// (that's AuditLog: business mutations) and from the "Usage logs" card below
|
||||
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
|
||||
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
|
||||
// filterable by date/project/user/tool, and exportable raw or sanitized.
|
||||
function _activityFilters(){
|
||||
const q = new URLSearchParams();
|
||||
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
|
||||
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
|
||||
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
|
||||
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
|
||||
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
|
||||
return q;
|
||||
}
|
||||
async function loadActivity(){
|
||||
const banner = document.getElementById('activity-banner');
|
||||
const box = document.getElementById('activity-admin');
|
||||
if(!box) return;
|
||||
banner.style.display='none';
|
||||
box.textContent = 'Loading…';
|
||||
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
|
||||
if(status===403){
|
||||
banner.className='banner bad'; banner.style.display='';
|
||||
banner.textContent = '✕ Your account can’t see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
|
||||
box.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !json){
|
||||
banner.className='banner bad'; banner.style.display='';
|
||||
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
|
||||
box.innerHTML=''; return;
|
||||
}
|
||||
renderActivity(json);
|
||||
}
|
||||
function renderActivity(sum){
|
||||
const box = document.getElementById('activity-admin');
|
||||
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const bucket = (label, obj, take) => {
|
||||
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
|
||||
if(!entries.length) return '';
|
||||
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
|
||||
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
|
||||
'</table>';
|
||||
};
|
||||
const perTool = Object.entries(sum.by_tool||{});
|
||||
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
|
||||
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
|
||||
'<div class="note">No per-tool data.</div>';
|
||||
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
|
||||
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
|
||||
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
|
||||
'</tbody></table>' : '<div class="note">No per-user data.</div>';
|
||||
box.innerHTML =
|
||||
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
|
||||
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
|
||||
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
|
||||
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
|
||||
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
|
||||
'</div>'+
|
||||
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
|
||||
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
|
||||
}
|
||||
async function exportActivity(sanitize){
|
||||
const banner = document.getElementById('activity-export-banner');
|
||||
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
|
||||
const q = _activityFilters();
|
||||
if(sanitize) q.set('sanitize', 'true');
|
||||
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
|
||||
if(status!==200 || typeof json !== 'string'){
|
||||
banner.className='banner bad';
|
||||
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([json], { type:'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
banner.className='banner ok';
|
||||
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
|
||||
}
|
||||
|
||||
// ── notifications / email settings ──────────────────────────────────────────────
|
||||
let _settings = {};
|
||||
async function loadSettings(){
|
||||
@@ -560,7 +665,7 @@ async function saveLocalization(){
|
||||
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.textContent = '✕ '+((json && json.detail) || ('HTTP '+status));
|
||||
msg.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
@@ -627,8 +732,8 @@ async function saveSettings(){
|
||||
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)'; }
|
||||
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;
|
||||
@@ -648,48 +753,6 @@ async function loadNotifications(){
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
||||
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
|
||||
// operator-facing readout belongs - behind the same admin gate as this whole
|
||||
// page (gateByRole() below shows nothing else either). Data comes from
|
||||
// wp-usage.js, the single implementation; the keys predate the move, so
|
||||
// everything recorded before it is still here.
|
||||
function loadUsage(){
|
||||
const box = document.getElementById('usage-admin');
|
||||
if(!box) return;
|
||||
const tools = [
|
||||
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
|
||||
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
|
||||
];
|
||||
let html = '';
|
||||
tools.forEach(([label, key, prefix]) => {
|
||||
const evs = (WPUsage.load(key).events) || [];
|
||||
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
|
||||
if(!evs.length){
|
||||
html += '<div class="note">No usage recorded in this browser yet.</div>';
|
||||
return;
|
||||
}
|
||||
const byEvent = {}, sessions = new Set();
|
||||
let first = evs[0].ts, last = evs[0].ts;
|
||||
evs.forEach(e => {
|
||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||
if(e.session) sessions.add(e.session);
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
});
|
||||
const fmt = v => v ? wpFormatDateTime(v) : '—';
|
||||
html += '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||
html += '</tbody></table>';
|
||||
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
|
||||
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
|
||||
});
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── access control: admins only ─────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||
|
||||
@@ -60,62 +60,10 @@
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||
// current password). Available from the top-right pill on any page.
|
||||
window.wpChangePassword = function () {
|
||||
if (document.getElementById('wp-pw-modal')) return;
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-pw-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
el.style.display = 'block'; el.textContent = text;
|
||||
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-pw-cancel').onclick = close;
|
||||
document.getElementById('wp-pw-cur').focus();
|
||||
document.getElementById('wp-pw-save').onclick = function () {
|
||||
var cur = document.getElementById('wp-pw-cur').value;
|
||||
var n1 = document.getElementById('wp-pw-new').value;
|
||||
var n2 = document.getElementById('wp-pw-new2').value;
|
||||
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
||||
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||
fetch('/api/auth/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
// window.wpChangePassword used to open a change-password dialog here. Removed in
|
||||
// T10.4 (D15/D16): there is no local password to change anymore — identity is
|
||||
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
|
||||
// too.
|
||||
|
||||
// ── permissions helpers ────────────────────────────────────────────────────
|
||||
// The server enforces all of this; these are for hiding controls the signed-in
|
||||
@@ -171,17 +119,46 @@
|
||||
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
|
||||
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
|
||||
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
|
||||
// the two items that were only here: Language & time, and Password.
|
||||
// the item that was only here: Language & time. (Password was the other one; T10.4
|
||||
// removed it along with the rest of local auth — D15/D16.)
|
||||
//
|
||||
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
||||
// left that would need a floating fallback pill.
|
||||
|
||||
// ── CR-019: usage ping ───────────────────────────────────────────────────
|
||||
// One page_open event per authenticated load, sent from exactly ONE place
|
||||
// (here) rather than from each page's own script - the shared-chrome lesson
|
||||
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
|
||||
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
|
||||
// error to the person using the app - a missed usage ping is not something
|
||||
// anyone here should notice happening.
|
||||
var TOOL_BY_PAGE = {
|
||||
'index.html': 'launcher',
|
||||
'work-package-suite.html': 'wizard',
|
||||
'wp-creation-index.html': 'creator',
|
||||
'field.html': 'field_view',
|
||||
'admin.html': 'admin',
|
||||
'users.html': 'directory'
|
||||
};
|
||||
function pingUsage() {
|
||||
var page = (location.pathname.split('/').pop() || 'index.html');
|
||||
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
|
||||
try {
|
||||
fetch('/api/usage/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tool: tool })
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function proceed(user) {
|
||||
clearTimeout(safety);
|
||||
window.WP_USER = user;
|
||||
reveal();
|
||||
if (window.WP_USER) {
|
||||
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
||||
pingUsage();
|
||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,3 +213,9 @@ select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--
|
||||
@media (max-width:620px){
|
||||
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
||||
}
|
||||
|
||||
/* C2 / T9.6: the console header links are standalone targets, not inline text,
|
||||
so they meet the touch floor at coarse pointers / phone widths. */
|
||||
@media (max-width: 500px), (pointer: coarse) {
|
||||
a.home { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
.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: var(--wp-status-success-bg); color: var(--wp-hover-success); }
|
||||
.pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text-alt); }
|
||||
.pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text); }
|
||||
.pill.bad { background: var(--wp-status-error-bg); color: var(--cds-support-error); }
|
||||
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: var(--cds-layer); }
|
||||
.fld-empty a { color: var(--cds-link-primary); }
|
||||
@@ -66,9 +66,9 @@
|
||||
.fld-toast.show { opacity: 1; }
|
||||
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
|
||||
.fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box;
|
||||
border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px;
|
||||
border:1px solid var(--cds-border-subtle); border-radius:6px; margin-bottom:8px;
|
||||
color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
|
||||
.fld-drawing:active { background: var(--cds-layer-hover-01); }
|
||||
.fld-drawing:active { background: var(--cds-layer-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -149,11 +149,11 @@ function renderDetail() {
|
||||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||||
p.files.map(function (f) {
|
||||
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
|
||||
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
|
||||
'' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
|
||||
}).join('') + '</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>' +
|
||||
'<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>' +
|
||||
|
||||
199
html/help.js
@@ -12,67 +12,174 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// ── the help-tip component (S8 / T9.5) ─────────────────────────────────────
|
||||
// Markup writes <span class="help-tip" data-tip="…">i</span>; this upgrades
|
||||
// every one to a real <button> at load (and via global.helpTipUpgrade(root)
|
||||
// for anything rendered later). One bubble serves all badges: focus and hover
|
||||
// show it, click/tap toggles it (the touch path tablets need), Escape and
|
||||
// leaving close it. The bubble is clamped to the viewport on both axes.
|
||||
var _tipOpenFor = null;
|
||||
|
||||
function tipBubble() {
|
||||
var b = document.getElementById('wp-tip-bubble');
|
||||
if (!b) {
|
||||
b = document.createElement('div');
|
||||
b.id = 'wp-tip-bubble';
|
||||
b.setAttribute('role', 'tooltip');
|
||||
b.hidden = true;
|
||||
document.body.appendChild(b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function tipShow(btn) {
|
||||
var b = tipBubble();
|
||||
b.textContent = btn.getAttribute('data-tip') || '';
|
||||
b.hidden = false;
|
||||
var r = btn.getBoundingClientRect();
|
||||
b.style.left = '0px'; b.style.top = '0px'; // measure at origin
|
||||
var bw = b.offsetWidth, bh = b.offsetHeight;
|
||||
var left = Math.min(Math.max(12, r.left + r.width / 2 - bw / 2),
|
||||
window.innerWidth - bw - 12);
|
||||
var top = r.top - bh - 8;
|
||||
if (top < 8) top = r.bottom + 8;
|
||||
b.style.left = left + 'px';
|
||||
b.style.top = top + 'px';
|
||||
btn.setAttribute('aria-describedby', 'wp-tip-bubble');
|
||||
}
|
||||
|
||||
function tipHide(btn) {
|
||||
var b = document.getElementById('wp-tip-bubble');
|
||||
if (b) b.hidden = true;
|
||||
if (btn) { btn.removeAttribute('aria-describedby'); btn.setAttribute('aria-expanded', 'false'); }
|
||||
if (_tipOpenFor === btn) _tipOpenFor = null;
|
||||
}
|
||||
|
||||
function upgradeTip(el) {
|
||||
if (el.tagName === 'BUTTON') return el;
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = el.className;
|
||||
btn.setAttribute('data-tip', el.getAttribute('data-tip') || '');
|
||||
btn.setAttribute('aria-label', 'More information');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.textContent = el.textContent || 'i';
|
||||
el.parentNode.replaceChild(btn, el);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function helpTipUpgrade(root) {
|
||||
(root || document).querySelectorAll('span.help-tip').forEach(upgradeTip);
|
||||
}
|
||||
global.helpTipUpgrade = helpTipUpgrade;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
helpTipUpgrade(document);
|
||||
// Delegated, so badges rendered later work without re-wiring.
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
|
||||
if (btn && btn.tagName !== 'BUTTON') btn = upgradeTip(btn);
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
if (_tipOpenFor === btn) { tipHide(btn); return; }
|
||||
if (_tipOpenFor) tipHide(_tipOpenFor);
|
||||
_tipOpenFor = btn;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
tipShow(btn);
|
||||
return;
|
||||
}
|
||||
if (_tipOpenFor) tipHide(_tipOpenFor); // tap elsewhere closes
|
||||
});
|
||||
document.addEventListener('focusin', function (e) {
|
||||
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
|
||||
if (btn) tipShow(btn);
|
||||
else if (_tipOpenFor) tipHide(_tipOpenFor);
|
||||
});
|
||||
document.addEventListener('focusout', function (e) {
|
||||
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
|
||||
if (btn && _tipOpenFor !== btn) tipHide(btn);
|
||||
});
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
|
||||
if (btn) { if (btn.tagName !== 'BUTTON') btn = upgradeTip(btn); tipShow(btn); }
|
||||
else if (!_tipOpenFor) tipHide(null);
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && _tipOpenFor) tipHide(_tipOpenFor);
|
||||
});
|
||||
});
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────
|
||||
var css = `
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
|
||||
/* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
|
||||
the old span never was (its :focus rule was dead code: no tabindex). The
|
||||
tooltip itself is #wp-tip-bubble below, a positioned element CLAMPED to the
|
||||
viewport - the old ::after escaped its badge to the right and was the last
|
||||
cause of the creator's 390px overflow (BL-001). Colours come from the
|
||||
theme's tokens; this block owned four of the raw hexes S5 counted. */
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
|
||||
margin-left:5px; padding:0; border:0; border-radius:50%;
|
||||
background:var(--cds-icon-secondary); color:var(--cds-text-inverse); font-size:10px; font-weight:700;
|
||||
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%);
|
||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; 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;
|
||||
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%);
|
||||
border:5px solid transparent; border-top-color:#161616; 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:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
|
||||
.help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
|
||||
#wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
|
||||
background:var(--cds-background-inverse); color:var(--cds-text-inverse);
|
||||
padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
|
||||
box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
|
||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||
.ui-help-overlay.open{ display:flex; }
|
||||
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||
.ui-help-modal{ background:var(--cds-layer); color:var(--cds-text-primary); 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;
|
||||
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 var(--cds-border-subtle); flex:none; }
|
||||
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
|
||||
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-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
|
||||
font-size:13px; outline:none; background:var(--cds-layer-accent); }
|
||||
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(15,98,254,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; }
|
||||
.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 a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:var(--cds-text-primary); text-decoration:none; font-size:13px;
|
||||
cursor:pointer; margin-bottom:1px; }
|
||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
|
||||
.ui-help-nav a:hover{ background:var(--cds-layer-hover); }
|
||||
.ui-help-nav a.active{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); font-weight:600; }
|
||||
.ui-help-nav a.nohit{ display:none; }
|
||||
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||
.ui-help-sec{ margin-bottom:30px; }
|
||||
.ui-help-sec.hide{ display:none; }
|
||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; 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-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:var(--cds-text-primary); scroll-margin-top:10px; }
|
||||
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--cds-link-primary); }
|
||||
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:var(--cds-text-primary); }
|
||||
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||
.ui-help-content li{ margin-bottom:5px; }
|
||||
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
.ui-help-content code{ background:var(--cds-layer-accent); padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||
.ui-help-content th, .ui-help-content td{ border:1px solid var(--cds-border-subtle); padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:var(--cds-layer-accent); 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-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
|
||||
/* Scoped to .ui-help-pill: this block is injected on EVERY page, and the
|
||||
creator's Issue (hold) status radio also carries the class pill-hold - the
|
||||
bare selector painted that radio error-red at all times, selected or not
|
||||
(found by Nick 2026-08-20; the collision dates to the login-portal era). */
|
||||
.ui-help-pill.pill-draft{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); } .ui-help-pill.pill-sched{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); }
|
||||
.ui-help-pill.pill-prog{ background:var(--wp-status-warning-bg); color:var(--wp-status-warning-text); } .ui-help-pill.pill-issued{ background:var(--wp-status-success-bg); color:var(--wp-status-success-text); }
|
||||
.ui-help-pill.pill-qc{ background:var(--cds-highlight); color:var(--cds-link-primary); } .ui-help-pill.pill-closed{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); }
|
||||
.ui-help-pill.pill-hold{ background:var(--wp-status-error-bg); color:var(--wp-status-error-text); }
|
||||
.ui-help-callout{ background:var(--cds-highlight); border-left:3px solid var(--cds-link-primary); padding:10px 14px; border-radius: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-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-noresult{ display:none; color:var(--cds-text-secondary); font-size:14px; padding:10px 2px; }
|
||||
.ui-help-content mark{ background:var(--wp-status-warning-border-a); color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
|
||||
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
|
||||
border:none; background:var(--cds-interactive-01); color:var(--cds-text-on-color); font-size:18px; font-weight:700; cursor:pointer;
|
||||
box-shadow:0 2px 10px rgba(20,30,50,.28); }
|
||||
.ui-help-fab:hover{ background:#0353e9; }
|
||||
.ui-help-fab:hover{ background:var(--cds-hover-primary); }
|
||||
@media (max-width:760px){
|
||||
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
|
||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid var(--cds-border-subtle); }
|
||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||
.ui-help-head{ flex-wrap:wrap; }
|
||||
}`;
|
||||
@@ -93,7 +200,7 @@
|
||||
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||
</ol>
|
||||
<h4>Moving around</h4>
|
||||
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
|
||||
<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>
|
||||
@@ -101,7 +208,7 @@
|
||||
<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 data</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
|
||||
|
||||
{ id: 'projects', title: 'Projects', body: `
|
||||
<h3>Projects</h3>
|
||||
@@ -133,7 +240,7 @@
|
||||
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||
</ol>
|
||||
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
|
||||
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>✎ Edit</strong>, which requires a logged reason.</div>` },
|
||||
|
||||
{ id: 'wps', title: 'Work Packages', body: `
|
||||
<h3>Creating Work Packages</h3>
|
||||
@@ -141,7 +248,7 @@
|
||||
<h4>Key fields</h4>
|
||||
<ul>
|
||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||
<li><strong>Assets</strong> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</li>
|
||||
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||
@@ -152,7 +259,7 @@
|
||||
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||
</ul>
|
||||
<h4>Saving</h4>
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||
|
||||
{ id: 'statuses', title: 'Statuses', body: `
|
||||
<h3>Work Package statuses</h3>
|
||||
@@ -223,7 +330,7 @@
|
||||
|
||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||
<h3>Dashboard & metrics</h3>
|
||||
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
|
||||
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>Dashboard</strong> tab, or the Creator header.</p>
|
||||
<h4>Metric cards (click to filter)</h4>
|
||||
<ul>
|
||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||
@@ -232,7 +339,7 @@
|
||||
<h4>Breakdowns & gates</h4>
|
||||
<ul>
|
||||
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
||||
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||
<li><strong>⊘ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||
</ul>
|
||||
<h4>The table</h4>
|
||||
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
|
||||
@@ -241,7 +348,7 @@
|
||||
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||
<h3>Samples, import / export & comments</h3>
|
||||
<h4>Load sample</h4>
|
||||
<p><strong>⭐ Load sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||
<p><strong>Load sample data</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||
<h4>Import / Export</h4>
|
||||
<ul>
|
||||
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
||||
@@ -249,9 +356,9 @@
|
||||
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||
</ul>
|
||||
<h4>Comments & feedback</h4>
|
||||
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>Step Comments</strong>), or package comments in the Creator's <strong>Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||
<h4>Usage logs</h4>
|
||||
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||
<p><strong>Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||
|
||||
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||
<h3>Tips & keyboard shortcuts</h3>
|
||||
@@ -281,7 +388,7 @@
|
||||
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||
<tr><td><strong>Asset</strong></td><td>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</td></tr>
|
||||
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||
</table>` },
|
||||
|
||||
@@ -427,6 +427,19 @@
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
|
||||
|
||||
<!-- D7 / T9.8: the way back into an archived project - PROJECT ADMINS ONLY
|
||||
(the server filters; everyone else gets an empty list and this section
|
||||
never renders). Visually its own thing, so nobody opens one thinking
|
||||
it is live: the server refuses every write regardless. -->
|
||||
<section class="section" id="archived-projects" hidden
|
||||
style="border:1px dashed var(--cds-border-subtle); background:var(--cds-layer-accent); opacity:.92">
|
||||
<h2>Archived projects</h2>
|
||||
<p style="font-size:13px; color:var(--cds-text-secondary)">Read-only. Visible to project
|
||||
admins only. Opening one lets you read everything; nothing on it can be changed while
|
||||
it stays archived.</p>
|
||||
<div id="archived-projects-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- (a) no projects at all -->
|
||||
<section class="section first-run" id="first-run" hidden>
|
||||
<h2>No projects yet</h2>
|
||||
@@ -580,12 +593,37 @@
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
let _projects = [];
|
||||
|
||||
// D7: render the archived list for whoever the server says may see one.
|
||||
function renderArchivedProjects(){
|
||||
ProjectData.listArchivedProjects().then(rows => {
|
||||
const sec = $('archived-projects');
|
||||
const list = $('archived-projects-list');
|
||||
if(!sec || !list) return;
|
||||
if(!rows.length){ sec.hidden = true; return; }
|
||||
sec.hidden = false;
|
||||
list.innerHTML = rows.map(p =>
|
||||
`<button type="button" class="card-button" style="display:block; width:100%; text-align:left; margin-bottom:8px"
|
||||
data-open-archived="${esc(p.id)}">
|
||||
${esc(p.name || p.id)} ${p.number ? '· ' + esc(p.number) : ''}
|
||||
<span style="font-size:11px; color:var(--cds-text-secondary)"> — archived, read-only</span>
|
||||
</button>`).join('');
|
||||
list.querySelectorAll('[data-open-archived]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const p = rows.find(x => x.id === b.dataset.openArchived);
|
||||
if(p){ ProjectData.setActive(p); location.reload(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initProjects(){
|
||||
renderArchivedProjects();
|
||||
ProjectData.list().then(list => {
|
||||
_projects = list || [];
|
||||
// A deep link names the project explicitly, and every other page in the
|
||||
@@ -606,7 +644,12 @@
|
||||
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
||||
WPUrl.replace({ project: active.id });
|
||||
}
|
||||
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
||||
// D7: a project OPENED FROM THE ARCHIVED LIST is active on purpose - its
|
||||
// stored summary says archived:true, and only someone the server let see
|
||||
// that list could have stored it. A project archived out from under
|
||||
// someone still drops and gets explained, exactly as before.
|
||||
const dropped = (active && !_projects.some(p => p.id === active.id)
|
||||
&& !active.archived) ? active : null;
|
||||
if(dropped) ProjectData.setActive(null);
|
||||
_listLoaded = true;
|
||||
renderProjectEntry();
|
||||
@@ -1004,7 +1047,7 @@
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Please enter feedback.');
|
||||
toast('Please enter feedback.', 'alert');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1025,7 +1068,7 @@
|
||||
function exportFeedback() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
const data = saved ? JSON.parse(saved) : [];
|
||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||
if (!data.length) { toast('No feedback to export yet.', 'alert'); return; }
|
||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
@@ -1043,7 +1086,7 @@
|
||||
try {
|
||||
const inc = JSON.parse(r.result);
|
||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||
if (!incoming.length) { toast('No feedback found in that file.', 'alert'); return; }
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
allComments = saved ? JSON.parse(saved) : [];
|
||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||
@@ -1051,8 +1094,8 @@
|
||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
loadComments();
|
||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { alert('Could not read that file.'); }
|
||||
toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { toast('Could not read that file.', 'alert'); }
|
||||
ev.target.value = '';
|
||||
};
|
||||
r.readAsText(f);
|
||||
|
||||
@@ -31,34 +31,23 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.brand img { height: 36px; width: auto; }
|
||||
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
background: var(--cds-field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--cds-border-strong);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||
button {
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-on-color);
|
||||
background: var(--cds-button-primary);
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.btn:hover { background: var(--cds-hover-primary); }
|
||||
.btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; }
|
||||
.error {
|
||||
display: none;
|
||||
background: var(--wp-status-error-bg);
|
||||
@@ -69,7 +58,6 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
.ok {
|
||||
display: none;
|
||||
background: var(--wp-status-success-bg);
|
||||
@@ -80,15 +68,7 @@
|
||||
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; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -99,61 +79,10 @@
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
<div id="ok" class="ok" role="status"></div>
|
||||
|
||||
<!-- SIGN IN -->
|
||||
<section id="view-login">
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
<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>
|
||||
<form id="forgot-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="forgot-username">Username or email</label>
|
||||
<input id="forgot-username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<button id="forgot-submit" type="submit">Email me a reset link</button>
|
||||
</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) -->
|
||||
<section id="view-reset" style="display:none">
|
||||
<h1>Set a new password</h1>
|
||||
<p class="sub">Choose a password you don't use anywhere else.</p>
|
||||
<form id="reset-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<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 & sign in</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
|
||||
</section>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the
|
||||
button below and follow the prompts there.</p>
|
||||
<a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a>
|
||||
|
||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||
</main>
|
||||
|
||||
218
html/login.js
@@ -1,26 +1,16 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
|
||||
Three views on one page:
|
||||
• sign in posts to /api/auth/login. On success the server sets an
|
||||
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. */
|
||||
One action: sign in with Okta. There is no local password anymore (D15/D16,
|
||||
T10.4) — this page's only job is building the link to /api/auth/okta/login
|
||||
(carrying ?next=, if there was one) and showing a plain-language message for
|
||||
the failure states server/app.py's okta_callback() sends back here (T10.5). */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var errorBox = document.getElementById('error');
|
||||
var okBox = document.getElementById('ok');
|
||||
var signinLink = document.getElementById('okta-signin');
|
||||
|
||||
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) {
|
||||
@@ -28,188 +18,38 @@
|
||||
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
|
||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||
// open-redirect.)
|
||||
function nextTarget() {
|
||||
// Same-site path only — mirrors the check server/app.py's _safe_next_path()
|
||||
// makes again on the way back, so a crafted ?next= can't become an open
|
||||
// redirect even if this client-side check were somehow bypassed.
|
||||
function safeNext() {
|
||||
try {
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||
} catch (e) {}
|
||||
return 'index.html';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resetToken() {
|
||||
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
||||
if (signinLink) {
|
||||
var next = safeNext();
|
||||
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next);
|
||||
}
|
||||
|
||||
function postJson(url, payload) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return null; }).then(function (j) {
|
||||
return { status: r.status, ok: r.ok, json: j };
|
||||
});
|
||||
});
|
||||
}
|
||||
var ERROR_MESSAGES = {
|
||||
disabled: 'Your account has been disabled. Contact an administrator.',
|
||||
cancelled: 'Sign-in was not completed. Select the button below to try again.'
|
||||
};
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var username = byId('username').value.trim();
|
||||
var password = byId('password').value;
|
||||
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
postJson('/api/auth/login', { username: username, password: password })
|
||||
.then(function (res) {
|
||||
if (res.ok) { location.replace(nextTarget()); return; }
|
||||
if (res.status === 401) showError('Invalid username or password.');
|
||||
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
|
||||
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 + ').'));
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
(function showErrorFromQuery() {
|
||||
try {
|
||||
var code = new URLSearchParams(location.search).get('error') || '';
|
||||
if (!code) return;
|
||||
showError(ERROR_MESSAGES[code] || 'Sign-in was not completed. Select the button below to try again.');
|
||||
// Out of the address bar once shown — an error code has no reason to
|
||||
// survive a refresh or get copied along with the link.
|
||||
var url = new URL(location.href);
|
||||
url.searchParams.delete('error');
|
||||
history.replaceState(null, '', url.pathname + url.search);
|
||||
} catch (e) {}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -52,6 +52,15 @@
|
||||
.catch(function () { return readLocal(); });
|
||||
},
|
||||
|
||||
// D7 / T9.8: the way back in, for project admins. The server filters the
|
||||
// answer by per-project role; everyone else simply receives [].
|
||||
listArchivedProjects: function () {
|
||||
return fetch(API + '/projects?archived=only', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (rows) { return (rows || []).filter(function (p) { return p.archived; }); })
|
||||
.catch(function () { return []; });
|
||||
},
|
||||
|
||||
get: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
@@ -207,7 +216,11 @@
|
||||
var d = sopRow.data; // { sop, state } as written by pushSOP
|
||||
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
|
||||
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
// BL-018: only a row in the shape pushSOP writes counts as complete.
|
||||
// Marking '1' for ANY row meant a malformed record opened the gate.
|
||||
if (d.sop && d.state) {
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
}
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
@@ -345,6 +358,12 @@
|
||||
// and one badge. The 'storage' listener below stays: it is what keeps two
|
||||
// TABS in step, which is a different thing and still happens.
|
||||
var _badgeHideTimer = null;
|
||||
// BL-011 (fixed at T9.9): the badge used to mount on the FIRST SYNC EVENT,
|
||||
// which is async, so the three fixed overlays on the SOP page landed in a
|
||||
// different DOM order run to run and every index-keyed comparison saw
|
||||
// phantom diffs. Mounting the (hidden) holder at DOMContentLoaded puts the
|
||||
// three in script order, deterministically.
|
||||
document.addEventListener('DOMContentLoaded', function () { renderSyncBadge(null); });
|
||||
function renderSyncBadge(c) {
|
||||
if (!document.body) return;
|
||||
var el = document.getElementById('wp-sync-badge');
|
||||
@@ -356,9 +375,10 @@
|
||||
el.setAttribute('role', 'status');
|
||||
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;';
|
||||
'padding:6px 12px;border:1px solid var(--cds-border-subtle);background:var(--cds-layer);color:var(--cds-text-secondary);box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
if (!c) return; // the eager DOMContentLoaded mount: holder only, no state yet
|
||||
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
|
||||
@@ -378,16 +398,16 @@
|
||||
if (c.dead) {
|
||||
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — 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';
|
||||
el.style.color = 'var(--wp-status-error-text)'; el.style.borderColor = 'var(--wp-status-error-border-a)'; el.style.background = 'var(--wp-status-error-bg)'; el.style.display = 'inline-flex';
|
||||
} else if (c.failed) {
|
||||
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying';
|
||||
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
|
||||
el.style.color = 'var(--wp-status-warning-text)'; el.style.borderColor = 'var(--cds-support-warning)'; el.style.background = 'var(--wp-status-warning-bg)'; el.style.display = 'inline-flex';
|
||||
} else if (c.pending) {
|
||||
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
|
||||
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
|
||||
el.style.color = 'var(--cds-text-secondary)'; el.style.borderColor = 'var(--cds-border-subtle)'; el.style.background = 'var(--cds-layer)'; el.style.display = 'inline-flex';
|
||||
} else {
|
||||
el.textContent = '✓ Everything sent to the project';
|
||||
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
|
||||
el.style.color = 'var(--wp-status-success-text)'; el.style.borderColor = 'var(--wp-status-success-border-a)'; el.style.background = 'var(--wp-status-success-bg)'; el.style.display = 'inline-flex';
|
||||
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +143,8 @@
|
||||
--wp-status-error-bg: #fff1f1;
|
||||
--wp-status-warning-bg: #fdf6dd;
|
||||
--wp-status-warning-text: #8e6a00;
|
||||
/* Four points from --wp-status-warning-text and doing the same job, on the
|
||||
field view's warn pill. Almost certainly a typo rather than a decision, but
|
||||
merging it moves a rendered colour, so T3.2 names it and T3.5 merges it.
|
||||
BL-009 / docs/reference/tokens.md section 8-K. */
|
||||
--wp-status-warning-text-alt: #8a6d00;
|
||||
/* BL-009, CLOSED at T9.9 (C4): the ninth amber (--wp-status-warning-text-alt,
|
||||
#8a6d00, four points from this one) is deleted; its consumers use this. */
|
||||
/* Carbon green-70. The value is Carbon, the role is not — Carbon has no
|
||||
"hover for a green fill", because green is not one of its action colours.
|
||||
Declared in no sheet today; written raw in five places. */
|
||||
@@ -321,11 +318,22 @@
|
||||
--wp-btn-danger-fill-bg: var(--cds-support-error);
|
||||
--wp-btn-danger-fill-fg: var(--cds-text-on-color);
|
||||
|
||||
/* -- the second blue -------------------------------------------------------
|
||||
#2563d6, not #0f62fe. Fills .sop-inherited — every field a work package
|
||||
inherited from its SOP — at 7% alpha, which is why nobody has noticed a
|
||||
second brand blue. Named here so it is visible; swapped at T3.5 (BL-008). */
|
||||
--wp-sop-inherited-bg: rgba(37, 99, 214, .07);
|
||||
/* BL-008, CLOSED at T9.9 (C4, approved Aug 18): the second brand blue is
|
||||
gone. .sop-inherited now tints with THE blue at the same 7% alpha. */
|
||||
--wp-sop-inherited-bg: rgba(15, 98, 254, .07);
|
||||
|
||||
/* The console feedback trio's success text (auth-guard / project-data /
|
||||
wp-format carried it as a literal until C4). */
|
||||
--wp-status-success-text: #0e6027;
|
||||
--wp-status-error-text: #a2191f;
|
||||
|
||||
/* The categorical badge palette (the creator's navigator). Data-vis colours,
|
||||
not UI states - named here because here is the only place a colour value
|
||||
may exist (C4); the app reads them by computed style at boot. */
|
||||
--wp-chart-1: #0f62fe; --wp-chart-2: #8a3ffc; --wp-chart-3: #007d79;
|
||||
--wp-chart-4: #d02670; --wp-chart-5: #ba4e00; --wp-chart-6: #1192e8;
|
||||
--wp-chart-7: #198038; --wp-chart-8: #a56eff; --wp-chart-9: #9f1853;
|
||||
--wp-chart-10: #005d5d;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
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
|
||||
@@ -83,12 +80,12 @@
|
||||
<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-username" placeholder="Username *" autocomplete="off"
|
||||
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
|
||||
<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>
|
||||
@@ -102,7 +99,8 @@
|
||||
</div>
|
||||
|
||||
<script src="console-util.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
|
||||
@@ -34,7 +34,7 @@ async function boot(){
|
||||
_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')+
|
||||
banner('scope-banner','bad','✕ '+apiError(status, json, 'Could not work out what you may do here')+
|
||||
' Showing the directory read-only.');
|
||||
} else {
|
||||
renderScope();
|
||||
@@ -85,7 +85,7 @@ 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'));
|
||||
banner('users-banner','bad','✕ '+apiError(status, json, 'Could not load the directory'));
|
||||
wrap.innerHTML = ''; return;
|
||||
}
|
||||
banner('users-banner','', '');
|
||||
@@ -183,11 +183,9 @@ function managerRow(u){
|
||||
: 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"')+'>'+
|
||||
@@ -254,38 +252,32 @@ function projAccessCell(u){
|
||||
// ── 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(); }
|
||||
else { wpAlertDialog({title:'Change failed', message:'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));
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'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));
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'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;
|
||||
if(!(await wpConfirmDialog({title:'Delete user',
|
||||
message:'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.',
|
||||
okLabel:'Delete user'}))) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status === 200) loadUsers();
|
||||
else alert('Could not delete '+username+': '+apiError(status, json));
|
||||
else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
@@ -337,28 +329,30 @@ function renderCreateForm(){
|
||||
async function createUser(){
|
||||
const msg = document.getElementById('users-create-msg');
|
||||
const val = id => (document.getElementById(id)||{}).value || '';
|
||||
// The username entered here MUST match what Okta's identity claim will send for
|
||||
// this person exactly — this creates the account ahead of their first sign-in,
|
||||
// and that's how a later Okta sign-in finds this row instead of provisioning a
|
||||
// second one. See create_user()'s docstring in server/app.py.
|
||||
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,
|
||||
username, 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 = '');
|
||||
say('var(--green)','✓ Created '+username+'.');
|
||||
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
|
||||
loadUsers();
|
||||
} else {
|
||||
say('var(--red)','❌ '+apiError(status, json, 'Could not create the account'));
|
||||
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +362,7 @@ async function createUser(){
|
||||
// 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; }
|
||||
if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
|
||||
openProjectModal(id, username, json);
|
||||
}
|
||||
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
@@ -453,7 +447,7 @@ function openProjectModal(userId, username, data){
|
||||
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));
|
||||
else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -655,7 +655,7 @@ function renderWPTypes(){
|
||||
const nameCell = t.custom
|
||||
? `<div style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
|
||||
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:var(--cds-text-on-color); border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||
</div>`
|
||||
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||
row.innerHTML = `
|
||||
@@ -669,7 +669,7 @@ function renderWPTypes(){
|
||||
});
|
||||
const addRow = document.createElement('div');
|
||||
addRow.style.cssText = 'margin-top:0.85rem;';
|
||||
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
|
||||
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary); color:var(--cds-text-on-color); border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
|
||||
container.appendChild(addRow);
|
||||
}
|
||||
|
||||
@@ -955,7 +955,7 @@ function renderCustomConstraints(){
|
||||
<strong>${escAttr(c.name)}</strong>
|
||||
<span style="display:flex; align-items:center; gap:0.75rem;">
|
||||
${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>
|
||||
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:var(--cds-text-on-color); 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>`;
|
||||
}
|
||||
@@ -980,11 +980,13 @@ function toggleConstraint(name){
|
||||
function showConstraintLibrary(){
|
||||
const modal = document.getElementById('constraint-modal');
|
||||
const lib = document.getElementById('constraint-library');
|
||||
// C1/T9.5: a library entry is an ACTION, so it is a button - keyboard and
|
||||
// touch come free, and the hover styling moved to CSS where it belongs.
|
||||
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
|
||||
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
|
||||
<button type="button" class="constraint-option" onclick="addCustomConstraint('${c}')">
|
||||
<strong>${c}</strong>
|
||||
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Add to this project</div>
|
||||
</button>
|
||||
`).join('');
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
@@ -1559,7 +1561,10 @@ if(typeof WPUrl !== 'undefined'){
|
||||
// NAVIGATE, so Back would bounce forward again and the button would appear
|
||||
// broken. Those addresses belong to the creator's own history stack, and the
|
||||
// boot redirect uses replace() precisely so no such entry is left here.
|
||||
const step = parseInt(state.step, 10);
|
||||
// BL-016 (fixed at T9.9): Back from ?step=6 to a URL with NO step used to
|
||||
// parse NaN and do nothing - the wizard stayed on 6 while the address bar
|
||||
// said otherwise. A step-less wizard URL IS step 1.
|
||||
const step = parseInt(state.step, 10) || 1;
|
||||
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
|
||||
});
|
||||
}
|
||||
@@ -2202,20 +2207,18 @@ function loadStepComments(){
|
||||
}
|
||||
}
|
||||
|
||||
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
|
||||
// Lightweight usage analytics stored in localStorage so the tool owner can review
|
||||
// engagement over time. No field VALUES are stored (field-edit events record only
|
||||
// the field id), keeping captured data non-sensitive.
|
||||
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
|
||||
// on the admin console. The wizard's own copy of showAnalytics() never had a
|
||||
// caller here - the button lived on the creator - and once B7 dissolved the
|
||||
// frame the duplicate sat in the same document as five colliding globals.
|
||||
// This page only records; dwell tracking keeps its page-local state below.
|
||||
// ── USAGE ANALYTICS (retired, T11.6) ────────────────────────────────────────
|
||||
// This used to write to wp-usage.js's per-browser localStorage log (D5/T7.10),
|
||||
// read back by the admin console's old "Usage logs" panel. CR-019 replaced
|
||||
// both with real, server-side, per-user activity (UsageEvent + the Activity &
|
||||
// usage card) - see decisions-2026-09-17.md for why this per-browser data was
|
||||
// never a source the new report could adopt. track() is now a no-op; kept
|
||||
// (rather than deleting its handful of call sites, including the dwell-timer
|
||||
// plumbing below) so this stays a one-line change instead of touching every
|
||||
// caller for the same outcome.
|
||||
let _stepEnter = Date.now();
|
||||
|
||||
function track(event, detail){
|
||||
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
|
||||
}
|
||||
function track(event, detail){ /* retired, T11.6 — see comment above */ }
|
||||
function trackStepDwell(){
|
||||
const ms = Date.now() - _stepEnter;
|
||||
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
|
||||
|
||||
@@ -741,6 +741,13 @@ body {
|
||||
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||
and T5.8's step validation use the same component. */
|
||||
|
||||
/* The constraint-library entries (C1/T9.5): real buttons, block layout. */
|
||||
.constraint-option { display:block; width:100%; text-align:left; padding:0.75rem;
|
||||
background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
||||
margin-bottom:0.5rem; cursor:pointer; font:inherit; color:inherit; transition:all .2s; }
|
||||
.constraint-option:hover, .constraint-option:focus-visible {
|
||||
border-color:var(--primary); background:var(--primary-light); }
|
||||
|
||||
/* NAVIGATION
|
||||
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
|
||||
steps the proposal's beside-the-fields actions meant scrolling to save; the
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="wp-list-import.js"></script>
|
||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||
<script src="wp-autosave.js"></script>
|
||||
|
||||
@@ -319,3 +319,20 @@
|
||||
the gate panel is what does the explaining. */
|
||||
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
|
||||
.nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); }
|
||||
|
||||
/* C2 / T9.6: touch sizing. At phone widths (and any coarse pointer) every
|
||||
control meets the 44px bar the field surfaces are held to; checkboxes,
|
||||
radios and the help-tip badge get the 24px WCAG floor with spacing doing
|
||||
the rest. Shared here because every page loads this sheet - six copies of
|
||||
this block is how the six pages drift apart again. */
|
||||
@media (max-width: 500px), (pointer: coarse) {
|
||||
button, .btn, .add-btn, .nav-btn, .header-button,
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
|
||||
select, textarea { min-height: 44px; }
|
||||
a.wp-appbar-link, .wp-sidenav-item, .nav-tab {
|
||||
min-height: 44px; display: inline-flex; align-items: center; }
|
||||
input[type="checkbox"], input[type="radio"] { min-width: 24px; min-height: 24px; }
|
||||
.help-tip { min-width: 24px; min-height: 24px; }
|
||||
.wp-navbtn, .ui-help-fab, .wp-sidenav-close { min-width: 44px; }
|
||||
.wp-appbar-brand { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ function _openDialog(opts){
|
||||
document.getElementById('wp-dialog-err').textContent='';
|
||||
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
|
||||
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
|
||||
document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':'';
|
||||
ov.classList.add('open');
|
||||
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
|
||||
});
|
||||
@@ -314,6 +315,10 @@ function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
|
||||
// prompt() said string or null; so does this - and a validate() answer renders
|
||||
// AT the input instead of round-tripping through another dialog.
|
||||
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
|
||||
// alert() said one thing and offered one button; so does this (D11 - the asset
|
||||
// importer arrived using alert(), and the kit had no one-button shape).
|
||||
// Escape still closes it; the resolved value is not meaningful for alerts.
|
||||
function wpAlertDialog(opts){ return _openDialog({...opts, input:false, okOnly:true}); }
|
||||
document.addEventListener('keydown', e=>{
|
||||
const ov=document.getElementById('wp-dialog');
|
||||
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
|
||||
@@ -435,7 +440,7 @@ function applySOP(){
|
||||
applyKind();
|
||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||
if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
|
||||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||||
updateNumber(); updateReleaseBanner();
|
||||
// CR-006. Last, after every builder has rendered its card — applying it earlier
|
||||
@@ -467,7 +472,7 @@ function applyKindVisibility(){
|
||||
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||||
show('kind-row', bimProj);
|
||||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
||||
show('asset-card', !ewp); // controls.dev assets
|
||||
show('asset-card', !ewp); // Micron DB assets
|
||||
show('material-card', !ewp); // bill of materials
|
||||
show('mimo-card', !ewp); // kitting / MIMO
|
||||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
||||
@@ -519,7 +524,7 @@ function lockQuality(id){
|
||||
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
|
||||
el.classList.toggle('locked-field', false);
|
||||
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
|
||||
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
|
||||
if(btn) btn.textContent = fromSOP ? '✎ Edit (reason required)' : '↺ Revert to SOP';
|
||||
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
|
||||
}
|
||||
function editQuality(id){
|
||||
@@ -542,15 +547,20 @@ function editQuality(id){
|
||||
}
|
||||
function renderCtxBar(){
|
||||
const bar=document.getElementById('ctx-bar');
|
||||
const archMark = window._projArchived
|
||||
? ` <span class="ctx-sample" style="background:var(--red);color:var(--cds-text-on-color)">ARCHIVED — READ-ONLY</span>` : '';
|
||||
if(!SOP){
|
||||
bar.innerHTML = activeProjectId
|
||||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||||
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.</div>`;
|
||||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.${archMark}</div>`
|
||||
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.${archMark}</div>`;
|
||||
return;
|
||||
}
|
||||
const p=SOP.project||{}, g=SOP.governance||{};
|
||||
// D7: an archived project is read-only. The chip is the courtesy; the server's
|
||||
// write refusal is the rule, and savePackage() says so before the round trip.
|
||||
const archived = archMark;
|
||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}${archived}</div>
|
||||
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
||||
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
||||
}
|
||||
@@ -570,7 +580,7 @@ function renderSopRefLinks(){
|
||||
const srcs=sopLinkedSources();
|
||||
if(!srcs.length){ box.innerHTML=''; return; }
|
||||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">▸ ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
}
|
||||
function renderSpecFolderLink(){
|
||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||||
@@ -802,7 +812,7 @@ function renderPredHint(){
|
||||
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
|
||||
el.style.color = '';
|
||||
} else if(blocking.length){
|
||||
el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
|
||||
el.innerHTML = '⊘ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
|
||||
'. Release is gated until they are Closed.';
|
||||
el.style.color = 'var(--red)';
|
||||
} else {
|
||||
@@ -1152,20 +1162,349 @@ async function splitByDiscipline(){
|
||||
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
|
||||
}
|
||||
|
||||
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
||||
// Interim: assets are linked manually back to controls.dev. A future direct
|
||||
// integration will let the user pick them from a list instead of pasting links.
|
||||
// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
|
||||
// Assets are picked from the Micron asset catalog, a SQL Server database this app
|
||||
// reads through /api/assets. The lookup is strictly read-only — picking an asset
|
||||
// never writes to the catalog, and there is no endpoint that could.
|
||||
//
|
||||
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
|
||||
// neither may block someone from writing a work package: in both cases the picker
|
||||
// says so and manual entry carries on. Manually entered assets are marked
|
||||
// source:'manual' so it stays visible which rows the catalog vouches for.
|
||||
// The whole catalog is fetched once when the page loads and searched in memory —
|
||||
// it is slow-moving reference data, so a request per keystroke would buy nothing
|
||||
// and cost latency on every one.
|
||||
let assetCatalog = []; // the full catalog, loaded once
|
||||
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
|
||||
let assetCatalogState = 'loading'; // loading | ready | absent | error
|
||||
let assetResults = []; // current matches; the result list indexes into this
|
||||
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
|
||||
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
|
||||
|
||||
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
|
||||
function assetAlreadyAdded(tag){
|
||||
const k = String(tag||'').trim().toLowerCase();
|
||||
return pkgAssets.some(a => assetKey(a) === k && k);
|
||||
}
|
||||
|
||||
function buildAssets(){
|
||||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||||
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||||
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||||
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||||
tb.appendChild(tr); });
|
||||
if(!pkgAssets.length){
|
||||
tb.innerHTML = `<tr><td colspan="3" class="asset-empty">No assets yet — search the Micron DB above to add the assets this package covers.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
pkgAssets.forEach((a,i)=>{
|
||||
const tr=document.createElement('tr');
|
||||
// The asset ID on a catalog row is shown as text, not an input: the catalog
|
||||
// is the source of truth for it and a locally edited copy would silently
|
||||
// disagree. The note is always the user's own, so it stays editable either
|
||||
// way. Every interpolation below is esc()'d text content or a numeric index
|
||||
// — never a raw string inside an inline handler, which is the bug pattern
|
||||
// recorded in KNOWN-ISSUES.md §1.
|
||||
const idCell = a.source === 'catalog'
|
||||
? `<td><span class="asset-tag">${esc(a.tag)}</span> <span class="asset-badge" title="From the Micron DB">Micron DB</span></td>`
|
||||
: `<td><input type="text" value="${esc(a.tag)}" placeholder="asset ID" oninput="pkgAssets[${i}].tag=this.value"></td>`;
|
||||
tr.innerHTML = idCell +
|
||||
`<td><input type="text" value="${esc(a.desc)}" placeholder="what it is / why it's in scope" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||
<td class="center"><button class="row-del" onclick="removeAsset(${i})" title="Remove">✕</button></td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Kept for saved packages written before the picker existed: their rows have no
|
||||
// `source`, so they would render as read-only catalog rows with no way to fix a
|
||||
// typo. Anything that didn't come from the catalog is treated as manual.
|
||||
function normaliseAsset(a){
|
||||
const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
|
||||
if(o.source !== 'catalog') o.source = 'manual';
|
||||
return o;
|
||||
}
|
||||
|
||||
function addManualAsset(){
|
||||
pkgAssets.push(normaliseAsset({}));
|
||||
buildAssets();
|
||||
track('asset_added',{source:'manual'});
|
||||
}
|
||||
|
||||
// ── CSV IMPORT ───────────────────────────────────────────────────────────────
|
||||
// Bulk-add a list of asset ids. Each imported id is checked against the loaded
|
||||
// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
|
||||
// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
|
||||
// for rather than silently dropped. Nothing is ever written back to Micron.
|
||||
const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
|
||||
|
||||
function importAssets(ev){
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if(!f){ return; }
|
||||
const clear = () => { ev.target.value = ''; };
|
||||
if(/\.xlsx?$/i.test(f.name)){
|
||||
wpAlertDialog({title:'Load from CSV', message:'Please save the workbook as CSV first (File → Save As → CSV), then load it here.'});
|
||||
clear(); return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
let rows;
|
||||
try { rows = parseCSV(r.result); }
|
||||
catch(e){ toast('Could not parse that CSV.', 'alert'); clear(); return; }
|
||||
if(!rows.length){ toast('That file has no rows.', 'alert'); clear(); return; }
|
||||
applyImportedAssets(rows);
|
||||
clear();
|
||||
};
|
||||
r.onerror = () => { toast('Could not read that file.', 'alert'); clear(); };
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
// Which column holds the ids, and whether row 0 is a header.
|
||||
// - a recognised header name wins outright;
|
||||
// - otherwise pick the column with the most Micron DB hits, so an export with
|
||||
// the ids in column D works without the user rearranging it;
|
||||
// - failing both (nothing matches — e.g. the DB is offline), use column 0.
|
||||
function pickAssetColumn(rows){
|
||||
const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
|
||||
const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
|
||||
if(named >= 0) return { col: named, start: 1 };
|
||||
|
||||
const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
|
||||
let best = 0, bestHits = 0;
|
||||
for(let c = 0; c < width; c++){
|
||||
let hits = 0;
|
||||
for(let i = 0; i < Math.min(rows.length, 200); i++){
|
||||
const v = String((rows[i] || [])[c] || '').trim();
|
||||
if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
|
||||
}
|
||||
if(hits > bestHits){ bestHits = hits; best = c; }
|
||||
}
|
||||
return { col: best, start: 0 };
|
||||
}
|
||||
|
||||
function applyImportedAssets(rows){
|
||||
const { col, start } = pickAssetColumn(rows);
|
||||
|
||||
// Collect, trimmed and de-duplicated within the file itself.
|
||||
const seen = new Set(), ids = [];
|
||||
for(let i = start; i < rows.length; i++){
|
||||
const v = String((rows[i] || [])[col] || '').trim();
|
||||
if(!v) continue;
|
||||
const k = v.toLowerCase();
|
||||
if(seen.has(k)) continue;
|
||||
seen.add(k); ids.push(v);
|
||||
}
|
||||
if(!ids.length){ toast('No asset ids found in that file.', 'alert'); return; }
|
||||
|
||||
// Cap the import rather than building a table with thousands of rows. Reported,
|
||||
// never silent — a truncated import that looked complete would be worse.
|
||||
const capped = ids.length > ASSET_IMPORT_MAX;
|
||||
const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
|
||||
|
||||
let matched = 0, unmatched = 0, dupes = 0;
|
||||
take.forEach(id => {
|
||||
if(assetAlreadyAdded(id)){ dupes++; return; }
|
||||
const canonical = assetCatalogIndex.get(id.toLowerCase());
|
||||
if(canonical){
|
||||
pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
|
||||
matched++;
|
||||
} else {
|
||||
pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
|
||||
unmatched++;
|
||||
}
|
||||
});
|
||||
|
||||
buildAssets();
|
||||
renderAssetResults(); // rows just added should now read "added"
|
||||
track('asset_imported', { matched: matched, unmatched: unmatched });
|
||||
|
||||
// Every id matched, nothing skipped, nothing truncated: a toast is enough.
|
||||
// Anything the user needs to act on — unmatched ids, a silent-looking
|
||||
// truncation, an unchecked import — interrupts with the detail instead.
|
||||
const offline = assetCatalogState !== 'ready';
|
||||
if(matched && !unmatched && !dupes && !capped && !offline){
|
||||
toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if(matched) parts.push(matched + ' found in the Micron DB');
|
||||
if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
|
||||
if(dupes) parts.push(dupes + ' already on this package (skipped)');
|
||||
let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
|
||||
(parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
|
||||
if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
|
||||
ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
|
||||
if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
|
||||
'checked against it — every row was added as manual.';
|
||||
wpAlertDialog({title:'Asset import', message:msg});
|
||||
}
|
||||
|
||||
function removeAsset(i){
|
||||
pkgAssets.splice(i,1);
|
||||
buildAssets();
|
||||
renderAssetResults(); // a removed asset becomes addable again
|
||||
}
|
||||
|
||||
// ── Catalog lookup ───────────────────────────────────────────────────────────
|
||||
function assetSourceNote(msg, tone){
|
||||
const el = document.getElementById('asset-source-note'); if(!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.style.color = tone === 'warn' ? 'var(--red)' : '';
|
||||
}
|
||||
|
||||
function initAssetPicker(){
|
||||
const box = document.getElementById('asset-search'); if(!box) return;
|
||||
box.addEventListener('input', () => runAssetSearch(box.value));
|
||||
// Re-open on focus only when the list is actually closed. Adding an asset
|
||||
// returns focus to this box, and re-running the search there would rebuild the
|
||||
// list under the cursor and throw away the scroll position mid-multi-add.
|
||||
box.addEventListener('focus', () => {
|
||||
const results = document.getElementById('asset-results');
|
||||
if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
|
||||
});
|
||||
// Pasting a column of ids straight out of Excel adds them all, rather than
|
||||
// dropping a multi-line blob into a search box that can only match one thing.
|
||||
// Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
|
||||
// importer's row/cell shape, so it goes through the same matching path.
|
||||
// A single value is left alone: that is an ordinary search, not a bulk add.
|
||||
box.addEventListener('paste', e => {
|
||||
const cb = e.clipboardData || window.clipboardData;
|
||||
const text = cb ? cb.getData('text') : '';
|
||||
if(!text) return;
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
|
||||
if(lines.length < 2) return; // one id — paste it and search as normal
|
||||
e.preventDefault();
|
||||
applyImportedAssets(lines.map(l => l.split('\t')));
|
||||
box.value = '';
|
||||
assetResults = [];
|
||||
openAssetResults(false);
|
||||
});
|
||||
box.addEventListener('keydown', e => {
|
||||
if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
|
||||
// Enter adds the first result that isn't already on the package — the common
|
||||
// case of typing an exact tag and taking it without reaching for the mouse.
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
|
||||
if(ix >= 0) addCatalogAsset(ix);
|
||||
}
|
||||
});
|
||||
// Click-away closes, matching the .pp-menu pickers elsewhere on this form.
|
||||
// Tested against composedPath() rather than e.target: adding an asset can
|
||||
// re-render the row that was clicked, and a detached target reports itself as
|
||||
// outside every container, which would close the list on every add.
|
||||
document.addEventListener('click', e => {
|
||||
const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
|
||||
const inside = path && path.length
|
||||
? path.some(n => n && n.id === 'asset-pick')
|
||||
: !!(e.target.closest && e.target.closest('#asset-pick'));
|
||||
if(!inside) openAssetResults(false);
|
||||
});
|
||||
// Delegated so result rows never need an inline handler carrying catalog text.
|
||||
const results = document.getElementById('asset-results');
|
||||
if(results) results.addEventListener('click', e => {
|
||||
const row = e.target.closest('[data-asset-ix]'); if(!row) return;
|
||||
addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
|
||||
});
|
||||
|
||||
box.disabled = true;
|
||||
box.placeholder = 'Loading asset IDs from the Micron DB…';
|
||||
assetSourceNote('Loading asset IDs from the Micron DB…');
|
||||
|
||||
fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
|
||||
.then(r => r.ok ? r.json()
|
||||
: r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
|
||||
.then(body => {
|
||||
if(!body.configured){
|
||||
assetCatalogState = 'absent';
|
||||
box.placeholder = 'Micron DB not configured — add assets manually below';
|
||||
assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
|
||||
return;
|
||||
}
|
||||
assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
|
||||
// Lowercased lookup for the CSV importer: it decides whether an imported id
|
||||
// is a real Micron asset, and maps it back to the DB's own casing so an
|
||||
// id typed as "ahu-2p-014" is stored exactly as Micron spells it.
|
||||
assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
|
||||
assetCatalogState = 'ready';
|
||||
box.disabled = false;
|
||||
box.placeholder = 'Search asset IDs, or paste a column from Excel…';
|
||||
assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
|
||||
})
|
||||
.catch(err => {
|
||||
assetCatalogState = 'error';
|
||||
box.placeholder = 'Micron DB unavailable — add assets manually below';
|
||||
assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
|
||||
: 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
|
||||
// so typing a full asset ID puts that asset first rather than whichever ID
|
||||
// happens to sort first.
|
||||
function runAssetSearch(q){
|
||||
q = String(q||'').trim().toLowerCase();
|
||||
if(!q || assetCatalogState !== 'ready'){
|
||||
assetResults = []; renderAssetResults(); openAssetResults(false); return;
|
||||
}
|
||||
const exact=[], prefix=[], other=[];
|
||||
// The cap bounds each TIER, never the scan: breaking on a combined count let
|
||||
// 500 alphabetically-early contains-matches evict an exact or prefix match
|
||||
// that sorted after them - and Enter then added the wrong asset. The scan is
|
||||
// in-memory and cheap; "the box scrolls, not the search" means the search
|
||||
// sees everything.
|
||||
for(const tag of assetCatalog){
|
||||
const t = tag.toLowerCase();
|
||||
if(t === q) exact.push(tag);
|
||||
else if(t.startsWith(q)){ if(prefix.length < ASSET_RESULT_MAX) prefix.push(tag); }
|
||||
else if(t.includes(q)){ if(other.length < ASSET_RESULT_MAX) other.push(tag); }
|
||||
}
|
||||
assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
|
||||
renderAssetResults();
|
||||
openAssetResults(true);
|
||||
}
|
||||
|
||||
function renderAssetResults(){
|
||||
const box = document.getElementById('asset-results'); if(!box) return;
|
||||
if(!assetResults.length){
|
||||
box.innerHTML = `<div class="asset-result-note">No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = assetResults.map((tag,ix) => {
|
||||
const on = assetAlreadyAdded(tag);
|
||||
return `<button type="button" class="asset-result${on?' is-added':''}" ${on?'disabled':''} data-asset-ix="${ix}">
|
||||
<span class="asset-result-tag">${esc(tag)}</span>
|
||||
<span class="asset-result-add">${on ? 'added' : '+ add'}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openAssetResults(open){
|
||||
const box = document.getElementById('asset-results');
|
||||
const inp = document.getElementById('asset-search');
|
||||
if(box) box.hidden = !open;
|
||||
if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function addCatalogAsset(ix){
|
||||
const tag = assetResults[ix]; if(!tag) return;
|
||||
if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
|
||||
pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
|
||||
buildAssets();
|
||||
toast('Added ' + tag); // announced (role=status) - a keyboard pick is otherwise silent
|
||||
// Mark just this row instead of re-rendering the list: the results stay open
|
||||
// for the next pick, the scroll position holds, and the clicked element is
|
||||
// never detached mid-click (see the composedPath note in initAssetPicker).
|
||||
markAssetResultAdded(ix);
|
||||
const box = document.getElementById('asset-search');
|
||||
if(box) box.focus(); // keep typing straight into the next search
|
||||
track('asset_added',{source:'catalog'});
|
||||
}
|
||||
|
||||
function markAssetResultAdded(ix){
|
||||
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
|
||||
if(!row) return;
|
||||
row.classList.add('is-added');
|
||||
row.disabled = true;
|
||||
const label = row.querySelector('.asset-result-add');
|
||||
if(label) label.textContent = 'added';
|
||||
}
|
||||
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
|
||||
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||
|
||||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||||
function buildAttach(){
|
||||
@@ -1241,7 +1580,7 @@ function wpFilesMirror(){
|
||||
function wpFilesRender(){
|
||||
const box=document.getElementById('wp-file-list'); if(!box) return;
|
||||
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
|
||||
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
|
||||
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a>
|
||||
<span class="wf-size">${wpFileSize(f.size||0)}</span>
|
||||
<input type="text" value="${(f.description||'').replace(/"/g,'"')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
|
||||
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
|
||||
@@ -1310,7 +1649,7 @@ function renderSopFileFolders(){
|
||||
const srcs=sopLinkedSources();
|
||||
box.innerHTML = srcs.length
|
||||
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">▸ ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||
}
|
||||
function toggleSopFilePanel(){
|
||||
@@ -1417,7 +1756,7 @@ function readiness(){
|
||||
function updateReleaseBanner(){
|
||||
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
||||
let cls, txt;
|
||||
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
|
||||
if(st==='Issue'){ cls='rb-hold'; txt=`⊘ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
|
||||
else if(st==='Ready for QA'){
|
||||
// CR-014: the QA decision lives where the state is announced. Accept moves to
|
||||
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
|
||||
@@ -1764,6 +2103,10 @@ function collectPackage(){
|
||||
};
|
||||
}
|
||||
async function savePackage(view){
|
||||
if(window._projArchived){
|
||||
toast('This project is archived — read-only. Nothing can be saved to it.', 'alert');
|
||||
return;
|
||||
}
|
||||
if(!wpValidateForm()) return;
|
||||
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
||||
// The status can also be set programmatically (the per-discipline roll-up), so
|
||||
@@ -1839,10 +2182,12 @@ function renderPackage(pkg){
|
||||
// because "the toggle does nothing" and "the toggle governs one row" look the
|
||||
// same from outside.
|
||||
|
||||
// D11: two columns now. The controls.dev link column died with the link field;
|
||||
// a catalog row's identity is its ID, and the note is the user's own text.
|
||||
if(pkg.assets&&pkg.assets.length){
|
||||
let t=`<table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
||||
add('assets', 'Assets (controls.dev)', t+`</tbody></table>`);
|
||||
let t=`<table><thead><tr><th style="width:240px">Asset ID</th><th>Note</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`);
|
||||
add('assets', 'Assets', t+`</tbody></table>`);
|
||||
}
|
||||
|
||||
let scopeHtml;
|
||||
@@ -1935,7 +2280,11 @@ function renderPackage(pkg){
|
||||
}
|
||||
function printPackage(){
|
||||
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
|
||||
// C4/T9.9 (and BL-008): the popup document has no stylesheet, so the theme's
|
||||
// values are read from the live page and inlined - including THE blue where
|
||||
// the second brand blue (#2563d6) used to be.
|
||||
const tok=(name)=>getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:${tok('--cds-text-primary')};padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid ${tok('--cds-highlight')};padding-bottom:4px;margin-top:22px;color:${tok('--cds-link-primary')}}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:${tok('--cds-layer-accent')};border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px;text-align:left}td{border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
|
||||
w.document.close(); w.print();
|
||||
}
|
||||
|
||||
@@ -2585,12 +2934,12 @@ const WP_NAV_CRITICAL_CSS = `
|
||||
body{--nav-w:288px;}
|
||||
body.wp-nav-collapsed{--nav-w:56px;}
|
||||
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
|
||||
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
|
||||
border-right:1px solid #e0e0e0;}
|
||||
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:var(--wp-nav-bg);
|
||||
border-right:1px solid var(--cds-border-subtle);}
|
||||
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
|
||||
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
|
||||
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
|
||||
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
|
||||
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:var(--cds-text-on-color);
|
||||
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||||
.wp-nav-body{min-width:0;flex:1 1 auto;}
|
||||
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
@@ -2682,8 +3031,19 @@ function setWpNavView(view){
|
||||
// A stable colour per package so the same one is always the same swatch — the cue
|
||||
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
|
||||
// it doesn't shuffle when a package is added or deleted.
|
||||
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
|
||||
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
|
||||
const WP_BADGE_COLORS = (() => {
|
||||
// C4/T9.9: the values live in theme-light.css (--wp-chart-1..10), the ONE
|
||||
// place a colour may exist; this reads them at boot. The fallback array is
|
||||
// token NAMES, not values - if the theme fails to load there are no colours
|
||||
// anywhere, which is the correct failure.
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const out = [];
|
||||
for(let k = 1; k <= 10; k++){
|
||||
const v = (cs.getPropertyValue('--wp-chart-' + k) || '').trim();
|
||||
out.push(v || 'var(--wp-chart-' + k + ')');
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
function wpBadgeColor(p){
|
||||
const key = (p.number || p.id || '') + '';
|
||||
let h = 0;
|
||||
@@ -2790,7 +3150,7 @@ function renderWpNav(){
|
||||
const h = live[live.length - 1];
|
||||
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
|
||||
: 'no reason recorded — log it from the status control';
|
||||
holdLine = '<span class="wp-nav-hold">⛔ ' + (open ? open + ' open — ' : '')
|
||||
holdLine = '<span class="wp-nav-hold">⊘ ' + (open ? open + ' open — ' : '')
|
||||
+ esc(why) + '</span>';
|
||||
}
|
||||
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
||||
@@ -2911,7 +3271,18 @@ function loadPackageIntoForm(p){
|
||||
onClashChange();
|
||||
applyKind();
|
||||
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
|
||||
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
|
||||
buildCostCodes();
|
||||
// BL-019 (fixed at T9.9): setting a <select> to a value with no option does
|
||||
// NOTHING, silently - so a code that left COST_CODES blanked on open and the
|
||||
// next save wrote the blank over the record. Same fix as gov_wosize: keep
|
||||
// the stored value as an option so the round-trip preserves it.
|
||||
{ const cs=document.getElementById('wp_cost');
|
||||
if(p.cost && ![...cs.options].some(o=>o.value===p.cost)){
|
||||
const o=document.createElement('option');
|
||||
o.value=p.cost; o.textContent=p.cost+' (not in the current list)';
|
||||
cs.appendChild(o);
|
||||
}
|
||||
cs.value=p.cost||''; }
|
||||
set('wp_wbs',p.wbs);
|
||||
document.getElementById('wp_kit_status').value=p.kitStatus||'';
|
||||
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
|
||||
@@ -2933,7 +3304,7 @@ function loadPackageIntoForm(p){
|
||||
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
// collections
|
||||
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
|
||||
pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
|
||||
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
||||
@@ -3004,7 +3375,7 @@ function newPackage(){
|
||||
setRadio('status','Draft');
|
||||
numberDims={}; buildNumberDims();
|
||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
|
||||
pkgAssets=[]; buildAssets();
|
||||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=['']; buildWorkSteps();
|
||||
@@ -3406,10 +3777,22 @@ function renderDashboard(){
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
${card('Est. hrs', Math.round(estH))}
|
||||
${card('Actual hrs', Math.round(actH))}
|
||||
${(()=>{
|
||||
// D12 (was BL-023): the productivity factor - actual against estimated,
|
||||
// the measure CR-017's tracking exists to enable. At or under 1.0 the
|
||||
// work beat the estimate (green); over it (red). Both hour fields are
|
||||
// optional, so with nothing to divide the card shows an em dash rather
|
||||
// than vanishing - a metric that disappears reads as "no such measure",
|
||||
// not "nothing logged yet". Server sums (B4), same as its neighbours.
|
||||
const pf = (estH > 0 && actH > 0) ? actH / estH : null;
|
||||
return card('Productivity (act/est)', pf === null ? '—' : pf.toFixed(2),
|
||||
pf === null ? '' : (pf <= 1 ? 'dm-green' : 'dm-red'));
|
||||
})()}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips (status chips also filter the board)
|
||||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||
// C1/T9.5: the chip filters the board, so it is a button.
|
||||
const statusChip=(label,count,cls,status)=>`<button type="button" class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></button>`;
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||
@@ -3430,7 +3813,7 @@ function renderDashboard(){
|
||||
|
||||
// gating panel — what's blocking release, from the server
|
||||
const gated=m.gating||[];
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">⊘ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||||
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
|
||||
gated.map(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td>
|
||||
<td>${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+
|
||||
@@ -3610,11 +3993,17 @@ function openSopModal(){
|
||||
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
|
||||
}
|
||||
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
|
||||
// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for
|
||||
// the whole suite - and its report lives on the admin console, where an
|
||||
// operator-facing readout belongs. This page only records. Same key, same
|
||||
// event shape: everything recorded before the move is still readable after it.
|
||||
function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); }
|
||||
// CR-019 / T11.6 (2026-09-23): this used to write to wp-usage.js's per-browser
|
||||
// localStorage log (D5/T7.10). Retired along with the admin console's old
|
||||
// "Usage logs" panel, its only reader - real, server-side, per-user activity
|
||||
// now exists (UsageEvent, the Activity & usage card, T11.1-T11.5) and this
|
||||
// data was never reliably tied to a real identity anyway, so it was not a
|
||||
// source the new report could adopt (decisions-2026-09-17.md). track() stays
|
||||
// as a no-op rather than deleting its ~35 call sites throughout this file:
|
||||
// removing every call individually is a much larger, riskier diff for the
|
||||
// same outcome, and a call site here still documents the moment worth
|
||||
// recording if usage analytics are ever rebuilt server-side.
|
||||
function track(event,detail){ /* retired, T11.6 — see comment above */ }
|
||||
|
||||
// ── COMMENTS ─────────────────────────────────────────────────────────────────
|
||||
const COMMENTS_KEY='wp_iwp_comments_v1';
|
||||
@@ -3633,7 +4022,9 @@ async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filte
|
||||
if(!mine){ toast('No comments to clear.', 'alert'); return; }
|
||||
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
|
||||
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
||||
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
||||
function cmtInit(){
|
||||
const ov=document.getElementById('cmt-overlay');
|
||||
if(ov && !ov._wired){ ov._wired=true; ov.addEventListener('click', toggleComments); } const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
||||
|
||||
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
||||
@@ -3830,6 +4221,16 @@ function bootData(){
|
||||
loadLocations(); // CR-004: the option lists come from the server
|
||||
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||||
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
|
||||
initAssetPicker(); // D11: the Micron catalog, fetched once per page load
|
||||
// D7: the read-only courtesy needs the SERVER's answer, not a stale local
|
||||
// summary - the page's project comes from the URL, which may not be the one
|
||||
// last stored. The chip and the save guard both read this flag.
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){
|
||||
ProjectData.get(activeProjectId).then(p=>{
|
||||
window._projArchived = !!(p && p.archived);
|
||||
if(window._projArchived) renderCtxBar();
|
||||
}).catch(()=>{});
|
||||
}
|
||||
initWpNavDrawer();
|
||||
renderSavedList();
|
||||
positionSectionNav();
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||
<script src="wp-autosave.js"></script>
|
||||
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
|
||||
@@ -303,12 +302,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ASSETS (controls.dev) -->
|
||||
<!-- ASSETS (Micron asset catalog) -->
|
||||
<div class="card" id="asset-card">
|
||||
<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="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>
|
||||
<button class="add-btn" onclick="addAsset()">+ Add asset</button>
|
||||
<div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
|
||||
<div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
|
||||
<div class="asset-pick" id="asset-pick">
|
||||
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
|
||||
placeholder="Search asset IDs, or paste a column from Excel…"
|
||||
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
|
||||
<div class="asset-results" id="asset-results" hidden></div>
|
||||
</div>
|
||||
<!-- role=status: loading -> ready/absent/error announces (the login.html pattern) -->
|
||||
<div class="field-hint" id="asset-source-note" role="status"></div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||
<div class="material-actions">
|
||||
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
|
||||
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
|
||||
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DISCIPLINES -->
|
||||
@@ -440,12 +451,12 @@
|
||||
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||
<div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
|
||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">✎ Edit (reason required)</button><span class="override-note"></span></div>
|
||||
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
|
||||
</div>
|
||||
|
||||
@@ -476,7 +487,7 @@
|
||||
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & view</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||
</div></div>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
@@ -591,7 +602,9 @@
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS DRAWER -->
|
||||
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
|
||||
<!-- The backdrop is NOT a control (C1): pointer dismissal is attached in
|
||||
cmtInit(), and Escape + the drawer's close button are the real paths. -->
|
||||
<div class="cmt-overlay" id="cmt-overlay"></div>
|
||||
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
||||
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
||||
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
||||
@@ -614,7 +627,7 @@
|
||||
<span class="sticky-status" id="sticky-status"></span>
|
||||
<div class="sticky-actions">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & view</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
after .main is what lets it be a sticky column without wrapping the layout. */
|
||||
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
|
||||
.main { min-width: 0; max-width: none; margin: 0;
|
||||
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
|
||||
padding: 14px 28px 72px calc(var(--nav-w,288px) + 28px); /* F6: top pad only; bottom stays clear of the sticky bar */
|
||||
transition: padding-left .18s ease; }
|
||||
|
||||
.section { display: none; }
|
||||
@@ -308,7 +308,7 @@
|
||||
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); }
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border); } /* F6 */
|
||||
.btn {
|
||||
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s;
|
||||
@@ -537,7 +537,7 @@
|
||||
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||
|
||||
/* ── 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:none; margin:0; padding:7px 28px 7px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; /* F6: denser, still the SOP identity strip */
|
||||
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
||||
@@ -575,7 +575,7 @@
|
||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||
.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 .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
||||
.release-banner .rb-inner { margin-top:8px; border-radius:var(--radius); padding:8px 16px; font-size:13px; font-weight:600; /* F6: A2's one warning, denser */
|
||||
display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
|
||||
@@ -689,7 +689,9 @@
|
||||
|
||||
/* Dev mode (comment 7) */
|
||||
.logo-wrap { position:relative; display:flex; align-items:center; }
|
||||
.dev-toggle { position:absolute; left:2px; bottom:-9px; width:18px; height:7px; padding:0; border:none;
|
||||
/* C2/T9.6: a deliberately unobtrusive dev switch is still a control - it
|
||||
meets the 24px floor and earns its subtlety with opacity, not size. */
|
||||
.dev-toggle { position:absolute; left:2px; bottom:-12px; width:24px; height:24px; padding:0; border:none;
|
||||
background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; }
|
||||
.dev-toggle:hover { opacity:0.35; }
|
||||
.dev-banner { background:var(--wp-dev-bg); color:var(--wp-dev-fg); font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; }
|
||||
@@ -756,7 +758,13 @@
|
||||
/* A collapsed section should read as a ROW in a list, not as a box with one line
|
||||
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
|
||||
which is a third of what F6 was measuring, arriving by a different door. */
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
/* F6 strict 2.0: a collapsed row is 36px on fine pointers - 13 of them at
|
||||
rest is where most of the two-screens overage lived. Coarse pointers keep
|
||||
the taller row below (the 44px tablet target, C1). */
|
||||
.card.collapsed { padding-top: 5px; padding-bottom: 5px; }
|
||||
@media (pointer: coarse) {
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
}
|
||||
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
||||
.card.collapsed .sub-heading { margin-bottom: 0; }
|
||||
|
||||
@@ -906,6 +914,38 @@
|
||||
border:1px solid var(--border); border-radius:3px; }
|
||||
.pp-free .field-hint { margin-top:4px; }
|
||||
|
||||
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
|
||||
A search box over a read-only catalog. Results drop below the input and are
|
||||
added to the table as rows; the catalog itself is never written to. */
|
||||
.asset-pick { position:relative; margin-bottom:10px; }
|
||||
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
|
||||
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
|
||||
box-sizing:border-box; }
|
||||
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
|
||||
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
|
||||
max-height:320px; overflow-y:auto; background:var(--surface);
|
||||
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
|
||||
box-shadow:0 8px 24px rgba(20,30,50,.18); }
|
||||
.asset-results[hidden] { display:none; }
|
||||
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
|
||||
width:100%; text-align:left; background:none; border:0;
|
||||
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
|
||||
.asset-result:hover:not(:disabled) { background:var(--surface2); }
|
||||
.asset-result:disabled { cursor:default; opacity:.55; }
|
||||
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
|
||||
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
|
||||
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
|
||||
/* Marks rows the catalog vouches for, so a manually typed asset is never
|
||||
mistaken for a looked-up one. */
|
||||
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
|
||||
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
|
||||
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
|
||||
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
|
||||
.asset-tag { font-weight:600; }
|
||||
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
|
||||
|
||||
/* 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);
|
||||
@@ -1115,7 +1155,8 @@
|
||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||
.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-chip[onclick] { cursor:pointer; }
|
||||
/* Chips are buttons since T9.5; reset the button chrome, keep the chip look. */
|
||||
button.dash-chip { font:inherit; font-size:12px; cursor:pointer; }
|
||||
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||
|
||||
172
html/wp-dialog.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/* Dialog kit + toast, shared (BL-024, 2026-08-20).
|
||||
*
|
||||
* The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
|
||||
* (index.html), the admin console and the user console carried 21 native
|
||||
* dialogs between them. Same contract as the creator's copy:
|
||||
*
|
||||
* wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise<bool>
|
||||
* wpPromptDialog({title, message, label, value, validate}) -> Promise<string|null>
|
||||
* wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
|
||||
* toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
|
||||
*
|
||||
* Self-contained on purpose: markup and styles are injected on first use, the
|
||||
* styles are theme tokens only (the token rule), and the class names are its
|
||||
* own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
|
||||
* The creator keeps its inline copy - it owns the same-id markup in its HTML -
|
||||
* so everything here is guarded: if the page already has the kit, this file
|
||||
* defines nothing.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
|
||||
|
||||
var CSS =
|
||||
'#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
|
||||
'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
|
||||
'#wp-dlg-overlay.open{display:flex;}' +
|
||||
'.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
|
||||
'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
|
||||
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
|
||||
'.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
|
||||
'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
|
||||
'.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
|
||||
'color:var(--cds-text-secondary);padding:4px 6px;}' +
|
||||
'.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'.wp-dlg-body{padding:16px 18px;}' +
|
||||
'#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
|
||||
'#wp-dlg-input-wrap{margin-top:10px;}' +
|
||||
'#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
|
||||
'#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
|
||||
'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
|
||||
'#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
|
||||
'#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
|
||||
'#wp-dlg-err:empty{display:none;}' +
|
||||
'.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
|
||||
'border-top:1px solid var(--cds-border-subtle);}' +
|
||||
'.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
|
||||
'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
|
||||
'.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
|
||||
'color:var(--cds-text-on-color);}' +
|
||||
'.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
|
||||
'#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
|
||||
'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
|
||||
'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
|
||||
'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
|
||||
'#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
|
||||
|
||||
function ensure() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) return ov;
|
||||
var st = document.createElement('style');
|
||||
st.textContent = CSS;
|
||||
document.head.appendChild(st);
|
||||
ov = document.createElement('div');
|
||||
ov.id = 'wp-dlg-overlay';
|
||||
ov.setAttribute('role', 'dialog');
|
||||
ov.setAttribute('aria-modal', 'true');
|
||||
ov.setAttribute('aria-labelledby', 'wp-dlg-title');
|
||||
ov.innerHTML =
|
||||
'<div class="wp-dlg">' +
|
||||
'<div class="wp-dlg-head"><div id="wp-dlg-title"></div>' +
|
||||
'<button type="button" class="wp-dlg-x" id="wp-dlg-x" title="Cancel" aria-label="Cancel">✕</button></div>' +
|
||||
'<div class="wp-dlg-body">' +
|
||||
'<div id="wp-dlg-msg"></div>' +
|
||||
'<div id="wp-dlg-input-wrap">' +
|
||||
'<label id="wp-dlg-label" for="wp-dlg-input"></label>' +
|
||||
'<input type="text" id="wp-dlg-input">' +
|
||||
'<div id="wp-dlg-err" role="alert"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wp-dlg-foot">' +
|
||||
'<button type="button" class="wp-dlg-btn" id="wp-dlg-cancel">Cancel</button>' +
|
||||
'<button type="button" class="wp-dlg-btn primary" id="wp-dlg-ok">OK</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-dlg-x').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-ok').addEventListener('click', ok);
|
||||
document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') ok();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
|
||||
});
|
||||
return ov;
|
||||
}
|
||||
|
||||
var resolveFn = null;
|
||||
|
||||
function open(opts) {
|
||||
return new Promise(function (res) {
|
||||
resolveFn = res;
|
||||
var ov = ensure();
|
||||
ov._opts = opts || {};
|
||||
document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
|
||||
document.getElementById('wp-dlg-msg').textContent = opts.message || '';
|
||||
document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
|
||||
document.getElementById('wp-dlg-label').textContent = opts.label || '';
|
||||
var inp = document.getElementById('wp-dlg-input');
|
||||
inp.value = (opts.value != null ? String(opts.value) : '');
|
||||
document.getElementById('wp-dlg-err').textContent = '';
|
||||
document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
|
||||
var cb = document.getElementById('wp-dlg-cancel');
|
||||
cb.textContent = opts.cancelLabel || 'Cancel';
|
||||
cb.style.display = opts.okOnly ? 'none' : '';
|
||||
ov.classList.add('open');
|
||||
setTimeout(function () {
|
||||
(opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function close(val) {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) ov.classList.remove('open');
|
||||
var r = resolveFn;
|
||||
resolveFn = null;
|
||||
if (r) r(val);
|
||||
}
|
||||
|
||||
function ok() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
if (opts.input) {
|
||||
var v = document.getElementById('wp-dlg-input').value;
|
||||
if (opts.validate) {
|
||||
var err = opts.validate(v);
|
||||
if (err) {
|
||||
document.getElementById('wp-dlg-err').textContent = err;
|
||||
document.getElementById('wp-dlg-input').focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
close(v);
|
||||
} else close(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
close(opts.input ? null : false);
|
||||
}
|
||||
|
||||
global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
|
||||
global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
|
||||
global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
|
||||
|
||||
if (typeof global.toast !== 'function') {
|
||||
// S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
|
||||
global.toast = function (msg, kind) {
|
||||
ensure();
|
||||
var t = document.getElementById('toast');
|
||||
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
|
||||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(global.toast._t);
|
||||
global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
|
||||
};
|
||||
}
|
||||
})(window);
|
||||
@@ -120,12 +120,12 @@
|
||||
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;';
|
||||
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;background:var(--cds-layer);';
|
||||
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin:14px 0 4px;font-weight:600;';
|
||||
var hint = 'font-size:11.5px;color:var(--cds-text-helper);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 & time</div>' +
|
||||
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);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 var(--cds-border-subtle);font-weight:700;">Language & 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 & number format</label>' +
|
||||
@@ -135,19 +135,19 @@
|
||||
'<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 id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:var(--cds-layer-accent);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 style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);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';
|
||||
e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)';
|
||||
e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
|
||||
@@ -105,6 +105,18 @@
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
|
||||
// throws - which used to land in catch() and read as "could not reach the
|
||||
// server" while the server was answering fine (found 2026-08-23, the
|
||||
// production locations import). Read text, parse if it parses, keep status.
|
||||
function readJson(r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
|
||||
return { ok: r.ok, status: r.status, body: j };
|
||||
});
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
@@ -114,7 +126,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
@@ -142,7 +154,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
@@ -161,7 +173,7 @@
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{ id: 'scope', label: 'Scope of Work',
|
||||
note: 'The ordered steps the crew performs, and the labour estimate.' },
|
||||
{ id: 'assets', label: 'Assets',
|
||||
note: 'Asset tags and controls.dev links. Off for Micron EUV — the content duplicates the database Clinton’s team maintains (CR-016).' },
|
||||
note: 'Asset IDs picked read-only from the Micron DB (D11), with manual entry for anything not listed. Off for Micron EUV — the customer’s own database stays the source of truth; this section only references it (CR-016).' },
|
||||
{ id: 'materials', label: 'Materials',
|
||||
note: 'The bill of materials that feeds kitting.' },
|
||||
{ id: 'kitting', label: 'Kitting',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{ 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',
|
||||
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console',
|
||||
sub: 'Settings & diagnostics',
|
||||
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
||||
// Account actions, inherited from the flat user menu that used to sit in the app
|
||||
@@ -51,9 +51,8 @@
|
||||
// drawer already had; these two were its only unique contents, so they moved here
|
||||
// rather than being lost with it. `action` items render as buttons, not links.
|
||||
{ section: 'Account' },
|
||||
{ action: 'wpPreferences', icon: '⌚', label: 'Language & time',
|
||||
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
||||
sub: 'Dates, numbers and time zone' },
|
||||
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
||||
];
|
||||
|
||||
function esc(v) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/* Usage analytics core — the ONE implementation (D5 / T7.10).
|
||||
|
||||
This existed three times: the creator's copy, the wizard's copy (which had no
|
||||
caller — the button lived on the creator), and the admin console's own reader.
|
||||
Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
|
||||
document as five colliding globals; an unreferenced duplicate is exactly what
|
||||
produced D5. One core now; the pages keep only a thin track() wrapper because
|
||||
page state (the creator's dev-mode pause) belongs to the page.
|
||||
|
||||
The storage KEYS are unchanged on purpose: everything recorded before this
|
||||
file existed is still readable through it. No field VALUES are ever stored —
|
||||
a field-edit event records the field id, nothing else.
|
||||
|
||||
Classic script, no modules: exposes window.WPUsage. */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
function load(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
|
||||
catch (e) { return { events: [] }; }
|
||||
}
|
||||
|
||||
function save(key, data) {
|
||||
try { localStorage.setItem(key, JSON.stringify(data)); }
|
||||
catch (e) { /* storage unavailable — degrade silently */ }
|
||||
}
|
||||
|
||||
function track(key, event, detail) {
|
||||
try {
|
||||
var d = load(key);
|
||||
d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
|
||||
if (d.events.length > 5000) d.events = d.events.slice(-5000);
|
||||
save(key, d);
|
||||
} catch (e) { /* never let telemetry break the tool it watches */ }
|
||||
}
|
||||
|
||||
function download(key, prefix) {
|
||||
var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.WPUsage = {
|
||||
load: load,
|
||||
save: save,
|
||||
track: track,
|
||||
download: download,
|
||||
// The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
|
||||
KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
|
||||
};
|
||||
})();
|
||||
@@ -12,14 +12,58 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
# CORS_ORIGINS=http://localhost:5500
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||
# the API falls back to a random per-process key, so logins reset on every
|
||||
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||
# There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in
|
||||
# still ends the same way it always did: a signed JWT in an HttpOnly session
|
||||
# cookie, which is what the four vars right below this line are for. The five
|
||||
# OKTA_* vars after that are what makes the actual sign-in possible; without
|
||||
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
|
||||
# describe()'s startup log line says so and nobody can sign in.
|
||||
|
||||
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
|
||||
# someone is — this app still decides roles/authorization locally, unchanged
|
||||
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
|
||||
# falls back to a random per-process key, so logins reset on every restart and
|
||||
# break across multiple gunicorn workers. Generate a strong one:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
# D18 (2026-09-23): a session slides on activity, capped by a hard ceiling
|
||||
# underneath - not one flat lifetime. Both defaults are proposed, not
|
||||
# confirmed against this tenant's actual Okta SSO session policy - if Okta's
|
||||
# own session outlives either number, re-auth here is likely a fast redirect
|
||||
# rather than a real login screen, so these cost less than they look like.
|
||||
#
|
||||
# No request for this many minutes ends the session outright.
|
||||
# AUTH_IDLE_MINUTES=30
|
||||
#
|
||||
# The absolute ceiling from original sign-in, regardless of activity - no
|
||||
# session outlives this no matter how continuously active it is. Default 8.
|
||||
# AUTH_SESSION_HOURS=8
|
||||
|
||||
# ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
|
||||
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default
|
||||
# or a custom authorization server URL. The API discovers the authorize/token/
|
||||
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing
|
||||
# else about Okta's endpoints is hand-entered.
|
||||
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
|
||||
|
||||
# Client ID and secret from the Okta app integration (Sign-in method: OIDC -
|
||||
# Authorization Code, Application type: Web Application). The secret is exactly
|
||||
# that — treat it like AUTH_SECRET_KEY, never commit it.
|
||||
OKTA_CLIENT_ID=CHANGE_ME
|
||||
OKTA_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# Must exactly match a "Sign-in redirect URI" registered on the Okta app
|
||||
# integration, scheme and path included, e.g.:
|
||||
# https://wp-suite.company.local/api/auth/okta/callback
|
||||
OKTA_REDIRECT_URI=CHANGE_ME
|
||||
|
||||
# Which ID token claim carries this person's directory identity, matched
|
||||
# against the local users.username column (server/app.py's okta_callback()).
|
||||
# preferred_username is Okta's usual default for an AD-imported user; override
|
||||
# it if your security team's Okta configuration uses a different claim (upn,
|
||||
# a custom claim, …) — no code change needed, just this value.
|
||||
# OKTA_IDENTITY_CLAIM=preferred_username
|
||||
|
||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||
@@ -30,3 +74,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||
# on and SMTP is configured.
|
||||
# SMTP_PASSWORD=your-smtp-app-password
|
||||
|
||||
# ── Micron asset catalog (optional) ───────────────────────────────────────────
|
||||
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
|
||||
# app only ever runs the single SELECT in server/assets_db.py, so give it a
|
||||
# db_datareader login and nothing more.
|
||||
#
|
||||
# Leave this unset and the suite works normally — the picker reports that no
|
||||
# catalog is configured and people type asset tags in by hand.
|
||||
#
|
||||
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
|
||||
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
|
||||
#
|
||||
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
|
||||
# and install the Microsoft ODBC driver in the image:
|
||||
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
||||
#
|
||||
# Two things to check when the picker says the catalog is unreachable:
|
||||
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
|
||||
# Micron schema — that one constant is the whole schema contract.
|
||||
# 2. The api container is on the `outbound` network in docker-compose.yml. The
|
||||
# `internal` network has no default gateway, which blocks the VPN as well as
|
||||
# the internet.
|
||||
|
||||
@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||
| GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) |
|
||||
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
|
||||
| POST | `/api/auth/logout` | clear the session cookie |
|
||||
| GET | `/api/auth/me` | the logged-in user |
|
||||
| POST | `/api/auth/password` | change your own password |
|
||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||
| POST | `/api/auth/users` | pre-create an account by username (**admin**) |
|
||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
@@ -40,50 +40,81 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
||||
|
||||
---
|
||||
|
||||
## Login portal (user accounts)
|
||||
## Sign-in (Okta)
|
||||
|
||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||
request is validated by checking the cookie's signature and expiry.
|
||||
There is no local password anywhere in this app (D15/D16) — Okta OIDC is the
|
||||
only way in. `login.html` is a single "Sign in with Okta" button; the actual
|
||||
exchange is `server/okta_auth.py` (the Okta client config) and the two routes
|
||||
in `server/app.py`: `okta_login()` sends the browser to Okta's authorize
|
||||
endpoint, `okta_callback()` exchanges the code, matches the ID token's identity
|
||||
claim against `users.username`, and signs the person in.
|
||||
|
||||
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly,
|
||||
SameSite=Lax** cookie (`wp_session`), marked **Secure** automatically whenever
|
||||
the request arrives over HTTPS (via NGINX's `X-Forwarded-Proto`). There is no
|
||||
server-side session store — each request is validated by checking the cookie's
|
||||
signature and expiry. Okta only confirms *who* someone is; this app still
|
||||
decides *what* they may do — roles, project membership, everything below stays
|
||||
local and unchanged by Okta.
|
||||
|
||||
**The real security boundary is the API:** every `/api/` data route is refused
|
||||
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||
to `login.html` when there's no session — that's for UX, not protection.
|
||||
|
||||
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||
`admin` (may manage users) and `user`.
|
||||
**Access gating is Okta's job, not this app's.** Only accounts assigned to the
|
||||
app integration in Okta can complete the sign-in flow at all, so there is no
|
||||
required-group or claim check layered on top here. Once Okta lets someone
|
||||
through, this app decides their role — see below.
|
||||
|
||||
### Set the signing secret
|
||||
Roles are `admin`, `project_super_user`, `project_admin`, `project_user`
|
||||
(`html/users.js`, enforced server-side).
|
||||
|
||||
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||
without it the API uses a random per-process key, so logins reset on restart.
|
||||
### Set the signing secret and the Okta app integration
|
||||
|
||||
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see
|
||||
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY`
|
||||
is **required in production**: without it the API uses a random per-process
|
||||
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the
|
||||
same way — the API starts without them, it just refuses every sign-in and says
|
||||
so in the startup log (`okta_auth.describe()`).
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
The Okta app integration itself (sign-in method OIDC, Application type Web
|
||||
Application) needs its **Sign-in redirect URI** set to exactly
|
||||
`OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to
|
||||
it — that assignment IS the access control (see above).
|
||||
|
||||
### Create the first admin
|
||||
|
||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||
shell (run from the **project root**, like uvicorn):
|
||||
There's no `create-admin` command anymore — creating an account from scratch
|
||||
by hand-typed username risks a second, orphaned row if it doesn't exactly match
|
||||
what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead,
|
||||
have the first admin **sign in through Okta once** — they land as an ordinary
|
||||
`project_user`, JIT-provisioned — then promote that existing row from a shell
|
||||
(run from the **project root**, like uvicorn):
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
# prompts for a password (min 8 chars)
|
||||
python -m server.manage_users promote alice --role admin
|
||||
```
|
||||
|
||||
In Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
docker compose exec api python -m server.manage_users promote alice --role admin
|
||||
```
|
||||
|
||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
Other commands: `list`, `disable <user>`, `enable <user>`. After the first
|
||||
admin exists, they can promote others through the User Directory page (or keep
|
||||
using the CLI) — no shell access needed for anyone after the first.
|
||||
|
||||
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
|
||||
unreachable for everyone, including admins, until Okta is restored (D16) — this
|
||||
is a deliberate choice, the same one the abandoned LDAPS design made, not an
|
||||
oversight.
|
||||
|
||||
---
|
||||
|
||||
@@ -291,25 +322,25 @@ docker compose down -v
|
||||
|
||||
## Quick test
|
||||
|
||||
`/api/health` is open; data routes now require a session, so log in first and
|
||||
reuse the cookie jar:
|
||||
`/api/health` is open; every other `/api/` route needs a session cookie:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||
|
||||
# Sign in, saving the session cookie to a jar
|
||||
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"<password>"}'
|
||||
|
||||
# Reuse the cookie on protected routes
|
||||
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||
Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
curl https://wp-suite.company.local/api/health
|
||||
```
|
||||
|
||||
There's no `curl`-able login anymore — Okta requires a real browser to
|
||||
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
|
||||
exercise a protected route from a script instead, use `server/smoketest.py`'s
|
||||
own technique (mint a session with `auth.create_token()` and set it as the
|
||||
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
|
||||
back an identity) rather than reaching for curl by hand — see that script's
|
||||
own AUTHENTICATION section for the exact steps, and why it has to run
|
||||
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.
|
||||
|
||||
@@ -52,6 +52,13 @@ def run_migrations_online() -> None:
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
# Each migration commits on its own. One transaction for the WHOLE
|
||||
# run meant a crash at step N rolled back steps 1..N-1 while their
|
||||
# "Running upgrade" lines stayed on screen claiming they ran - the
|
||||
# 2026-08-21 outage's stamp-to-head repair trusted those lines and
|
||||
# left production missing two tables (found 2026-08-23 when the
|
||||
# locations import 500'd on a table that "had been created").
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
29
server/alembic/versions/1d60a608bb51_drop_local_password.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""drop local password (T10.4, wave 10 / D15 / D16)
|
||||
|
||||
Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
|
||||
`password_hash` was NOT NULL at the database level since the baseline schema, so
|
||||
downgrade re-adds it with server_default='' rather than leaving existing rows
|
||||
without a value — the same pattern used for the locale/timezone drop-precedent
|
||||
columns, applied in reverse.
|
||||
|
||||
Revision ID: 1d60a608bb51
|
||||
Revises: a1b8c6d4e2f9
|
||||
Create Date: 2026-09-03 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1d60a608bb51'
|
||||
down_revision = 'a1b8c6d4e2f9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column('users', 'password_hash')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))
|
||||
46
server/alembic/versions/8e2cb3003f8a_usage_events.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""usage events (CR-019, wave 11)
|
||||
|
||||
Append-only navigation/session activity, separate from audit_log on purpose —
|
||||
see the UsageEvent docstring in server/models.py. Retention is indefinite by
|
||||
decision (docs/waves/decisions-2026-09-17.md); nothing here schedules a purge.
|
||||
|
||||
Revision ID: 8e2cb3003f8a
|
||||
Revises: 1d60a608bb51
|
||||
Create Date: 2026-09-23 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8e2cb3003f8a'
|
||||
down_revision = '1d60a608bb51'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('usage_events',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('username', sa.String(length=200), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('tool', sa.String(length=40), nullable=False),
|
||||
sa.Column('event', sa.String(length=40), nullable=False),
|
||||
sa.Column('detail', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_usage_events_at'), 'usage_events', ['at'], unique=False)
|
||||
op.create_index(op.f('ix_usage_events_username'), 'usage_events', ['username'], unique=False)
|
||||
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_usage_events_tool'), 'usage_events', ['tool'], unique=False)
|
||||
op.create_index(op.f('ix_usage_events_event'), 'usage_events', ['event'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_usage_events_event'), table_name='usage_events')
|
||||
op.drop_index(op.f('ix_usage_events_tool'), table_name='usage_events')
|
||||
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
|
||||
op.drop_index(op.f('ix_usage_events_username'), table_name='usage_events')
|
||||
op.drop_index(op.f('ix_usage_events_at'), table_name='usage_events')
|
||||
op.drop_table('usage_events')
|
||||
@@ -32,7 +32,11 @@ def upgrade() -> None:
|
||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
|
||||
# Postgres refuses it (DatatypeMismatch) - found when this migration
|
||||
# took down the wp.controls.dev api container on 2026-08-21. The
|
||||
# location-taxonomy migration next door had it right all along.
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
|
||||
694
server/app.py
@@ -9,24 +9,32 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import base64
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from time import monotonic
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy import select, delete, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models, auth, notify
|
||||
from . import models, auth, notify, assets_db, okta_auth, okta_fake
|
||||
|
||||
log = logging.getLogger("wpsuite.app")
|
||||
|
||||
# Schema management:
|
||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||
@@ -56,6 +64,22 @@ if _origins:
|
||||
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
|
||||
)
|
||||
|
||||
# Authlib's Okta client needs request.session to carry the OIDC state/nonce (and,
|
||||
# below, our own post-login redirect target) across the round trip to Okta and
|
||||
# back — it raises an AssertionError without this. Bug found in T10.2 (those
|
||||
# routes never crashed in testing because every prior check mocked
|
||||
# authorize_redirect/authorize_access_token directly, bypassing Authlib's real
|
||||
# implementation); fixed here rather than reworking already-shipped T10.2 code.
|
||||
#
|
||||
# This is NOT the app's session cookie — wp_session (auth.py) still carries the
|
||||
# actual signed-in identity, unchanged. This cookie holds nothing but ephemeral,
|
||||
# per-attempt OAuth state, so it gets a short lifetime and a plain secret reuse
|
||||
# (auth.SECRET_KEY) rather than its own required config knob.
|
||||
app.add_middleware(
|
||||
SessionMiddleware, secret_key=auth.SECRET_KEY, session_cookie="wp_oauth_state",
|
||||
same_site="lax", https_only=False, max_age=600,
|
||||
)
|
||||
|
||||
|
||||
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||
@@ -84,12 +108,23 @@ def _csrf_ok(request: Request) -> bool:
|
||||
async def auth_gate(request: Request, call_next):
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
claims = None
|
||||
if method != "OPTIONS" and auth._needs_auth(path):
|
||||
if not auth.is_request_authenticated(request):
|
||||
claims = auth.is_request_authenticated(request)
|
||||
if not claims:
|
||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
|
||||
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
|
||||
return await call_next(request)
|
||||
response = await call_next(request)
|
||||
# D18: slide the session forward on activity, capped by its absolute
|
||||
# ceiling. Reads only the already-validated claims - no DB hit here, and
|
||||
# the separate is_active/token_version check in get_current_user still
|
||||
# runs on its own for every request regardless of whether this refreshes.
|
||||
if claims is not None:
|
||||
refreshed = auth.maybe_refresh_token(claims)
|
||||
if refreshed:
|
||||
auth.set_session_cookie(response, request, refreshed)
|
||||
return response
|
||||
|
||||
|
||||
def gen_id(prefix: str) -> str:
|
||||
@@ -458,15 +493,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
||||
return (base + path) if base else path
|
||||
|
||||
|
||||
def wp_titled(wp: "models.WorkPackage") -> str:
|
||||
"""Number — title, for a message body. Decided 2026-08-20: the title is
|
||||
customer CONTEXT and may ride in mail; document CONTENT may not."""
|
||||
t = (wp.subject or "").strip()
|
||||
n = wp.number or "a work package"
|
||||
return f"{n} — {t}" if t else n
|
||||
|
||||
|
||||
def wp_where(wp: "models.WorkPackage") -> str:
|
||||
"""Where the work happens, for a message body: the CR-004 structured
|
||||
fields (stored as paths — stable, and readable to the people these mails
|
||||
address), else the pre-CR-004 free text. Empty string when unset, and
|
||||
callers drop the line entirely rather than mail 'Where: '."""
|
||||
data = wp.data or {}
|
||||
parts = [str(data.get(d) or "").strip() for d in LOCATION_DIMENSIONS]
|
||||
parts = [p for p in parts if p]
|
||||
return " / ".join(parts) if parts else str(data.get("location") or "").strip()
|
||||
|
||||
|
||||
def _where_line(wp: "models.WorkPackage") -> str:
|
||||
w = wp_where(wp)
|
||||
return f"Where: {w}\n" if w else ""
|
||||
|
||||
|
||||
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
|
||||
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
|
||||
# customer IP inside the app, behind login).
|
||||
# Number, title and location — customer context, allowed since the
|
||||
# 2026-08-20 decision (decisions-2026-08-20.md). Contents stay behind
|
||||
# the link: no scope text, no descriptions, no attachments.
|
||||
who = actor.full_name or actor.username
|
||||
name = assignee.full_name or assignee.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
|
||||
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"{who} assigned you a work package: {wp_titled(wp)}.\n"
|
||||
+ _where_line(wp) +
|
||||
f"\nOpen the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
@@ -579,14 +640,8 @@ def health():
|
||||
|
||||
|
||||
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class NewUserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||
@@ -608,24 +663,6 @@ class PreferencesIn(BaseModel):
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminPasswordIn(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
@@ -648,160 +685,148 @@ class AutoAddIn(BaseModel):
|
||||
role: str = ""
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
||||
user = auth.find_user(db, body.username)
|
||||
now = models.utcnow()
|
||||
# Always run the hash comparison first — even for missing or locked accounts —
|
||||
# so response timing doesn't leak which usernames exist. verify_password
|
||||
# tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
locked = user.locked_until if user else None
|
||||
if locked is not None and locked.tzinfo is None:
|
||||
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
|
||||
if locked is not None and locked > now:
|
||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||
if not user or not valid:
|
||||
if user:
|
||||
user.failed_attempts = (user.failed_attempts or 0) + 1
|
||||
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
||||
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
||||
user.failed_attempts = 0
|
||||
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
|
||||
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout(response: Response):
|
||||
auth.clear_session_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Self-service password reset (needs email switched on) ──────────────────────
|
||||
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
|
||||
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
|
||||
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
|
||||
# restart — deliberately simple; the token expiry is the real control.
|
||||
_reset_last: dict[str, float] = {}
|
||||
# ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
|
||||
# Access gating is Okta's job: only accounts assigned to this app integration in Okta
|
||||
# can complete authorize_redirect at all. No app-side group/claim check is layered on
|
||||
# top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
|
||||
|
||||
def _safe_next_path(raw: str) -> str:
|
||||
"""A same-site path only — same rule login.js's own nextTarget() enforces
|
||||
client-side. Rejects absolute/scheme URLs ('//evil.com', 'https://evil.com')
|
||||
so a crafted ?next= can't turn a real Okta sign-in into an open redirect."""
|
||||
raw = (raw or "").strip()
|
||||
if raw and raw.startswith("/") and not raw.startswith("//"):
|
||||
return raw
|
||||
return ""
|
||||
|
||||
|
||||
def _reset_throttled(request: Request, username: str) -> bool:
|
||||
now = monotonic()
|
||||
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
|
||||
prev = _reset_last.get(key)
|
||||
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
|
||||
return True
|
||||
_reset_last[key] = now
|
||||
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
|
||||
cutoff = now - RESET_COOLDOWN_SECONDS
|
||||
for k in [k for k, t in _reset_last.items() if t < cutoff]:
|
||||
_reset_last.pop(k, None)
|
||||
return False
|
||||
@app.get("/api/auth/okta/login")
|
||||
async def okta_login(request: Request):
|
||||
"""Send the browser to Okta's authorize endpoint. Where to land afterward
|
||||
(?next=, e.g. from a deep link an assignment email carried — X1/CR-011/CR-014)
|
||||
rides in the OAuth-state session cookie alongside Authlib's own state/nonce,
|
||||
since nothing else survives the round trip to Okta and back."""
|
||||
if not okta_auth.oauth:
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
next_path = _safe_next_path(request.query_params.get("next", ""))
|
||||
if next_path:
|
||||
request.session["post_login_redirect"] = next_path
|
||||
return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
|
||||
|
||||
|
||||
def reset_body(user: "models.User", link: str, minutes: int) -> str:
|
||||
# No account detail beyond the username, and no customer data — same rule as
|
||||
# the assignment mail. The link is the only sensitive thing in here.
|
||||
who = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A password reset was requested for your Work Package Suite account "
|
||||
f"({user.username}).\n\n"
|
||||
f"Set a new password:\n{link}\n\n"
|
||||
f"The link expires in {minutes} minutes and can only be used once. "
|
||||
f"If you didn't request this, you can ignore this email — your current "
|
||||
f"password still works.\n"
|
||||
)
|
||||
@app.get("/api/auth/okta/callback")
|
||||
async def okta_callback(request: Request, db: Session = Depends(get_db)):
|
||||
"""Exchange the authorization code for tokens, validate the ID token, and sign the
|
||||
person in. T10.3: matches the identity claim to a local account, or JIT-provisions
|
||||
one, then issues the same session cookie login() does today.
|
||||
|
||||
Failure paths land back on the login page with a plain-language ?error= instead
|
||||
of a raw HTTPException — this route is reached by a full-page browser navigation
|
||||
from Okta, not a fetch() call, so a JSON error body is just a broken-looking page
|
||||
to whoever is sitting at the keyboard (T10.5)."""
|
||||
if not okta_auth.oauth:
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
try:
|
||||
token = await okta_auth.oauth.okta.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
log.warning("Okta callback rejected: %s", exc)
|
||||
return RedirectResponse(url="/login.html?error=cancelled", status_code=303)
|
||||
claims = token.get("userinfo") or {}
|
||||
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
|
||||
if not identity:
|
||||
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
|
||||
@app.get("/api/auth/reset-available")
|
||||
def reset_available(db: Session = Depends(get_db)):
|
||||
"""Whether the login page should offer 'Forgot password'. Self-service reset
|
||||
depends entirely on outbound email, so it's off unless email is enabled AND
|
||||
SMTP is configured — otherwise the only route is an admin reset."""
|
||||
s = notify.get_settings(db)
|
||||
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
|
||||
|
||||
|
||||
@app.post("/api/auth/forgot-password")
|
||||
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
|
||||
"""Email a reset link. Always returns the same 200 response whether or not the
|
||||
account exists — this endpoint is unauthenticated, so it must not become a
|
||||
username/email oracle. Failures are recorded in the audit log instead."""
|
||||
s = notify.get_settings(db)
|
||||
if not (s.get("email_enabled") and notify.smtp_ready(s)):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
|
||||
user = auth.find_user(db, identity)
|
||||
if user is None:
|
||||
# JIT provisioning (D15). Okta is the only gate on WHO can reach this route
|
||||
# at all — this app still decides what a first-time sign-in may do. A new
|
||||
# account gets the lowest-privilege role and no project membership; an admin
|
||||
# or project super user grants access afterward, same as any account created
|
||||
# by hand today (create_user() above). No password field exists at all —
|
||||
# Okta is the only credential (D15/D16, real deletion as of T10.4).
|
||||
user = models.User(
|
||||
id=gen_id("user"),
|
||||
username=identity,
|
||||
email=(claims.get("email") or "").strip(),
|
||||
full_name=(claims.get("name") or "").strip(),
|
||||
role=auth.ROLE_PROJECT_USER,
|
||||
)
|
||||
if _reset_throttled(request, body.username):
|
||||
# Same shape as the success response — no oracle, no mail bomb.
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
user = auth.find_user(db, body.username)
|
||||
if user and user.is_active and user.email:
|
||||
base = (s.get("app_base_url") or "").rstrip("/")
|
||||
token = auth.create_reset_token(user)
|
||||
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
|
||||
sent = notify.send_now(
|
||||
db, user.email,
|
||||
"Work Package Suite — reset your password",
|
||||
reset_body(user, link, auth.RESET_MINUTES),
|
||||
)
|
||||
log_event(db, user.username, "password_reset_requested", "user", user.id,
|
||||
summary=user.username, detail={"emailed": bool(sent)})
|
||||
db.commit()
|
||||
else:
|
||||
# Log the miss for the admin's benefit; the caller can't tell the difference.
|
||||
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
|
||||
summary=(body.username or "")[:200],
|
||||
detail={"reason": "no account, inactive, or no email on file"})
|
||||
db.commit()
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
db.add(user)
|
||||
db.flush()
|
||||
log_event(db, user.username, "user_created", "user", user.id, summary=user.username,
|
||||
detail={"role": user.role, "via": "okta_jit"})
|
||||
elif not user.is_active:
|
||||
# Deprovisioning stays local (D15's "roles stay local"): Okta letting someone
|
||||
# through does not override an account this app has disabled. Same rule
|
||||
# login() enforced today, now surfaced as a login-page banner (T10.5)
|
||||
# instead of a raw 403 body, for the reason in this route's docstring.
|
||||
return RedirectResponse(url="/login.html?error=disabled", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/auth/reset-password")
|
||||
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
||||
"""Complete a reset using the emailed token. The token carries the user's
|
||||
token_version, and finishing a reset bumps it — so the link is single-use and
|
||||
every existing session for that account is signed out."""
|
||||
claims = auth.decode_reset_token(body.token or "")
|
||||
if not claims:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
|
||||
# A completed reset also clears any login lockout — the person has proven
|
||||
# control of the mailbox, so there's nothing left to throttle.
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
|
||||
user.last_login_at = models.utcnow()
|
||||
# CR-019: one login event per Okta sign-in, written here rather than
|
||||
# inferred from session creation elsewhere, so there is exactly one
|
||||
# source of truth for "did this person sign in" - not one per page load
|
||||
# afterward (T11.2 covers that separately, as page_open events).
|
||||
db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
db.refresh(user)
|
||||
|
||||
tok = auth.create_token(user)
|
||||
target = _safe_next_path(request.session.pop("post_login_redirect", "")) or "/index.html"
|
||||
redirect = RedirectResponse(url=target, status_code=303)
|
||||
auth.set_session_cookie(redirect, request, tok)
|
||||
return redirect
|
||||
|
||||
|
||||
# ── Fake Okta test seam (T10.7) ──────────────────────────────────────────────
|
||||
# Registered ONLY when the fake is active — checked once, at import time, same
|
||||
# timing okta_auth.oauth itself is built at. In production these two routes do
|
||||
# not exist at all, not merely refuse a request: see server/okta_fake.py's
|
||||
# docstring for why that distinction matters given D16 leaves no other way in.
|
||||
if okta_fake.is_active():
|
||||
|
||||
@app.get("/api/auth/okta/_fake_provider")
|
||||
async def okta_fake_provider(request: Request):
|
||||
"""Stands in for Okta's own sign-in screen. A plain list of the
|
||||
identities WP_OKTA_FAKE_DIRECTORY defines, so a browser check drives a
|
||||
real page through a real round trip rather than skipping it."""
|
||||
state = request.query_params.get("state", "")
|
||||
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(okta_fake.picker_page(state, redirect_uri))
|
||||
|
||||
@app.get("/api/auth/okta/_fake_provider/consent")
|
||||
async def okta_fake_consent(request: Request):
|
||||
"""What clicking an identity (or Deny) on the fake picker does: hands
|
||||
back an authorization code (or an error) at okta_callback, exactly the
|
||||
shape a real Okta redirect would carry. Everything after this — the
|
||||
state check, JIT provisioning, the disabled-account and open-redirect
|
||||
guards — is the real okta_callback() above, unmodified."""
|
||||
state = request.query_params.get("state", "")
|
||||
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
|
||||
if request.query_params.get("deny"):
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_uri}?error=access_denied&error_description=denied+by+fake+user&state={state}",
|
||||
status_code=303)
|
||||
username = request.query_params.get("username", "")
|
||||
entry = okta_fake.directory().get(username)
|
||||
if not isinstance(entry, dict):
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_uri}?error=invalid_request&error_description=unknown+fake+identity&state={state}",
|
||||
status_code=303)
|
||||
claims = {okta_auth.IDENTITY_CLAIM: username,
|
||||
"email": entry.get("email", ""), "name": entry.get("name", "")}
|
||||
code = okta_fake.new_code(claims)
|
||||
return RedirectResponse(url=f"{redirect_uri}?code={code}&state={state}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
@@ -857,22 +882,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Keep this session logged in by re-issuing a cookie carrying the new version.
|
||||
auth.set_session_cookie(response, request, auth.create_token(user))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration ─────────────────────────────────────────────────────────
|
||||
# Two kinds of caller reach these routes: an app admin, who manages every account,
|
||||
# and a Project Super User, who manages the accounts on the projects they administer.
|
||||
@@ -960,9 +969,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
"""Create an account ahead of its first Okta sign-in — e.g. to put it on
|
||||
projects or hand it a role before anyone has ever signed in as them.
|
||||
|
||||
`body.username` MUST match what Okta's identity claim will send for this
|
||||
person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
|
||||
is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
|
||||
fail loudly; it silently produces a second, JIT-provisioned account instead
|
||||
of signing this person into the one just created here."""
|
||||
allowed = grantable_roles(actor)
|
||||
if body.role not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||
@@ -994,7 +1008,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
username=body.username.strip(),
|
||||
email=body.email.strip(),
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
project_role=body.project_role.strip()[:120],
|
||||
)
|
||||
@@ -1021,24 +1034,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
# An administrative password reset was the one user-account change that left no
|
||||
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
|
||||
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
|
||||
detail={"by": "administrator"})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
@@ -1294,6 +1289,15 @@ def list_projects(
|
||||
elif archived != "all":
|
||||
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
||||
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
|
||||
# D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone
|
||||
# below that sees them nowhere, counts and pickers included. The default
|
||||
# listing already excludes them; asking for them is what gets gated, and it
|
||||
# is gated per project, so admin-on-Job-A does not surface archived Job B.
|
||||
if archived != "exclude":
|
||||
rows = [p for p in rows
|
||||
if p.archived_at is None
|
||||
or effective_role(db, user, p.id) in (
|
||||
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)]
|
||||
return [p.summary() for p in rows]
|
||||
|
||||
|
||||
@@ -1588,15 +1592,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
||||
).first()
|
||||
if not sop:
|
||||
return []
|
||||
proj = (sop.data or {}).get("project") or {}
|
||||
data = sop.data or {}
|
||||
# BL-021, fixed 2026-08-20: pushSOP writes the row as data={sop, state}, so
|
||||
# the project block lives at data['sop']['project']. This read was one
|
||||
# level too shallow - always {} - and the critical-reopen mail never
|
||||
# reached the PM or CM its docstring promises. Same tolerant read as
|
||||
# project_qa_group below: nested shape first, flat shape for hand-written rows.
|
||||
proj = ((data.get("sop") or {}).get("project")
|
||||
or data.get("project") or {})
|
||||
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
|
||||
|
||||
|
||||
def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]:
|
||||
"""D2: the QA group named on the project's latest complete SOP. pushSOP writes
|
||||
the row as data={sop, state}, so the project block is data['sop']['project'] -
|
||||
note that project_sop_team above reads data['project'], which that shape never
|
||||
has (BL-021, logged, not fixed here)."""
|
||||
project_sop_team above read the flat shape until BL-021 was fixed
|
||||
(2026-08-20); both now read nested-first, exactly alike."""
|
||||
if not project_id:
|
||||
return []
|
||||
sop = db.scalars(
|
||||
@@ -1643,14 +1654,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
|
||||
|
||||
def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
|
||||
actor: "models.User", link: str) -> str:
|
||||
# A WP number and a deep link - NOT the package contents. The task text asked
|
||||
# for location and a scope summary, but the done-when list (and the standing
|
||||
# rule) says no customer IP in a message body; the link is the summary.
|
||||
# Number, title and location ride in the body — the 2026-08-20 decision
|
||||
# restored the location the T7.6 done-when had excluded. The SCOPE summary
|
||||
# stays out: scope text is document content, and the link is its summary.
|
||||
who = actor.full_name or actor.username
|
||||
name = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n"
|
||||
f"{who} moved {wp_titled(wp)} to Ready for QA.\n"
|
||||
+ _where_line(wp) +
|
||||
f"It is in the QA queue waiting to be accepted or returned.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
@@ -1663,7 +1675,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
|
||||
name = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n"
|
||||
f"{who} returned {wp_titled(wp)} from Ready for QA to In Progress.\n"
|
||||
+ _where_line(wp) +
|
||||
f"The reason is recorded on the package.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
@@ -1696,18 +1709,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage",
|
||||
|
||||
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
||||
actor: "models.User", link: str) -> str:
|
||||
# Constraint names and a WP number only — no package contents, same rule as the
|
||||
# assignment mail.
|
||||
# Constraint names, number, title and location — customer context per the
|
||||
# 2026-08-20 decision. No package contents; the link carries those.
|
||||
who = user.full_name or user.username
|
||||
by = actor.full_name or actor.username
|
||||
which = ", ".join(names)
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A critical constraint was reopened on {wp.number or 'a work package'} "
|
||||
f"A critical constraint was reopened on {wp_titled(wp)} "
|
||||
f"after it was released to the field, so the package is on hold.\n\n"
|
||||
f"Constraint: {which}\n"
|
||||
+ _where_line(wp) +
|
||||
f"Reopened by: {by}\n\n"
|
||||
f"Open the package:\n{link}\n"
|
||||
f"Open the package:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
|
||||
@@ -1724,7 +1739,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
|
||||
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} moved kitting on {wp.number or 'a work package'} from "
|
||||
f"{who} moved kitting on {wp_titled(wp)} from "
|
||||
f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
|
||||
f"Delivery location: {delivery}.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
@@ -1740,7 +1755,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage",
|
||||
needed_line = f" needed by {needed}" if needed else ""
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} raised a material request on {wp.number or 'a work package'}: "
|
||||
f"{who} raised a material request on {wp_titled(wp)}: "
|
||||
f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
|
||||
f"Delivery location: {delivery}.\n\n"
|
||||
f"Open it here:\n{link}\n\n"
|
||||
@@ -2342,6 +2357,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
|
||||
rejected.append({"line": i, "text": line,
|
||||
"reason": "no letters or digits to make a code from"})
|
||||
continue
|
||||
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
|
||||
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
|
||||
# production import (2026-08-23, BL-027's class again) instead of coming
|
||||
# back as a rejection with its line number. Validate per row, here,
|
||||
# so every dialect answers the same way: with a reason.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
long_p = next((p for p in parts if len(p) > 200), None)
|
||||
if long_p is not None:
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
|
||||
continue
|
||||
if any(len(location_slug(p)) > 60 for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a code would be longer than 60 characters"})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
@@ -2408,6 +2441,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||
|
||||
rows, rejected = parse_location_rows(body.text)
|
||||
read_total = len(rows) + len(rejected)
|
||||
|
||||
existing = {n.path: n for n in db.scalars(
|
||||
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||
@@ -2422,6 +2456,10 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
for line_no, parts in rows:
|
||||
segs = [location_slug(p) for p in parts]
|
||||
full = "/".join(segs)
|
||||
if len(full) > 200:
|
||||
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
|
||||
"reason": "the combined path is longer than 200 characters"})
|
||||
continue
|
||||
if full in seen_in_file:
|
||||
duplicates.append({"line": line_no, "path": full, "names": parts,
|
||||
"reason": "already on line %d of this import" % seen_in_file[full]})
|
||||
@@ -2464,7 +2502,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
|
||||
result = {
|
||||
"project_id": project_id, "dry_run": bool(body.dry_run),
|
||||
"read": len(rows) + len(rejected),
|
||||
"read": read_total,
|
||||
"created": created, "duplicates": duplicates,
|
||||
"reactivated": reactivated, "rejected": rejected,
|
||||
}
|
||||
@@ -2961,6 +2999,18 @@ def parse_material_rows(text: str):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||
continue
|
||||
# Same guard as parse_location_rows: reject what Postgres would refuse
|
||||
# (VARCHAR limits, control bytes) with the line number, never a 500.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
caps = ((300, "description"), (20, "unit"), (80, "code"))
|
||||
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
|
||||
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
|
||||
if long_col:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
@@ -3345,6 +3395,177 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
|
||||
return _save_comment(body, db, user)
|
||||
|
||||
|
||||
# ── CR-019: usage/activity metrics ──────────────────────────────────────────
|
||||
class UsageIn(BaseModel):
|
||||
tool: str = ""
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/usage/ping")
|
||||
def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""One usage_events row per authenticated page load. Called exactly once,
|
||||
from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
|
||||
per page's own script, the same lesson S4's per-page nav already taught
|
||||
this codebase. `user` comes from the session via get_current_user, never
|
||||
from anything the client claims - identity here is a server-enforced
|
||||
fact, matching every other write in this file, not a client-reported one."""
|
||||
tool = (body.tool or "").strip()[:40]
|
||||
db.add(models.UsageEvent(
|
||||
id=gen_id("uev"), username=user.username, project_id=body.project_id,
|
||||
tool=tool, event="page_open",
|
||||
))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _parse_date_q(v: str, end: bool = False) -> Optional[datetime]:
|
||||
"""Accepts a plain YYYY-MM-DD (what a <input type=date> sends) or a full
|
||||
ISO datetime. A date-only `to` means "through the end of that day", not
|
||||
midnight at its start - otherwise a range of "today" would match nothing
|
||||
from today at all."""
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
d = datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return None
|
||||
if d.tzinfo is None:
|
||||
d = d.replace(tzinfo=timezone.utc)
|
||||
if end and len(v) <= 10: # date-only
|
||||
d = d + timedelta(days=1) - timedelta(microseconds=1)
|
||||
return d
|
||||
|
||||
|
||||
def _usage_query(db: Session, caller: "models.User", date_from, date_to, project_id, username, tool):
|
||||
"""Shared by the summary and export endpoints so the two can never
|
||||
disagree about which rows a filter set matches - the export is a raw
|
||||
dump of exactly what the summary counted, not a separately-derived view."""
|
||||
stmt = select(models.UsageEvent)
|
||||
managed = managed_project_ids(db, caller)
|
||||
if managed is not None:
|
||||
# A project_super_user (never an app admin, who gets managed=None) is
|
||||
# scoped to events tied to a project they administer, plus their OWN
|
||||
# suite-wide activity (admin console opens etc. carry no project_id) -
|
||||
# never another user's activity outside what they manage.
|
||||
if managed:
|
||||
stmt = stmt.where(or_(
|
||||
models.UsageEvent.project_id.in_(managed),
|
||||
models.UsageEvent.username == caller.username,
|
||||
))
|
||||
else:
|
||||
stmt = stmt.where(models.UsageEvent.username == caller.username)
|
||||
df = _parse_date_q(date_from)
|
||||
dt = _parse_date_q(date_to, end=True)
|
||||
if df:
|
||||
stmt = stmt.where(models.UsageEvent.at >= df)
|
||||
if dt:
|
||||
stmt = stmt.where(models.UsageEvent.at <= dt)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.UsageEvent.project_id == project_id)
|
||||
if username:
|
||||
stmt = stmt.where(models.UsageEvent.username == username)
|
||||
if tool:
|
||||
stmt = stmt.where(models.UsageEvent.tool == tool)
|
||||
return stmt.order_by(models.UsageEvent.at)
|
||||
|
||||
|
||||
@app.get("/api/usage/summary")
|
||||
def usage_summary(
|
||||
date_from: Optional[str] = Query(None, alias="from"),
|
||||
date_to: Optional[str] = Query(None, alias="to"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
username: Optional[str] = Query(None),
|
||||
tool: Optional[str] = Query(None),
|
||||
caller: models.User = Depends(require_user_manager),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CR-019. Same gate as the User Directory (require_user_manager): an app
|
||||
admin or a project_super_user with at least one managed project. Filters
|
||||
combine. Aggregated in Python over the filtered row set rather than a SQL
|
||||
GROUP BY - correct and simple at today's scale; if usage_events grows
|
||||
into the millions (plausible, given retention is indefinite by decision),
|
||||
the day/week/month bucketing here is the first thing to move server-side
|
||||
into SQL. Not done now because nothing currently requires it."""
|
||||
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
|
||||
|
||||
by_day: dict[str, set] = {}
|
||||
by_week: dict[str, set] = {}
|
||||
by_month: dict[str, set] = {}
|
||||
per_user_last: dict[str, datetime] = {}
|
||||
per_tool: dict[str, int] = {}
|
||||
for e in rows:
|
||||
by_day.setdefault(e.at.date().isoformat(), set()).add(e.username)
|
||||
by_week.setdefault(e.at.strftime("%G-W%V"), set()).add(e.username)
|
||||
by_month.setdefault(e.at.strftime("%Y-%m"), set()).add(e.username)
|
||||
if e.username not in per_user_last or e.at > per_user_last[e.username]:
|
||||
per_user_last[e.username] = e.at
|
||||
per_tool[e.tool] = per_tool.get(e.tool, 0) + 1
|
||||
|
||||
return {
|
||||
"active_users": {
|
||||
"by_day": {k: len(v) for k, v in sorted(by_day.items())},
|
||||
"by_week": {k: len(v) for k, v in sorted(by_week.items())},
|
||||
"by_month": {k: len(v) for k, v in sorted(by_month.items())},
|
||||
},
|
||||
"per_user_last_active": {u: models._iso(t) for u, t in sorted(per_user_last.items())},
|
||||
"by_tool": dict(sorted(per_tool.items(), key=lambda kv: -kv[1])),
|
||||
"event_count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def _pseudonym(username: str) -> str:
|
||||
"""A stable per-user id for the sanitized export — the SAME input always
|
||||
produces the SAME output, within one export and across separate export
|
||||
runs, so an external system (Power BI or similar) can still group and
|
||||
trend "by user" without ever receiving a real name. HMAC rather than a
|
||||
plain hash: a plain sha256(username) is trivially reversed against a
|
||||
wordlist of the handful of usernames this app actually has; keying it
|
||||
with AUTH_SECRET_KEY (already a real secret, already required in
|
||||
production — see auth.py) means recovering a username from its
|
||||
pseudonym requires the signing key, not just guessing."""
|
||||
digest = hmac.new(auth.SECRET_KEY.encode(), username.encode(), hashlib.sha256).hexdigest()
|
||||
return "u_" + digest[:16]
|
||||
|
||||
|
||||
@app.get("/api/usage/export")
|
||||
def usage_export(
|
||||
date_from: Optional[str] = Query(None, alias="from"),
|
||||
date_to: Optional[str] = Query(None, alias="to"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
username: Optional[str] = Query(None),
|
||||
tool: Optional[str] = Query(None),
|
||||
sanitize: bool = Query(False),
|
||||
caller: models.User = Depends(require_user_manager),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CR-019. Same gate, same filters, same underlying row set as
|
||||
usage_summary() (_usage_query) — the export can never show a different
|
||||
slice of data than what the console counted for the same filters.
|
||||
|
||||
sanitize=true replaces `username` with a stable pseudonym (_pseudonym)
|
||||
and — deliberately — the `detail` column is not exported in EITHER mode.
|
||||
Every event this app writes today (login, page_open) leaves `detail`
|
||||
empty, so this costs nothing now, but it also means a future event type
|
||||
that DOES populate `detail` can't accidentally leak a real name into a
|
||||
sanitized file through a column nobody thought to scrub. If `detail`
|
||||
is ever needed in the export, it has to be sanitized explicitly, not
|
||||
assumed safe because the rest of the row was."""
|
||||
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["at", "username", "project_id", "tool", "event"])
|
||||
for e in rows:
|
||||
who = _pseudonym(e.username) if sanitize else e.username
|
||||
w.writerow([models._iso(e.at), who, e.project_id or "", e.tool, e.event])
|
||||
filename = "usage_export_%s_%s.csv" % (
|
||||
"sanitized" if sanitize else "raw", datetime.now(timezone.utc).strftime("%Y%m%d"),
|
||||
)
|
||||
return Response(
|
||||
content=buf.getvalue(), media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/comments")
|
||||
def list_comments(
|
||||
source: Optional[str] = Query(None),
|
||||
@@ -3383,6 +3604,29 @@ def list_comments(
|
||||
return [c.to_dict() for c in rows]
|
||||
|
||||
|
||||
# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
|
||||
# Backs the asset picker in the work package creator. This is a *lookup*, not a
|
||||
# resource this app owns: there is no POST, and nothing here ever writes to the
|
||||
# Micron database. It is deliberately not project-scoped by the app's own access
|
||||
# rules — the catalog is reference data, and any signed-in user who can build a
|
||||
# work package needs to be able to name the assets it covers. Authentication is
|
||||
# still required (the auth_gate middleware covers every /api/ path).
|
||||
@app.get("/api/assets")
|
||||
def list_assets(_user: models.User = Depends(auth.get_current_user)):
|
||||
"""The whole catalog, fetched once when the creator loads. Searching happens
|
||||
in the browser — there is no per-keystroke endpoint by design."""
|
||||
if not assets_db.configured():
|
||||
# Not an error — the suite is designed to run without Micron wired up.
|
||||
# The picker reads this and switches to manual entry.
|
||||
return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
|
||||
try:
|
||||
return {"configured": True, "assets": assets_db.load()}
|
||||
except assets_db.AssetSourceError as exc:
|
||||
# 503, not 500: the suite is healthy, its upstream lookup is not. The
|
||||
# picker degrades to manual entry rather than blocking the package.
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
|
||||
|
||||
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
||||
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
||||
|
||||
246
server/assets_db.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Read-only reader for the Micron asset catalog.
|
||||
|
||||
The work package creator used to ask people to paste a controls.dev link for
|
||||
every asset. Assets actually live in the Micron database — a SQL Server instance
|
||||
that is NOT part of this repo and whose schema is not managed here. This module
|
||||
gives the API a *read-only* window onto it so the creator can offer a searchable
|
||||
picker instead of free-text links.
|
||||
|
||||
How it works: the whole catalog is fetched in one query and handed to the browser
|
||||
when the creator loads. Searching then happens in the browser with no round trip
|
||||
at all. The catalog is a list of asset IDs — about 9k of them today and not
|
||||
expected past 100k — so it is small enough to send whole, and it is slow-moving
|
||||
reference data, so there is nothing to gain from querying it per keystroke and a
|
||||
lot of latency to lose. A short server-side cache keeps a room full of people
|
||||
opening the page from turning into a query each.
|
||||
|
||||
Other deliberate constraints:
|
||||
|
||||
* **Read-only, always.** The only statement in this file is the SELECT below.
|
||||
Point it at a login with `db_datareader` and nothing else.
|
||||
* **No prime_db dependency.** A plain SQLAlchemy connection built from a
|
||||
connection string, kept separate from the app's own engine in `db.py`, so a
|
||||
Micron outage can never affect the suite's own database.
|
||||
|
||||
Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
|
||||
returns False, the API says so, and the UI falls back to manual entry. The suite
|
||||
boots and runs fine without the Micron database being reachable.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── The query ─────────────────────────────────────────────────────────────────
|
||||
# The only place the Micron schema appears; everything else here is plumbing.
|
||||
# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
|
||||
# enough to hand over whole, and a partial list would silently hide assets.
|
||||
#
|
||||
# Add a WHERE clause here if some rows should never be offered at all
|
||||
# (decommissioned assets, other sites, …). Filtering at the source keeps the
|
||||
# payload small, which matters more than anything else here.
|
||||
ASSET_QUERY = """
|
||||
SELECT a.AssetID AS tag
|
||||
FROM Asset.Asset AS a
|
||||
ORDER BY a.AssetID
|
||||
"""
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
"""A malformed tuning knob degrades to its default; it must never keep the
|
||||
suite from booting. app.py imports this module unconditionally, so a bare
|
||||
int() here would turn "300s" in someone's .env into a crash-looping API -
|
||||
the total-outage switch an OPTIONAL feature is not allowed to own."""
|
||||
raw = os.getenv(name, "")
|
||||
try:
|
||||
return int(raw.strip()) if raw.strip() else default
|
||||
except ValueError:
|
||||
log.warning("%s=%r is not an integer; using the default %d.", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
# How long a fetched catalog is reused before the next page load re-queries.
|
||||
CACHE_SECONDS = _env_int("MICRON_ASSETS_CACHE_SECONDS", 300) # 5 min
|
||||
# How long a FAILURE is remembered before the next request retries the source.
|
||||
# Without this, every page load during a Micron outage spends CONNECT_TIMEOUT
|
||||
# seconds inside a worker thread; enough concurrent loads exhaust the app's
|
||||
# shared sync threadpool and take unrelated endpoints down with the picker.
|
||||
FAIL_CACHE_SECONDS = _env_int("MICRON_ASSETS_FAIL_CACHE_SECONDS", 30)
|
||||
CONNECT_TIMEOUT = 8
|
||||
|
||||
|
||||
class AssetSourceError(RuntimeError):
|
||||
"""The catalog is configured but could not be read."""
|
||||
|
||||
|
||||
# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
|
||||
# A full SQLAlchemy URL, e.g.
|
||||
# mssql+pymssql://user:pass@host:1433/MicronDB
|
||||
# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
||||
# URL-encode any special characters in the password.
|
||||
_engine = None
|
||||
_engine_lock = threading.Lock()
|
||||
|
||||
|
||||
def _db_url() -> str:
|
||||
return os.getenv("MICRON_DB_URL", "").strip()
|
||||
|
||||
|
||||
def configured() -> bool:
|
||||
return bool(_db_url())
|
||||
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
"""Catch the one URL mistake that produces a baffling error message.
|
||||
|
||||
A password containing an unencoded '@' makes the URL ambiguous: the parser
|
||||
splits on the first '@', so part of the password ends up parsed as the host.
|
||||
The driver then reports a connection failure against a nonsense hostname that
|
||||
happens to contain a fragment of the password — confusing to read and unsafe
|
||||
to display. Detect it here and say plainly what is wrong.
|
||||
|
||||
Nothing from the URL is included in the message; it never leaves this process.
|
||||
"""
|
||||
authority = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
if authority.count("@") > 1:
|
||||
raise AssetSourceError(
|
||||
"MICRON_DB_URL is ambiguous: the username or password contains an "
|
||||
"unencoded '@'. Percent-encode the special characters — @ = %40, "
|
||||
": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
|
||||
)
|
||||
|
||||
|
||||
def _connect_args(url: str) -> dict:
|
||||
"""Per-driver connect timeouts, so an unreachable Micron host fails fast
|
||||
instead of tying up a worker until the OS gives up."""
|
||||
if url.startswith("mssql+pymssql"):
|
||||
return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
|
||||
if url.startswith("mssql+pyodbc"):
|
||||
return {"timeout": CONNECT_TIMEOUT}
|
||||
return {}
|
||||
|
||||
|
||||
def _get_engine():
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
url = _db_url()
|
||||
if not url:
|
||||
raise AssetSourceError("The Micron DB is not configured.")
|
||||
_validate_url(url)
|
||||
with _engine_lock:
|
||||
if _engine is None:
|
||||
try:
|
||||
_engine = create_engine(
|
||||
url,
|
||||
connect_args=_connect_args(url),
|
||||
pool_pre_ping=True, # a recycled dead connection retries instead of erroring
|
||||
pool_recycle=1800,
|
||||
pool_size=1, # one catalog query now and then, not a workload
|
||||
max_overflow=1,
|
||||
future=True,
|
||||
)
|
||||
except Exception as exc: # bad URL, missing driver package, …
|
||||
# See the note on load() — the exception text can echo the
|
||||
# connection string, so it is logged and not propagated.
|
||||
log.error("Micron asset catalog: could not open the connection: %s", exc)
|
||||
raise AssetSourceError(
|
||||
"Could not open a connection to the Micron DB. "
|
||||
"Check MICRON_DB_URL and the API log for the driver error."
|
||||
) from exc
|
||||
return _engine
|
||||
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
# Every page load asks for the whole catalog, so without this a shift change
|
||||
# would be one full-table query per person. Held per worker process.
|
||||
_cache: list[dict] | None = None
|
||||
_cached_at = 0.0
|
||||
_error: str | None = None # negative cache: the last failure's user-safe text
|
||||
_error_at = 0.0
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def load(force: bool = False) -> list[dict]:
|
||||
"""Return the whole catalog as [{'tag': …}, …]. Never writes.
|
||||
|
||||
Failures are handled in two tiers so a Micron outage stays the picker's
|
||||
problem and never the suite's (the module contract above):
|
||||
* a previously fetched catalog is served STALE - it is slow-moving
|
||||
reference data, and old-but-real beats an error;
|
||||
* with nothing to serve, the failure itself is cached for
|
||||
FAIL_CACHE_SECONDS, so an outage costs one CONNECT_TIMEOUT per window
|
||||
instead of one per page load stacking up in the shared threadpool."""
|
||||
global _cache, _cached_at, _error, _error_at
|
||||
with _cache_lock:
|
||||
if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
|
||||
return _cache
|
||||
if (_error is not None and not force
|
||||
and (time.monotonic() - _error_at) < FAIL_CACHE_SECONDS
|
||||
and _cache is None):
|
||||
raise AssetSourceError(_error)
|
||||
|
||||
try:
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(ASSET_QUERY)).mappings().all()
|
||||
except AssetSourceError as exc:
|
||||
# _get_engine already logged and sanitised; remember or stale-serve.
|
||||
with _cache_lock:
|
||||
if _cache is not None:
|
||||
log.warning("Micron asset catalog unavailable; serving the cached "
|
||||
"catalog (%d rows).", len(_cache))
|
||||
return _cache
|
||||
_error, _error_at = str(exc), time.monotonic()
|
||||
raise
|
||||
except SQLAlchemyError as exc:
|
||||
# The driver's message is NOT propagated. AssetSourceError text reaches the
|
||||
# browser, and connection errors quote the host, the login, and — when the
|
||||
# URL is malformed — fragments of the password. Operators get the detail
|
||||
# from the API log, where it belongs; users get a message they can act on.
|
||||
log.error("Micron asset catalog query failed: %s", exc)
|
||||
msg = ("The Micron DB could not be read. Check that the host is "
|
||||
"reachable, that the login has SELECT on the asset table, and that "
|
||||
"ASSET_QUERY matches the real schema — the API log has the driver error.")
|
||||
with _cache_lock:
|
||||
if _cache is not None:
|
||||
log.warning("Micron asset catalog unavailable; serving the cached "
|
||||
"catalog (%d rows).", len(_cache))
|
||||
return _cache
|
||||
_error, _error_at = msg, time.monotonic()
|
||||
raise AssetSourceError(msg) from exc
|
||||
|
||||
# Drop rows with no identifier — an asset with no tag is not selectable and
|
||||
# would render as a blank line in the picker.
|
||||
rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
|
||||
with _cache_lock:
|
||||
_cache, _cached_at = rows, time.monotonic()
|
||||
_error = None
|
||||
return rows
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Describe the source for the UI, so it can explain itself rather than just
|
||||
showing an empty dropdown."""
|
||||
if not configured():
|
||||
return {
|
||||
"configured": False, "ok": False, "count": 0,
|
||||
"detail": "The Micron DB is not configured — enter assets manually.",
|
||||
}
|
||||
try:
|
||||
rows = load()
|
||||
except AssetSourceError as exc:
|
||||
return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
|
||||
return {"configured": True, "ok": True, "count": len(rows),
|
||||
"detail": f"{len(rows):,} asset IDs from the Micron DB."}
|
||||
188
server/auth.py
@@ -1,10 +1,11 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
|
||||
and the routes in server/app.py); there is no local password anywhere in this app
|
||||
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
|
||||
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
|
||||
self-validating, there is no server-side session store — every request is checked by
|
||||
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
Security model:
|
||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||
@@ -17,7 +18,9 @@ Security model:
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Permissions roles (`User.role`) — distinct from a person's job function on the
|
||||
project, which lives in `User.project_role` and grants nothing:
|
||||
project, which lives in `User.project_role` and grants nothing. Decided entirely
|
||||
locally: Okta gates WHO can authenticate at all, this app decides what an
|
||||
authenticated account may do — see D15/D16.
|
||||
• admin application administrator: user administration, app settings,
|
||||
and implicit access to every project.
|
||||
• project_super_user
|
||||
@@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
|
||||
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.
|
||||
Also where Okta JIT provisioning (T10.3) lands a brand-new
|
||||
account — the lowest-privilege role, promoted locally from
|
||||
there by an admin (see manage_users.py for the bootstrap case).
|
||||
|
||||
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 secrets
|
||||
@@ -46,7 +47,6 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
@@ -59,10 +59,26 @@ log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# How long an emailed password-reset link stays valid.
|
||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
# D18 (2026-09-23): a session now slides on activity, capped by a hard ceiling
|
||||
# underneath - not a single flat lifetime. A pure idle timer with no ceiling
|
||||
# would let a continuously-active session never force a fresh Okta recheck,
|
||||
# which is a worse fit for this item's own purpose (catching someone still
|
||||
# active after being deprovisioned) than a flat expiry would have been. Both
|
||||
# numbers are proposed defaults, not confirmed against the tenant's actual
|
||||
# Okta SSO session policy - see docs/waves/decisions-2026-09-17.md.
|
||||
#
|
||||
# No request for this long invalidates the session outright.
|
||||
IDLE_MINUTES = int(os.getenv("AUTH_IDLE_MINUTES", "30"))
|
||||
# The absolute ceiling from the ORIGINAL sign-in, regardless of activity. Same
|
||||
# env var name as the old flat-lifetime design; the meaning changed, the name
|
||||
# didn't, because it still answers "how long can this session possibly live."
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "8"))
|
||||
# How much later a refreshed exp must be before it's worth rewriting the
|
||||
# cookie. Without this, an active session gets a new Set-Cookie on literally
|
||||
# every request - correct, but wasteful, and it makes the cookie a busier
|
||||
# target than it needs to be. A third of the idle window is a reasonable
|
||||
# balance: refreshed a few times within any idle window, never every request.
|
||||
_REFRESH_SLACK = timedelta(minutes=max(1, IDLE_MINUTES // 3))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
@@ -121,30 +137,7 @@ def is_project_admin(user: "models.User") -> bool:
|
||||
# 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 (the Okta routes themselves, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
"/api/health",
|
||||
@@ -184,23 +177,31 @@ def _load_secret() -> str:
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def _parse_claim_dt(v) -> Optional[datetime]:
|
||||
"""`login_at` is a custom claim, so unlike `exp`/`iat` (which PyJWT
|
||||
special-cases for a datetime -> POSIX-timestamp conversion on encode) it
|
||||
is stored and read back as a plain numeric timestamp. Returns None for
|
||||
anything unparseable rather than raising - a malformed or legacy token
|
||||
should fail closed into "no refresh", not 500."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(float(v), tz=timezone.utc)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _next_exp(login_at: datetime, now: datetime) -> datetime:
|
||||
"""Whichever comes first: another IDLE_MINUTES of quiet from now, or the
|
||||
absolute SESSION_HOURS ceiling measured from the session's original
|
||||
sign-in. Shared by create_token and maybe_refresh_token so the two can't
|
||||
drift apart."""
|
||||
ceiling = login_at + timedelta(hours=SESSION_HOURS)
|
||||
idle_edge = now + timedelta(minutes=IDLE_MINUTES)
|
||||
return min(ceiling, idle_edge)
|
||||
|
||||
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
@@ -209,7 +210,48 @@ def create_token(user: "models.User") -> str:
|
||||
"role": user.role,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
"login_at": now.timestamp(),
|
||||
"exp": _next_exp(now, now),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def maybe_refresh_token(claims: dict) -> Optional[str]:
|
||||
"""Given a validated token's claims, return a reissued token if the
|
||||
session is worth extending, or None if nothing should change. Called from
|
||||
`auth_gate` on every authenticated request (D18) - deliberately reads only
|
||||
the already-validated claims, never the database, so it costs nothing
|
||||
beyond the JWT encode itself. The separate is_active/token_version check
|
||||
in get_current_user is unaffected either way.
|
||||
|
||||
Three ways this returns None: past the absolute ceiling (session is done,
|
||||
full re-login required - never extended, not even by a second); the
|
||||
claims can't be parsed (fails closed into no-refresh rather than guessing);
|
||||
or a refresh happened recently enough that a new cookie isn't worth
|
||||
writing yet (_REFRESH_SLACK)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
login_at = _parse_claim_dt(claims.get("login_at"))
|
||||
if login_at is None:
|
||||
# Pre-D18 token (no login_at claim) - fall back to iat so it still
|
||||
# gets a real ceiling instead of riding on the old flat exp forever.
|
||||
login_at = _parse_claim_dt(claims.get("iat"))
|
||||
if login_at is None:
|
||||
return None
|
||||
ceiling = login_at + timedelta(hours=SESSION_HOURS)
|
||||
if now >= ceiling:
|
||||
return None
|
||||
new_exp = _next_exp(login_at, now)
|
||||
current_exp = _parse_claim_dt(claims.get("exp"))
|
||||
if current_exp is not None and (new_exp - current_exp) < _REFRESH_SLACK:
|
||||
return None
|
||||
payload = {
|
||||
"sub": claims.get("sub"),
|
||||
"username": claims.get("username"),
|
||||
"role": claims.get("role"),
|
||||
"ver": claims.get("ver", 0),
|
||||
"iat": now,
|
||||
"login_at": login_at.timestamp(),
|
||||
"exp": new_exp,
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
@@ -221,39 +263,15 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
# A password-reset token must never be usable as a session cookie.
|
||||
# No route issues a `typ`-carrying token anymore (that was the password-reset
|
||||
# token, removed in T10.4), but a session token still must never validate as
|
||||
# one — kept as a defensive check, cheap insurance against a future token type
|
||||
# riding the same 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 ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
There is no local password (D15) and no local account creation from here anymore
|
||||
(D16, T10.4) — accounts are created by signing in through Okta, which JIT-
|
||||
provisions a row at the lowest-privilege role (see server/okta_auth.py,
|
||||
server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower
|
||||
now: change the role on an account that already exists, and do routine account
|
||||
maintenance from a shell on the server.
|
||||
|
||||
That narrower job is still how the very first admin gets named (D16): have that
|
||||
person sign in through Okta once — they land as project_user — then promote them
|
||||
from here. Promoting an existing row, rather than creating one blind, matters
|
||||
because it never has to guess the exact string Okta will send as the identity
|
||||
claim; a hand-typed username that doesn't match it exactly would just produce a
|
||||
second, orphaned account instead of the one you meant to promote.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users promote alice --role admin
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
python -m server.manage_users disable bob
|
||||
python -m server.manage_users enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
@@ -26,53 +30,48 @@ from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||
# documented one-liners keep working; anything else has to be a current role.
|
||||
def cmd_promote(args) -> None:
|
||||
role = args.role
|
||||
# 'user' is the pre-roles spelling of 'project_user', accepted here so a
|
||||
# documented one-liner from before this rework keeps working.
|
||||
if role == "user":
|
||||
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:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(
|
||||
f"No user named '{args.username}'. This promotes an existing account, it "
|
||||
f"doesn't create one — they need to sign in through Okta at least once first."
|
||||
)
|
||||
old_role = u.role
|
||||
u.role = role
|
||||
# Audited the same way a role change from the web Admin Console already is
|
||||
# (server/app.py's set_user_role() -> log_event(), action "role_changed") —
|
||||
# this command changes the same field and previously left no record of who
|
||||
# ran it or what it changed (T10.10). "actor" can't name a real person here:
|
||||
# a container shell exec carries no signed-in identity to attribute it to,
|
||||
# so it's tagged as the tool itself rather than guessing. "via" mirrors JIT
|
||||
# provisioning's own tag on user_created events.
|
||||
db.add(models.AuditLog(
|
||||
id=f"ev_{uuid.uuid4().hex[:12]}",
|
||||
actor="cli:manage_users",
|
||||
action="role_changed",
|
||||
entity_type="user",
|
||||
entity_id=u.id,
|
||||
summary=u.username,
|
||||
detail={"from": old_role, "to": role, "via": "cli"},
|
||||
))
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
|
||||
|
||||
|
||||
def cmd_list(args) -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
print("No users yet. Accounts appear here once someone signs in through Okta.")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
@@ -80,17 +79,6 @@ def cmd_list(args) -> None:
|
||||
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
db.commit()
|
||||
print(f"Password reset for {u.username}.")
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
@@ -108,39 +96,23 @@ def main() -> None:
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
|
||||
pr.add_argument("username")
|
||||
pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
if args.cmd == "promote":
|
||||
cmd_promote(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
|
||||
@@ -142,8 +142,10 @@ class WorkPackage(Base):
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with.
|
||||
"""A login account. No password is stored here or anywhere else — identity is
|
||||
confirmed by Okta (OIDC), this app only decides what the account may do once
|
||||
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
|
||||
`username` is what Okta's identity claim resolves to.
|
||||
|
||||
Two independent notions of "role", deliberately separate:
|
||||
• role the PERMISSIONS role — what the account may do in the app.
|
||||
@@ -159,7 +161,6 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
@@ -336,6 +337,45 @@ class AuditLog(Base):
|
||||
}
|
||||
|
||||
|
||||
class UsageEvent(Base):
|
||||
"""CR-019: append-only record of who used the suite, when, and which tool —
|
||||
navigation/session activity, not business mutations. Deliberately a SEPARATE
|
||||
table from AuditLog rather than a new `action` value there: AuditLog answers
|
||||
"who changed what" and is read by people auditing a specific record's
|
||||
history; mixing in a `page_open` row for every authenticated page load
|
||||
would make that trail noisy for its existing purpose. This table answers a
|
||||
different question — "who is active, and on what" — and CR-019's admin
|
||||
console reads from here, not from AuditLog.
|
||||
|
||||
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
|
||||
is later removed should still show up in historical activity rather than
|
||||
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
|
||||
`is_active=False` — it never deletes a row — so this is defensive symmetry
|
||||
rather than a live concern today.
|
||||
|
||||
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
|
||||
nothing purges rows written here; that is a deliberate product decision,
|
||||
not an oversight to fix later."""
|
||||
__tablename__ = "usage_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
username: Mapped[str] = mapped_column(String(200), default="", index=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
# creator | wizard | field_view | dashboard | admin | directory | ...
|
||||
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
# page_open | login
|
||||
event: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "at": _iso(self.at), "username": self.username,
|
||||
"project_id": self.project_id, "tool": self.tool, "event": self.event,
|
||||
"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
|
||||
|
||||
@@ -7,11 +7,14 @@ 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.
|
||||
background task. Notification bodies carry customer CONTEXT — the WP number, its
|
||||
title, where the work happens — and a deep link, never customer document CONTENT
|
||||
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
|
||||
the summary of everything a body leaves out.
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
import socket
|
||||
import uuid
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
@@ -107,7 +110,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
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:
|
||||
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
|
||||
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
|
||||
# is slow or unreachable - sends are sequential background tasks, so a batch
|
||||
# of notifications trickled out one per five seconds. gethostname() never
|
||||
# touches the network. Found 2026-08-20 when the office link dropped.
|
||||
with smtplib.SMTP(host, port, timeout=15,
|
||||
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
|
||||
if s.get("smtp_use_tls", True):
|
||||
srv.starttls()
|
||||
if user:
|
||||
|
||||
109
server/okta_auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
|
||||
|
||||
This module owns the conversation with Okta and nothing else: it does not issue this
|
||||
app's own session cookie, does not touch the database, and does not decide who may sign
|
||||
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
|
||||
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
|
||||
account on every request. Only how a person's identity gets confirmed changes: an Okta
|
||||
authorization-code flow, in place of the local username/password check.
|
||||
|
||||
Access gating is Okta's job, not this module's. Only accounts assigned to this app
|
||||
integration in Okta ever complete the flow at all, so there is no required-group or
|
||||
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
|
||||
difference from D13's design, not an oversight.
|
||||
|
||||
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
|
||||
with any of the four settings below missing, `is_configured()` is False and `describe()`
|
||||
says so in the startup log, rather than the app discovering it later at the login button.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("wpsuite.okta")
|
||||
|
||||
try:
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
HAVE_AUTHLIB = True
|
||||
except ImportError: # pragma: no cover
|
||||
HAVE_AUTHLIB = False
|
||||
OAuth = None # type: ignore[assignment]
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
|
||||
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
|
||||
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
|
||||
# hand-entered except this base URL, the client credentials, and our own callback.
|
||||
ISSUER = os.getenv("OKTA_ISSUER", "")
|
||||
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
|
||||
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
|
||||
# e.g. https://wp.controls.dev/api/auth/okta/callback
|
||||
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
|
||||
|
||||
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
|
||||
# why access gating and permissions both stay out of the token.
|
||||
SCOPES = "openid profile email"
|
||||
|
||||
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
|
||||
# matching against the local `users` table (T10.3). Not yet confirmed by security —
|
||||
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
|
||||
# documented placeholder, NOT a verified answer. Override via env once security replies
|
||||
# so the real value can drop in without a code change.
|
||||
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
|
||||
network — that would be a `selftest()`, added when T10.2 needs one."""
|
||||
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
|
||||
|
||||
|
||||
def describe() -> str:
|
||||
"""One line for the startup log, matching the LDAPS module's discipline: an
|
||||
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
|
||||
the login button."""
|
||||
from . import okta_fake
|
||||
if okta_fake.is_active():
|
||||
return (f"*** FAKE OKTA PROVIDER ACTIVE — identities come from "
|
||||
f"{okta_fake.ENV_VAR}, NOT from Okta. Tests only. ***")
|
||||
if not HAVE_AUTHLIB:
|
||||
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
|
||||
missing = [name for name, val in (
|
||||
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
|
||||
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
|
||||
) if not val]
|
||||
if missing:
|
||||
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
|
||||
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
|
||||
f"identity claim {IDENTITY_CLAIM!r}")
|
||||
|
||||
|
||||
def _build_oauth():
|
||||
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
|
||||
routes) can fail loudly instead of Authlib raising deep inside a request.
|
||||
|
||||
Checked before `is_configured()`: the fake (T10.7) needs none of the four real
|
||||
Okta settings, and must win whenever it is legitimately active so a test run
|
||||
never has to also fill in placeholder OKTA_ISSUER/CLIENT_ID/etc. `is_active()`
|
||||
already refuses outside a SQLite-backed test database — see okta_fake.py."""
|
||||
from . import okta_fake
|
||||
if okta_fake.is_active():
|
||||
log.warning("*** FAKE OKTA PROVIDER ACTIVE (%s) — tests only ***", okta_fake.ENV_VAR)
|
||||
return okta_fake.build()
|
||||
if not is_configured():
|
||||
return None
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="okta",
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": SCOPES},
|
||||
)
|
||||
return oauth
|
||||
|
||||
|
||||
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
|
||||
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
|
||||
oauth = _build_oauth()
|
||||
190
server/okta_fake.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""A fake Okta, for tests only — T10.7.
|
||||
|
||||
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
|
||||
endpoint and exchanges the code with Okta's token endpoint over the network
|
||||
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
|
||||
cannot reach any of that: there is no live Okta tenant in CI, and the browser
|
||||
checks launch the app as a SUBPROCESS (`start_server` in tests/browser_check.py),
|
||||
so a monkeypatch in the test process would never reach the code doing the
|
||||
authenticating. The seam has to be configurable from the ENVIRONMENT — same
|
||||
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
|
||||
|
||||
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
|
||||
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
|
||||
and the token exchange — with no network call anywhere:
|
||||
|
||||
{"root": {"email": "root@example.test", "name": "Root Person"}}
|
||||
|
||||
The key is the identity value a real Okta ID token would carry in whichever
|
||||
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
|
||||
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
|
||||
name is actually configured rather than a hard-coded one.
|
||||
|
||||
WHAT THIS DOES AND DOES NOT REPLACE
|
||||
|
||||
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
|
||||
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
|
||||
third-party library, not this app's security logic. Everything this app itself
|
||||
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
|
||||
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
|
||||
check, JIT provisioning, and which claim carries identity. A fake run exercises
|
||||
the actual code for all of that, not a re-implementation of it — the same
|
||||
boundary `ldap_fake.py` drew around the anonymous-bind guard.
|
||||
|
||||
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
|
||||
|
||||
An environment variable that lets anyone "sign in" as any identity by visiting a
|
||||
picker page is exactly the kind of thing that must never be reachable outside a
|
||||
test process — D16 leaves no local password fallback and no break-glass, so a
|
||||
fake provider silently active in production would be a total authentication
|
||||
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
|
||||
database is configured, using the same test `auth._load_secret` and
|
||||
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
|
||||
production, full stop. `okta_auth.describe()` also shouts when the fake is
|
||||
live, and `app.py` registers the picker/consent routes only when the fake is
|
||||
active at import time — in production they do not exist, not merely refuse.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from html import escape
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
log = logging.getLogger("wpsuite.okta.fake")
|
||||
|
||||
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
|
||||
|
||||
# One-time authorization codes, in-process only. The login and the callback that
|
||||
# redeems the code both happen inside the SAME uvicorn process within one test
|
||||
# run, so this needs no more durability than that — the server restart every
|
||||
# check does between runs clears it for free. Not a cache: entries are popped on
|
||||
# first use (below) and expire on their own otherwise.
|
||||
_CODE_TTL_SECONDS = 120
|
||||
_PENDING_CODES: dict = {}
|
||||
|
||||
|
||||
def _raw() -> str:
|
||||
return os.getenv(ENV_VAR, "").strip()
|
||||
|
||||
|
||||
def is_active() -> bool:
|
||||
"""Whether the fake should answer. False in anything resembling production."""
|
||||
if not _raw():
|
||||
return False
|
||||
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
|
||||
# imported from okta_auth (and app.py), which must stay importable on its own.
|
||||
from .db import DATABASE_URL
|
||||
if not str(DATABASE_URL).startswith("sqlite"):
|
||||
log.error(
|
||||
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
|
||||
"the fake Okta provider — this looks like production, and D16 leaves no "
|
||||
"other way in, so honouring it would be an authentication bypass. "
|
||||
"Unset %s.", ENV_VAR, ENV_VAR)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def directory() -> dict:
|
||||
try:
|
||||
data = json.loads(_raw())
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("top level must be an object")
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001 — a malformed fake must not look auth-shaped
|
||||
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def new_code(claims: dict) -> str:
|
||||
code = secrets.token_urlsafe(24)
|
||||
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
|
||||
return code
|
||||
|
||||
|
||||
def consume_code(code: str) -> Optional[dict]:
|
||||
"""Pop and return the claims for a code, or None if unknown/expired/reused.
|
||||
Popping makes the code single-use, matching a real authorization code."""
|
||||
entry = _PENDING_CODES.pop(code, None)
|
||||
if not entry or entry["expires"] < time.time():
|
||||
return None
|
||||
return entry["claims"]
|
||||
|
||||
|
||||
def picker_page(state: str, redirect_uri: str) -> str:
|
||||
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
|
||||
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
|
||||
through a real page rather than skip the round trip with a minted cookie.
|
||||
Deliberately plain: nothing here is styled to resemble a real Okta page."""
|
||||
from . import okta_auth
|
||||
rows = []
|
||||
for username in directory():
|
||||
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
|
||||
rows.append(
|
||||
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
|
||||
f'Continue as {escape(username)}</a></li>')
|
||||
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
|
||||
deny_href = escape(deny_href, quote=True)
|
||||
return (
|
||||
"<!doctype html><title>FAKE Okta — tests only</title>"
|
||||
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
|
||||
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
|
||||
"<ul>" + "".join(rows) + "</ul>"
|
||||
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
|
||||
)
|
||||
|
||||
|
||||
class _FakeOktaClient:
|
||||
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
|
||||
calls, the same async signatures, zero network calls."""
|
||||
|
||||
async def authorize_redirect(self, request, redirect_uri):
|
||||
state = secrets.token_urlsafe(24)
|
||||
# The only session write this fake makes. authorize_access_token below is
|
||||
# the only read — mirrors exactly what real Authlib does with `state`,
|
||||
# which is what T10.2's missing-SessionMiddleware bug was about: this
|
||||
# round trip is a genuine test of the same plumbing.
|
||||
request.session["_okta_fake_state"] = state
|
||||
target = redirect_uri or "/api/auth/okta/callback"
|
||||
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
|
||||
f"&redirect_uri={quote(target, safe='')}")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
async def authorize_access_token(self, request):
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
expected = request.session.pop("_okta_fake_state", None)
|
||||
given = request.query_params.get("state", "")
|
||||
if not expected or given != expected:
|
||||
raise OAuthError(
|
||||
error="invalid_state",
|
||||
description="fake Okta: state did not match the session (T10.7 seam)")
|
||||
err = request.query_params.get("error")
|
||||
if err:
|
||||
raise OAuthError(
|
||||
error=err,
|
||||
description=request.query_params.get("error_description", "denied"))
|
||||
code = request.query_params.get("code", "")
|
||||
claims = consume_code(code)
|
||||
if claims is None:
|
||||
raise OAuthError(error="invalid_grant",
|
||||
description="fake Okta: unknown or expired code")
|
||||
return {"userinfo": claims}
|
||||
|
||||
|
||||
class FakeOAuth:
|
||||
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
|
||||
registered client as an attribute by name; `app.py` only ever touches
|
||||
`.okta`, so that is the only attribute this needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.okta = _FakeOktaClient()
|
||||
|
||||
|
||||
def build() -> "FakeOAuth":
|
||||
return FakeOAuth()
|
||||
@@ -9,8 +9,17 @@ gunicorn==26.0.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5 # database migrations
|
||||
psycopg[binary]==3.3.4
|
||||
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
|
||||
# Chosen over pyodbc because it ships self-contained wheels —
|
||||
# pyodbc would also need msodbcsql18 + unixODBC installed in
|
||||
# the image. To use pyodbc instead, add it here, install the
|
||||
# Microsoft ODBC driver in the Dockerfile, and switch
|
||||
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
||||
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)
|
||||
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
|
||||
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
|
||||
itsdangerous==2.2.0 # signs the OAuth-state cookie SessionMiddleware sets — required by
|
||||
# Authlib's authorize_redirect/authorize_access_token, not optional
|
||||
|
||||
@@ -9,15 +9,17 @@ and to have data to inspect.
|
||||
|
||||
Every /api/ route except /api/health requires a session, so this signs in first and
|
||||
keeps the session cookie for the rest of the run — the same way server/smoketest.py
|
||||
does, reusing its opener rather than growing a second implementation of it.
|
||||
Credentials come from the environment so the password never has to appear in a
|
||||
command line or shell history:
|
||||
does, reusing its opener AND its session-minting (not a second implementation).
|
||||
|
||||
There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
|
||||
AUTHENTICATION section for why and what that means: this needs to run where it can
|
||||
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
|
||||
and the account must already exist (this seeds a project, not a user).
|
||||
|
||||
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
|
||||
export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD
|
||||
|
||||
…or pass --user / --password. Use an admin account: seeding creates a project, and
|
||||
--clean deletes one, which needs Project Admin on it.
|
||||
…or pass --user. Use an admin account: seeding creates a project, and --clean
|
||||
deletes one, which needs Project Admin on it.
|
||||
|
||||
USAGE
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
@@ -48,16 +50,19 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# So `from server import auth, models` / `from server.db import SessionLocal` also
|
||||
# resolve (needed to mint a session — see AUTHENTICATION above).
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# The session handling is smoketest.py's, imported rather than copied: one cookie
|
||||
# jar implementation, one login flow, one place to fix. Importing is safe — that
|
||||
# module does its work under `if __name__ == "__main__"`.
|
||||
from smoketest import build_opener # noqa: E402
|
||||
# jar implementation, one session-minting flow, one place to fix. Importing is
|
||||
# safe — that module does its work under `if __name__ == "__main__"`.
|
||||
from smoketest import build_opener, seed_session_cookie # noqa: E402
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# Carries the cookie jar holding the session issued by /api/auth/login. This
|
||||
# script used to call urllib.request.urlopen() directly, which has no cookie
|
||||
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
|
||||
# This script used to call urllib.request.urlopen() directly, which has no cookie
|
||||
# support, so the session was dropped and every data route answered 401 (S13).
|
||||
OPENER = None
|
||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||
@@ -116,29 +121,22 @@ def main():
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
|
||||
"Use an admin account.")
|
||||
ap.add_argument("--password",
|
||||
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
|
||||
"preferred, so it stays out of shell history)")
|
||||
help="existing account to sign in as (default: $WP_SEED_USER, then "
|
||||
"$WP_SMOKE_USER). Use an admin account.")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
OPENER = build_opener(CTX)
|
||||
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
|
||||
("WP_SEED_PASSWORD", args.password)) if not v)
|
||||
if not args.user:
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
"no account — $WP_SEED_USER not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" this can seed without one. Set them and re-run:\n\n"
|
||||
" export WP_SEED_USER=<admin-account>\n"
|
||||
" export WP_SEED_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
|
||||
" too, so one set of credentials serves this and smoketest.py.")
|
||||
" this can seed without one. Set it and re-run:\n\n"
|
||||
" export WP_SEED_USER=<admin-account>\n\n"
|
||||
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
|
||||
" this and smoketest.py.")
|
||||
|
||||
# health gate
|
||||
try:
|
||||
@@ -148,18 +146,25 @@ def main():
|
||||
if st != 200:
|
||||
print(f"ABORT: /api/health returned {st}"); return 1
|
||||
|
||||
# Sign in. The cookie the response sets is held by OPENER's jar and rides 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 a\n"
|
||||
" few consecutive failures, so retrying with the wrong password makes this\n"
|
||||
" worse. 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)
|
||||
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
|
||||
# into OPENER's jar, so it rides every request after this one.
|
||||
try:
|
||||
from server import auth as srv_auth
|
||||
from server.db import SessionLocal
|
||||
except ImportError as e:
|
||||
return abort(f"cannot import the server package to mint a session: {e}",
|
||||
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
|
||||
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
|
||||
with SessionLocal() as db:
|
||||
user = srv_auth.find_user(db, args.user)
|
||||
if not user:
|
||||
return abort(f"no account named '{args.user}'.",
|
||||
" This signs in as an existing account, it doesn't create one — sign in\n"
|
||||
" through Okta once first, or create it from the admin console.")
|
||||
if not user.is_active:
|
||||
return abort(f"'{args.user}' is disabled.", "")
|
||||
token = srv_auth.create_token(user)
|
||||
seed_session_cookie(token, BASE)
|
||||
logged_in = True
|
||||
print(f"Signed in as {args.user}.")
|
||||
|
||||
|
||||
@@ -6,16 +6,23 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
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:
|
||||
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
|
||||
and Okta requires a real browser to complete, which this stdlib script cannot
|
||||
do. So instead of signing in over HTTP the way the front end does, this script
|
||||
mints a session the same way server/app.py's okta_callback() does after Okta
|
||||
hands back an identity: auth.create_token() for an existing account, seeded
|
||||
straight into the cookie jar. That means it needs to run somewhere that can
|
||||
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
|
||||
test — inside the api container, or locally against your dev DB. It can no
|
||||
longer sign in to an arbitrary remote URL from an unrelated workstation; if
|
||||
the target is remote, run it on that host or inside that container instead.
|
||||
|
||||
export WP_SMOKE_USER=smoketest
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
…or pass --user / --password explicitly.
|
||||
…or pass --user explicitly. The account must already exist — sign it in
|
||||
through Okta once first (or create it from the admin console) if it doesn't;
|
||||
this script promotes no one and provisions nothing.
|
||||
|
||||
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);
|
||||
@@ -24,26 +31,29 @@ AUTHENTICATION
|
||||
discover it in the cleanup step.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
# Self-signed / internal TLS cert? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
|
||||
# FastAPI directly):
|
||||
docker compose exec -e WP_SMOKE_USER api \
|
||||
python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Local dev, against the app you're running yourself:
|
||||
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
|
||||
python3 server/smoketest.py http://localhost:8000
|
||||
|
||||
# Self-signed / internal TLS cert on the HTTP side? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# 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 http://localhost:8000 --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
|
||||
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.
|
||||
could not start (unreachable host, missing credentials, or no account by that
|
||||
username). 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 http.cookiejar
|
||||
@@ -53,6 +63,12 @@ import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
|
||||
# when this file is run directly (`python3 server/smoketest.py`) rather than as
|
||||
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||
_PASS, _FAIL = [], []
|
||||
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
|
||||
|
||||
BASE = ""
|
||||
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.
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session.
|
||||
# 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
|
||||
COOKIE_JAR = None
|
||||
|
||||
|
||||
def build_opener(ctx=None):
|
||||
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
||||
global COOKIE_JAR
|
||||
COOKIE_JAR = http.cookiejar.CookieJar()
|
||||
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener(*handlers)
|
||||
|
||||
|
||||
def seed_session_cookie(token: str, base: str) -> None:
|
||||
"""Put a minted session into the jar directly, the same shape a Set-Cookie
|
||||
response from the old /api/auth/login would have produced — so the logout
|
||||
check below (which relies on the jar honoring logout()'s Set-Cookie that
|
||||
expires it) keeps working unchanged. `base` is explicit rather than read off
|
||||
this module's own BASE global, so seed_demo.py (which imports this function
|
||||
but has its own BASE) seeds the cookie for the host it's actually targeting."""
|
||||
host = urlparse(base).hostname or "localhost"
|
||||
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
|
||||
version=0, name="wp_session", value=token,
|
||||
port=None, port_specified=False,
|
||||
domain=host, domain_specified=True, domain_initial_dot=False,
|
||||
path="/", path_specified=True,
|
||||
secure=False, expires=None, discard=True,
|
||||
comment=None, comment_url=None, rest={"HttpOnly": None},
|
||||
))
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
url = BASE + path
|
||||
@@ -116,10 +153,7 @@ def main():
|
||||
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("--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)")
|
||||
help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
@@ -128,17 +162,14 @@ def main():
|
||||
|
||||
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)
|
||||
# Refuse to start without a username rather than running headlong into 401s.
|
||||
if not args.user:
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
"no account — $WP_SMOKE_USER 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"
|
||||
" meaningful to test without one. Set it and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n\n"
|
||||
" Or pass --user. 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
|
||||
@@ -157,21 +188,29 @@ def main():
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
||||
# 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)
|
||||
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
|
||||
# it into OPENER's jar, so it rides every request after this one exactly the
|
||||
# way a real Set-Cookie response would have.
|
||||
try:
|
||||
from server import auth as srv_auth
|
||||
from server.db import SessionLocal
|
||||
except ImportError as e:
|
||||
return abort(f"cannot import the server package to mint a session: {e}",
|
||||
" This script now needs to run where server/ is importable and\n"
|
||||
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
|
||||
" AUTHENTICATION above.")
|
||||
with SessionLocal() as db:
|
||||
user = srv_auth.find_user(db, args.user)
|
||||
if not user:
|
||||
return abort(f"no account named '{args.user}'.",
|
||||
" This script signs in as an existing account, it doesn't create one —\n"
|
||||
" sign in through Okta once first, or create it from the admin console.")
|
||||
if not user.is_active:
|
||||
return abort(f"'{args.user}' is disabled.", "")
|
||||
token = srv_auth.create_token(user)
|
||||
seed_session_cookie(token, BASE)
|
||||
logged_in = True
|
||||
check("login issues a session", st == 200)
|
||||
check("session cookie seeded", bool(token))
|
||||
|
||||
# 3) Prove the session actually travels — this is the check whose absence let
|
||||
# an unauthenticated version of this script look like a broken stack.
|
||||
|
||||
@@ -155,6 +155,18 @@ def main():
|
||||
str(shown) == str(as_root["total"]),
|
||||
"shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"]))
|
||||
chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown)
|
||||
# D12: the productivity factor card, computed from the SAME server
|
||||
# sums as its neighbours. Both hour fields are optional (CR-017),
|
||||
# so the expected value is derived, not hardcoded: a real quotient
|
||||
# when both sums exist, an em dash when either is zero.
|
||||
pf_shown = page.eval(
|
||||
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
|
||||
".find(x=>/Productivity/i.test(x.textContent));"
|
||||
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
|
||||
est, act = as_root.get("est_hours") or 0, as_root.get("actual_hours") or 0
|
||||
pf_want = ("%.2f" % (act / est)) if est > 0 and act > 0 else "—"
|
||||
chk("the D12 productivity card shows actual/estimated from the server sums",
|
||||
pf_shown == pf_want, "shown=%r want=%r (est=%r act=%r)" % (pf_shown, pf_want, est, act))
|
||||
|
||||
print("\n3. a failed aggregate request is an error, not a zero")
|
||||
page.eval("""(() => {
|
||||
|
||||
181
tests/archived_check.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Can a project admin get back into an archived project — and only they? — D7, T9.8.
|
||||
|
||||
Archiving read as deletion because there was no way back in. Now: a separate,
|
||||
labelled, read-only list on the launcher for project admins; the server filters
|
||||
the answer by per-project role, refuses every write regardless of what the
|
||||
browser sends, and shows archived projects to nobody else anywhere - counts and
|
||||
pickers included.
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
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
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
from qa_gate_check import api # noqa: E402
|
||||
|
||||
|
||||
def ascii_(v, n=280):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.5):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def archive_projB(db_path):
|
||||
from server.db import SessionLocal
|
||||
from server import models
|
||||
with SessionLocal() as db:
|
||||
proj = db.get(models.Project, "projB")
|
||||
proj.archived_at = models.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-arch-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
archive_projB(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root, bob, pat = tok["root"], tok["bob"], tok["pat"]
|
||||
|
||||
# ── 1. who sees what ──────────────────────────────────────────────────
|
||||
print("\n1. visibility, by role")
|
||||
_, rows = api(base, "/api/projects", root)
|
||||
chk("the default list hides archived projects from EVERYONE, admin included",
|
||||
all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows]))
|
||||
_, rows = api(base, "/api/projects?archived=only", root)
|
||||
chk("an admin asking for the archived list gets it",
|
||||
[p["id"] for p in rows] == ["projB"], ascii_(rows))
|
||||
_, rows = api(base, "/api/projects?archived=only", bob)
|
||||
chk("a plain project user ON that project gets an empty list - no leak",
|
||||
rows == [], ascii_(rows))
|
||||
_, rows = api(base, "/api/projects?archived=all", bob)
|
||||
chk("...and cannot smuggle it through archived=all either",
|
||||
all(p["id"] != "projB" for p in rows), ascii_(rows))
|
||||
_, rows = api(base, "/api/projects?archived=only", pat)
|
||||
chk("a user with no access to it sees nothing, same as before",
|
||||
rows == [], ascii_(rows))
|
||||
|
||||
# ── 2. the server refuses writes regardless of the browser ───────────
|
||||
print("\n2. frozen means frozen")
|
||||
code, out = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpArch1", "project_id": "projB", "number": "AR-1",
|
||||
"subject": "write into the archive", "status": "Draft",
|
||||
"data": {"constraints": []}})
|
||||
chk("a direct write to an archived project is refused, even for an admin",
|
||||
code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out)))
|
||||
code, _ = api(base, "/api/projects/projB/materials", root, "POST",
|
||||
{"description": "Sample sneak", "unit": "EA"})
|
||||
chk("...and so is every other write route (material list)", code in (403, 409), code)
|
||||
code, wps = api(base, "/api/wps?project_id=projB", root)
|
||||
chk("reading it still works - archived is readable, not gone",
|
||||
code == 200, code)
|
||||
|
||||
# ── 3. the launcher, both roles, at 390px ─────────────────────────────
|
||||
print("\n3. the launcher")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(390, 844, mobile=True)
|
||||
page.goto(base + "/index.html")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
sec = json.loads(page.eval("""JSON.stringify((() => {
|
||||
const s = document.getElementById('archived-projects');
|
||||
return {hidden: !s || s.hidden,
|
||||
text: s ? s.textContent : '',
|
||||
buttons: s ? s.querySelectorAll('button').length : 0};
|
||||
})())"""))
|
||||
chk("a project admin sees the archived list, separate and labelled",
|
||||
not sec["hidden"] and "Archived projects" in sec["text"]
|
||||
and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec))
|
||||
chk("...and it fits at 390px", page.eval(
|
||||
"document.getElementById('archived-projects').scrollWidth <= 392"))
|
||||
|
||||
page.eval("document.querySelector('[data-open-archived]').click()")
|
||||
settle(2.0)
|
||||
chk("opening one makes it the active project",
|
||||
page.eval("(ProjectData.getActive()||{}).id") == "projB")
|
||||
|
||||
# the creator's read-only courtesy on top of the server's rule
|
||||
page.goto(base + "/wp-creation-index.html?project=projB")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
|
||||
chk("the creator says ARCHIVED where the project is named",
|
||||
"ARCHIVED" in page.eval(
|
||||
"(document.getElementById('ctx-bar')||{textContent:''}).textContent"))
|
||||
page.eval("document.getElementById('wp_subject').value='x'")
|
||||
page.eval("document.getElementById('wp_type').value='Conduit Install'")
|
||||
n0 = page.eval("savedPackages.length")
|
||||
page.eval("void savePackage(false)")
|
||||
settle(0.8)
|
||||
chk("saving is refused with a reason, before the round trip",
|
||||
page.eval("savedPackages.length") == n0
|
||||
and "archived" in page.eval(
|
||||
"(document.getElementById('toast')||{textContent:''}).textContent").lower())
|
||||
|
||||
# a NON-admin's launcher shows no archived section at all
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", bob)
|
||||
page.goto(base + "/index.html")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
chk("a non-admin's launcher never shows the section",
|
||||
page.eval("(() => { const s=document.getElementById('archived-projects');"
|
||||
" return !s || s.hidden; })()"))
|
||||
|
||||
# projB has no SOP, and GET /api/sops/latest answering 404 for it is the
|
||||
# correct answer, not an error - the seed fixture documents exactly this
|
||||
# false alarm.
|
||||
js_errors = [e for e in page.js_errors()
|
||||
if "beforeunload" not in e and "sops/latest" not in e]
|
||||
chk("no JavaScript errors anywhere in this run", not js_errors,
|
||||
ascii_(js_errors[:2]))
|
||||
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
263
tests/assets_check.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the Micron asset picker read-only, and does it degrade to manual entry? — D11.
|
||||
|
||||
Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to
|
||||
the R2 creator (decisions-2026-08-20.md). The properties this pins:
|
||||
|
||||
* **Read-only, structurally.** assets_db.py holds one SELECT and nothing else;
|
||||
/api/assets has no writing verb. Picking an asset can never change Micron.
|
||||
* **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false,
|
||||
the picker says so, and manual entry carries the package. The suite must run
|
||||
without Micron existing at all — every other probe implicitly relies on that.
|
||||
* **Broken is not a leak.** A configured-but-unusable URL 503s with a message
|
||||
that never echoes the connection string (whose parse errors can quote
|
||||
password fragments).
|
||||
* **The client honours the catalog.** Search ranks exact matches first, a
|
||||
picked row is locked to the DB's own casing and badged, imports canonicalise
|
||||
casing / fall back to manual / skip duplicates, and the import summary goes
|
||||
through the T7.9 dialog kit, not a native alert().
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not
|
||||
back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
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
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
from qa_gate_check import api # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
SERVER = os.path.join(ROOT, "server")
|
||||
|
||||
|
||||
def ascii_(v, n=240):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def strip_py(src):
|
||||
src = re.sub(r'""".*?"""', "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
# ── 1. read-only, structurally ─────────────────────────────────────────────
|
||||
print("\n1. read-only, structurally")
|
||||
src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read())
|
||||
verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b",
|
||||
src, re.I)
|
||||
chk("assets_db.py contains no writing SQL verb", not verbs, verbs)
|
||||
chk("...and exactly one SELECT (the whole schema contract)",
|
||||
len(re.findall(r"\bSELECT\b", src, re.I)) == 1)
|
||||
app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read()
|
||||
chk("/api/assets is a GET and only a GET",
|
||||
len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1
|
||||
and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src))
|
||||
outside = [f for f in ("models.py", "auth.py", "notify.py")
|
||||
if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()]
|
||||
chk("the connection string is env-only plumbing, not model or auth state",
|
||||
not outside, outside)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
|
||||
# ── 2. the API's unconfigured state ────────────────────────────────────
|
||||
print("\n2. unconfigured is a first-class state")
|
||||
st, _ = api(base, "/api/assets", "not-a-session")
|
||||
chk("anonymous gets 401, same as every other /api/ path", st == 401, st)
|
||||
st, body = api(base, "/api/assets", tok["root"])
|
||||
chk("signed in, no MICRON_DB_URL: 200 with configured:false",
|
||||
st == 200 and body and body.get("configured") is False
|
||||
and body.get("assets") == [], ascii_(body))
|
||||
chk("...and the detail tells the user what to do instead",
|
||||
"manual" in (body.get("detail") or "").lower(), ascii_(body))
|
||||
|
||||
# ── 3. the picker, catalog absent ──────────────────────────────────────
|
||||
print("\n3. the picker degrades to manual entry")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
chk("the creator boots", wait_creator(page))
|
||||
time.sleep(1.2)
|
||||
chk("the search box is disabled and says the catalog is not configured",
|
||||
page.eval("(() => { const b=document.getElementById('asset-search');"
|
||||
" return b.disabled && /not configured/i.test(b.placeholder); })()"))
|
||||
chk("the source note announces it (role=status, non-empty)",
|
||||
page.eval("(() => { const n=document.getElementById('asset-source-note');"
|
||||
" return n.getAttribute('role')==='status' && n.textContent.length>0; })()"))
|
||||
chk("no assets yet: the empty state renders instead of a blank table",
|
||||
page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)"))
|
||||
page.eval("addManualAsset()")
|
||||
chk("+ Add asset adds an editable manual row",
|
||||
page.eval("pkgAssets.length") == 1
|
||||
and page.eval("pkgAssets[0].source") == "manual"
|
||||
and page.eval("!!document.querySelector('#asset-body input')"))
|
||||
page.eval("document.querySelector('#asset-body input').value='HAND-01';"
|
||||
"document.querySelector('#asset-body input')"
|
||||
".dispatchEvent(new Event('input',{bubbles:true}))")
|
||||
chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01")
|
||||
|
||||
# ── 4. the client honours the catalog (injected; no SQL Server here) ──
|
||||
print("\n4. search, pick, import — against an injected catalog")
|
||||
page.eval("pkgAssets=[]; buildAssets();"
|
||||
"assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];"
|
||||
"assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));"
|
||||
"assetCatalogState='ready';"
|
||||
"(() => { const b=document.getElementById('asset-search');"
|
||||
" b.disabled=false; b.placeholder='Search asset IDs'; })()")
|
||||
page.eval("runAssetSearch('pump-01')")
|
||||
chk("an exact match outranks a longer contains-match",
|
||||
page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]',
|
||||
ascii_(page.eval("JSON.stringify(assetResults)")))
|
||||
chk("results render as real <button>s, none disabled yet",
|
||||
page.eval("(() => { const r=[...document.querySelectorAll('#asset-results button.asset-result')];"
|
||||
" return r.length===2 && r.every(b=>!b.disabled); })()"))
|
||||
page.eval("addCatalogAsset(0)")
|
||||
chk("the pick is announced (role=status toast) - a keyboard pick is otherwise silent",
|
||||
page.eval("(() => { const t=document.getElementById('toast');"
|
||||
" return !!t && t.getAttribute('role')==='status'"
|
||||
" && /Added PUMP-01/.test(t.textContent); })()"))
|
||||
chk("picking adds a catalog row: locked ID (no input), badge, source:'catalog'",
|
||||
page.eval("pkgAssets.length") == 1
|
||||
and page.eval("pkgAssets[0].source") == "catalog"
|
||||
and page.eval("(() => { const tr=document.querySelector('#asset-body tr');"
|
||||
" return !!tr.querySelector('.asset-badge')"
|
||||
" && !tr.cells[0].querySelector('input'); })()"))
|
||||
n0 = page.eval("pkgAssets.length")
|
||||
page.eval("addCatalogAsset(0)")
|
||||
chk("picking it again is refused (already on the package)",
|
||||
page.eval("pkgAssets.length") == n0)
|
||||
chk("normaliseAsset: no source means manual; an unknown source means manual",
|
||||
page.eval("normaliseAsset({tag:'X'}).source") == "manual"
|
||||
and page.eval("normaliseAsset({tag:'X',source:'evil'}).source") == "manual"
|
||||
and page.eval("normaliseAsset({tag:'X',source:'catalog'}).source") == "catalog")
|
||||
|
||||
page.eval("void applyImportedAssets([['asset id'],['ahu-2p-015'],['NOT-IN-DB'],['AHU-2P-015']])")
|
||||
time.sleep(0.4)
|
||||
got = json.loads(page.eval(
|
||||
"JSON.stringify(pkgAssets.map(a=>({t:a.tag,s:a.source})))"))
|
||||
chk("import: a hit is canonicalised to the DB's own casing and badged catalog",
|
||||
{"t": "AHU-2P-015", "s": "catalog"} in got, ascii_(got))
|
||||
chk("...a miss is kept, visibly manual — not silently dropped",
|
||||
{"t": "NOT-IN-DB", "s": "manual"} in got, ascii_(got))
|
||||
chk("...the in-file duplicate is skipped (3 rows total: pick + hit + miss)",
|
||||
len(got) == 3, ascii_(got))
|
||||
chk("...and the summary is the T7.9 dialog, not a native alert()",
|
||||
page.eval("document.getElementById('wp-dialog').classList.contains('open')")
|
||||
and page.eval("document.getElementById('wp-dialog-cancel').style.display") == "none")
|
||||
page.eval("wpDialogOk()")
|
||||
|
||||
page.eval("(() => { const b=document.getElementById('asset-search');"
|
||||
" b.value='ct-1'; runAssetSearch(b.value);"
|
||||
" b.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); })()")
|
||||
chk("Enter takes the first result not already on the package",
|
||||
page.eval("pkgAssets[pkgAssets.length-1].tag") == "CT-100")
|
||||
|
||||
# removal reopens the row for re-adding
|
||||
page.eval("runAssetSearch('ct-100')")
|
||||
chk("a just-added result reads 'added' and is disabled",
|
||||
page.eval("(() => { const b=document.querySelector('#asset-results button');"
|
||||
" return b.disabled && /added/.test(b.textContent); })()"))
|
||||
page.eval("removeAsset(pkgAssets.length-1)")
|
||||
chk("removing the asset makes it addable again",
|
||||
page.eval("(() => { const b=document.querySelector('#asset-results button');"
|
||||
" return !b.disabled && /add/.test(b.textContent); })()"))
|
||||
|
||||
# The tier-cap regression (review finding, fixed same day): 600
|
||||
# alphabetically-early contains-matches must not evict a prefix match
|
||||
# that sorts after every one of them. Before the fix the scan broke at
|
||||
# a COMBINED 500 and Enter added the wrong asset, ID-locked.
|
||||
got = json.loads(page.eval(
|
||||
"(() => { const c=[];"
|
||||
" for(let i=0;i<600;i++) c.push('A'+String(i).padStart(4,'0')+'-PMP-10');"
|
||||
" c.push('PMP-10-EXTRA');"
|
||||
" assetCatalog=c; assetCatalogIndex=new Map(c.map(t=>[t.toLowerCase(),t]));"
|
||||
" assetCatalogState='ready'; runAssetSearch('pmp-10');"
|
||||
" return JSON.stringify([assetResults[0], assetResults.length]); })()"))
|
||||
chk("a prefix match outranks 600 earlier contains-matches (cap is per tier)",
|
||||
got[0] == "PMP-10-EXTRA" and got[1] == 500, ascii_(got))
|
||||
|
||||
# ── 5. configured-but-broken: a 503 that does not leak ────────────────
|
||||
print("\n5. broken is not a leak")
|
||||
browser.close()
|
||||
browser = None
|
||||
server.terminate()
|
||||
server.wait(timeout=10)
|
||||
os.environ["MICRON_DB_URL"] = "mssql+pymssql://user:S3CRETpw@127.0.0.1:1/MicronDB"
|
||||
# A malformed tuning knob must degrade, not crash the boot (review
|
||||
# finding: int() at import time made "5m" a total-outage switch).
|
||||
os.environ["MICRON_ASSETS_CACHE_SECONDS"] = "5m"
|
||||
try:
|
||||
port2 = cdp.free_port()
|
||||
base2 = "http://127.0.0.1:%d" % port2
|
||||
server = start_server(port2, db_path)
|
||||
chk("the suite boots with MICRON_ASSETS_CACHE_SECONDS='5m' (degrades, no crash)",
|
||||
server is not None and server.poll() is None)
|
||||
st, body = api(base2, "/api/assets", tok["root"])
|
||||
detail = (body or {}).get("detail") or ""
|
||||
chk("a configured-but-unusable catalog answers 503, not 500",
|
||||
st == 503, (st, ascii_(body)))
|
||||
chk("...and the message never echoes the URL, login or password",
|
||||
"S3CRETpw" not in detail and "user" not in detail
|
||||
and "127.0.0.1:1" not in detail, ascii_(detail))
|
||||
# The negative cache (review finding): the second request inside the
|
||||
# failure window must answer from the remembered error - same 503,
|
||||
# same safe text - not stack another connect attempt in a worker.
|
||||
st2, body2 = api(base2, "/api/assets", tok["root"])
|
||||
chk("...and a second request answers the cached failure, stable and safe",
|
||||
st2 == 503 and (body2 or {}).get("detail") == detail,
|
||||
(st2, ascii_(body2)))
|
||||
finally:
|
||||
del os.environ["MICRON_DB_URL"]
|
||||
del os.environ["MICRON_ASSETS_CACHE_SECONDS"]
|
||||
finally:
|
||||
if browser:
|
||||
browser.close()
|
||||
if server:
|
||||
server.terminate()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -178,6 +178,24 @@ def main():
|
||||
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
|
||||
browser = cdp.Browser()
|
||||
page = browser.page()
|
||||
# BL-012 (fixed at T9.9): admin's captured height varied ~600px between
|
||||
# runs and the creator at 1440px shifted, because live timestamps and
|
||||
# relative times re-render per run. Freezing Date (and Math.random) in
|
||||
# every new document makes a capture comparable with the last one.
|
||||
page.ws.call("Page.addScriptToEvaluateOnNewDocument", {"source": (
|
||||
"(function(){"
|
||||
"var FIXED = 1755600000000;" # 2026-08-19T10:40Z
|
||||
"var RealDate = Date;"
|
||||
"function FrozenDate(){ return new RealDate(FIXED); }"
|
||||
"FrozenDate.now = function(){ return FIXED; };"
|
||||
"FrozenDate.parse = RealDate.parse; FrozenDate.UTC = RealDate.UTC;"
|
||||
"FrozenDate.prototype = RealDate.prototype;"
|
||||
"window.Date = FrozenDate;"
|
||||
"var seed = 42;"
|
||||
"Math.random = function(){ seed = (seed * 9301 + 49297) % 233280;"
|
||||
" return seed / 233280; };"
|
||||
"})();"
|
||||
)})
|
||||
for name, filename, user, wait_for in pages:
|
||||
capture(page, base, tok, name, filename, user, wait_for,
|
||||
widths, args.out, args.label)
|
||||
|
||||
@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
|
||||
not test this" is not the same answer as "this is broken".
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -39,7 +40,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
|
||||
PW = "CorrectHorseBattery9"
|
||||
_PASS, _FAIL = [], []
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def seed(db_path):
|
||||
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))
|
||||
role=role))
|
||||
|
||||
mk("root", auth.ROLE_ADMIN)
|
||||
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
||||
@@ -109,9 +109,34 @@ def seed(db_path):
|
||||
# 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.
|
||||
# BL-018 (fixed at T9.9): the production shape is {sop, state}, as
|
||||
# ProjectData.pushSOP writes it. The old {"governance": ...} blob was a
|
||||
# shape no code path produces, and it sent four probes' creators to the
|
||||
# SOP gate until each imported set_sop() to overwrite it.
|
||||
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
|
||||
complete=True,
|
||||
data={"governance": {"disciplines": ["Mechanical", "Electrical"]}}))
|
||||
data={"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
|
||||
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
|
||||
"governance": {"disciplines": ["Mechanical", "Electrical"],
|
||||
"woFormat": "WP##-[TYPE]"},
|
||||
"woTypes": [{"name": "Conduit Install", "enabled": True}],
|
||||
"sections": {}},
|
||||
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
|
||||
"division": "Internal", "site": "QA Lab"},
|
||||
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||
"teamMembers": [], "sections": {},
|
||||
"signoffRoles": [{"role": "Superintendent", "name": ""},
|
||||
{"role": "Foreman", "name": ""}],
|
||||
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
|
||||
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
|
||||
"disciplines": ["Mechanical", "Electrical"],
|
||||
"discMode": "choice", "instanceSuffix": "letter",
|
||||
"sizeHoursMax": ""},
|
||||
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
|
||||
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
|
||||
"trackingUrl": "", "commissioningUrl": ""},
|
||||
"constraints": [], "sequence": [], "sources": []}}))
|
||||
db.flush()
|
||||
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
|
||||
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
|
||||
@@ -125,10 +150,29 @@ def seed(db_path):
|
||||
for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
def start_server(port, db_path):
|
||||
# T10.7. The app authenticates through Okta, which no test can reach, and
|
||||
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
|
||||
# reach the code doing the authenticating. server/okta_fake.py reads this
|
||||
# instead, and refuses to work against a non-SQLite database.
|
||||
#
|
||||
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
|
||||
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
|
||||
# is driven — url_state_check's deep-link case. It is set for every server here
|
||||
# anyway so that a test which starts signing in later does not fail
|
||||
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
|
||||
FAKE_DIRECTORY = json.dumps({
|
||||
u: {"email": f"{u}@example.test", "name": u.title()}
|
||||
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
||||
})
|
||||
|
||||
|
||||
def start_server(port, db_path, extra_env=None):
|
||||
env = dict(os.environ)
|
||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
@@ -411,7 +455,10 @@ def main():
|
||||
finally:
|
||||
if args.keep_server:
|
||||
print(f"\n --keep-server: still up at {base}, database at {db_path}")
|
||||
print(" Sign in as root / " + PW)
|
||||
# No local password exists (D15/D16) — there's nothing to type into a login
|
||||
# form. Set the session cookie directly, the same way this script's own
|
||||
# fixture does, from the browser console on that origin:
|
||||
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
|
||||
else:
|
||||
if server:
|
||||
# Wait for it to actually exit before deleting the database out from
|
||||
|
||||
110
tests/color_check.py
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is theme-light.css the only place a colour exists? — C4, T9.9.
|
||||
|
||||
The token rule, finally enforceable everywhere: after this sweep no colour
|
||||
literal survives outside theme-light.css - not in page stylesheets, not in the
|
||||
help centre's injected styles (BL-004), not in the JS-built dialogs (BL-005),
|
||||
not in the print popup. One accent blue (BL-008 - the second brand blue is
|
||||
gone, .sop-inherited tints with THE blue) and one warning amber (BL-009 - the
|
||||
alt token is deleted). Comments are stripped first: quoting a hex while
|
||||
explaining it is not declaring one (the BL-017 lesson).
|
||||
|
||||
The exceptions, in full: <meta name="theme-color"> (a meta attribute cannot
|
||||
resolve a CSS var), and rgba() shadow/overlay alphas, which are opacity
|
||||
recipes, not palette entries.
|
||||
|
||||
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from browser_check import chk, _PASS, _FAIL # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
def strip_comments(src, is_css):
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
if not is_css:
|
||||
src = "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
src = re.sub(r"<!--.*?-->", "", src, flags=re.S)
|
||||
return src
|
||||
|
||||
|
||||
def main():
|
||||
print("\n1. hex literals outside theme-light.css")
|
||||
offenders = []
|
||||
for name in sorted(os.listdir(HTML)):
|
||||
if not name.endswith((".js", ".html", ".css")) or name == "theme-light.css":
|
||||
continue
|
||||
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
|
||||
name.endswith(".css"))
|
||||
# the one exception: the browser-chrome hint, which cannot use var()
|
||||
src = re.sub(r'<meta name="theme-color" content="#[0-9a-fA-F]{6}"\s*/?>', "", src)
|
||||
for m in re.finditer(r"#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b", src):
|
||||
offenders.append("%s: %s" % (name, m.group(0)))
|
||||
chk("no hex colour literal outside theme-light.css; grep confirms",
|
||||
not offenders, offenders[:8])
|
||||
|
||||
print("\n2. one blue, one amber")
|
||||
theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read()
|
||||
code = strip_comments(theme, True)
|
||||
# BL-025 widened this: the rgb spelling is compared space-free, because
|
||||
# rgba(37,99,214,.15) in help.js slid past the spaced grep for months.
|
||||
chk("the second brand blue (#2563d6) is gone from the theme itself",
|
||||
"2563d6" not in code.lower()
|
||||
and "37,99,214" not in code.replace(" ", ""))
|
||||
chk("the ninth amber (--wp-status-warning-text-alt) is deleted",
|
||||
"--wp-status-warning-text-alt" not in code)
|
||||
others = []
|
||||
for name in sorted(os.listdir(HTML)):
|
||||
if name == "theme-light.css" or not name.endswith((".js", ".css", ".html")):
|
||||
continue
|
||||
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
|
||||
name.endswith(".css"))
|
||||
if ("warning-text-alt" in src or "2563d6" in src.lower()
|
||||
or "37,99,214" in src.replace(" ", "")):
|
||||
others.append(name)
|
||||
chk("...and no consumer still references either", not others, others)
|
||||
|
||||
print("\n3. every token consumed is a token defined")
|
||||
# The bug this pins: help.js (and six other files) shipped consuming
|
||||
# --cds-layer-01/-02 and --cds-border-subtle-01/-strong-01 - names the theme
|
||||
# never defined (its names carry no -01 suffix). An undefined var() makes
|
||||
# the whole declaration invalid, so the help centre modal, the password and
|
||||
# language dialogs, and the print popup all rendered TRANSPARENT
|
||||
# backgrounds. Found by the user, 2026-08-20. Definitions are collected
|
||||
# from every file (page aliases are legal); consumption of a name nobody
|
||||
# defines is the defect.
|
||||
defined, consumed = set(), {}
|
||||
for name in sorted(os.listdir(HTML)):
|
||||
if not name.endswith((".js", ".html", ".css")):
|
||||
continue
|
||||
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
|
||||
for m in re.finditer(r"(--[a-zA-Z0-9-]+)\s*:", src):
|
||||
defined.add(m.group(1))
|
||||
for m in re.finditer(r"setProperty\(\s*['\"](--[a-zA-Z0-9-]+)", src):
|
||||
defined.add(m.group(1))
|
||||
for m in re.finditer(r"var\(\s*(--[a-zA-Z0-9-]+)", src):
|
||||
consumed.setdefault(m.group(1), set()).add(name)
|
||||
# --wp-chart- is the creator's JS-concatenated fallback ('--wp-chart-'+k);
|
||||
# the numbered names it builds are all defined, the fragment is not a name.
|
||||
unresolved = ["%s (%s)" % (t, ", ".join(sorted(fs)))
|
||||
for t, fs in sorted(consumed.items())
|
||||
if t not in defined and t != "--wp-chart-"]
|
||||
chk("no var() anywhere names a token that nothing defines",
|
||||
not unresolved, unresolved[:8])
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
132
tests/console_dialogs_check.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Are the consoles' and launcher's 21 native dialogs gone? — BL-024, 2026-08-20.
|
||||
|
||||
S1 counted 79 native dialogs app-wide; its two tasks (T5.8 wizard, T7.9
|
||||
creator) removed 58 and the audit found the remaining 21 on surfaces no S1
|
||||
task named: admin.js (6), users.js (10), the launcher's inline script (5).
|
||||
They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
|
||||
self-injecting component (guarded so the creator's inline copy still wins on
|
||||
its own page).
|
||||
|
||||
Static half greps the counts; browser half drives the users console's dialogs
|
||||
with natives poisoned and proves they still work end to end.
|
||||
|
||||
Used to drive this via the password-reset prompt specifically, because it was
|
||||
the one place on this page exercising wp-dialog.js's PROMPT variant (text input
|
||||
+ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
|
||||
removed admin password reset entirely — there is no password to reset anymore
|
||||
— so that coverage moved with it. The prompt-with-validate() pattern itself is
|
||||
still exercised, just not on this page: see creator_dialogs_check.py for
|
||||
wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
|
||||
a new prompt-style dialog, it belongs back in this file.
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
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
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from qa_gate_check import api # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
def ascii_(v, n=240):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def strip_js(src):
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
def natives(name):
|
||||
code = strip_js(io.open(os.path.join(HTML, name), encoding="utf-8").read())
|
||||
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", code))
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
print("\n1. the counts (baseline 6 + 10 + 5 = 21)")
|
||||
for name in ("admin.js", "users.js", "index.html"):
|
||||
chk("%s: 0 native dialogs" % name, natives(name) == 0, natives(name))
|
||||
chk("wp-dialog.js exists, has the guard, and no natives of its own",
|
||||
natives("wp-dialog.js") == 0
|
||||
and "typeof global.wpConfirmDialog === 'function'" in
|
||||
io.open(os.path.join(HTML, "wp-dialog.js"), encoding="utf-8").read())
|
||||
for page in ("index.html", "admin.html", "users.html"):
|
||||
chk("%s loads the kit" % page,
|
||||
'src="wp-dialog.js"' in io.open(os.path.join(HTML, page), encoding="utf-8").read())
|
||||
chk("the creator keeps its own copy (it owns the same-id markup in its HTML)",
|
||||
"function wpConfirmDialog" in
|
||||
io.open(os.path.join(HTML, "wp-creation-app.js"), encoding="utf-8").read())
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-condlg-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
|
||||
print("\n2. the users console, natives poisoned")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/users.html")
|
||||
time.sleep(2.0)
|
||||
page.eval("window.alert=()=>{throw new Error('native alert reached')};"
|
||||
"window.confirm=()=>{throw new Error('native confirm reached')};"
|
||||
"window.prompt=()=>{throw new Error('native prompt reached')};")
|
||||
chk("the console booted with a user table",
|
||||
page.eval("!!document.querySelector('table')"))
|
||||
|
||||
print("\n3. destroy needs a real yes")
|
||||
page.eval("void deleteUser('user_bob','bob')")
|
||||
time.sleep(0.4)
|
||||
chk("the delete asks through the kit, spelling out what goes with it",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return o.classList.contains('open')"
|
||||
" && /cannot be undone/.test(document.getElementById('wp-dlg-msg').textContent); })()"))
|
||||
page.eval("document.getElementById('wp-dlg-cancel').click()")
|
||||
time.sleep(0.6)
|
||||
st, users = api(base, "/api/auth/users", tok["root"])
|
||||
chk("cancel means no: bob is still an account",
|
||||
st == 200 and any(u.get("username") == "bob" for u in (users or [])),
|
||||
ascii_([u.get("username") for u in (users or [])]))
|
||||
errs = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||
chk("no JavaScript errors, and no path reached a native dialog (they throw here)",
|
||||
not errs, ascii_(errs[:3]))
|
||||
finally:
|
||||
if browser:
|
||||
browser.close()
|
||||
if server:
|
||||
server.terminate()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
135
tests/critical_reopen_check.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the critical-reopen mail reach the PM and CM? — BL-021, fixed 2026-08-20.
|
||||
|
||||
`project_sop_team()` read `sop.data['project']`, but `pushSOP` stores every row
|
||||
as `data={sop, state}` — the project block is one level deeper. The lookup
|
||||
returned `[]` for every real row, so the on-hold email's recipient list was
|
||||
silently reduced to assignee + distribution: the PM and CM named in
|
||||
`notify_critical_reopen`'s own docstring never got it, from the day it shipped.
|
||||
|
||||
The fixture writes the PRODUCTION shape (nested under 'sop'), because a
|
||||
hand-built flat row would have passed against the bug — which is exactly how it
|
||||
went unverified this long. Sink pattern from qa_gate_check, one implementation.
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn; run it alone, not back to back.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from qa_gate_check import SmtpSink, api, wait_for # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def ascii_(v, n=280):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def set_team(pm_id, cm_id):
|
||||
"""The PRODUCTION shape: data['sop']['project'], as pushSOP writes it."""
|
||||
from server.db import SessionLocal
|
||||
from server import models
|
||||
with SessionLocal() as db:
|
||||
sop = db.get(models.Sop, "sopA")
|
||||
data = json.loads(json.dumps(sop.data or {}))
|
||||
proj = data.setdefault("sop", {}).setdefault("project", {})
|
||||
proj["pmId"], proj["cmId"] = pm_id, cm_id
|
||||
sop.data = data
|
||||
db.commit()
|
||||
|
||||
|
||||
def main():
|
||||
print("\n1. the read matches the written shape (static)")
|
||||
src = io.open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()
|
||||
fn = src[src.index("def project_sop_team"):src.index("def project_qa_group")]
|
||||
chk("project_sop_team reads the nested data['sop']['project'] first",
|
||||
'.get("sop")' in fn and '.get("project")' in fn)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-reopen-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
sink = SmtpSink()
|
||||
sink.start()
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
set_team("user_sue", "user_pat")
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
api(base, "/api/settings", root, "PUT", {
|
||||
"email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
|
||||
"smtp_use_tls": False, "from_addr": "suite@sink.local",
|
||||
"app_base_url": base})
|
||||
|
||||
print("\n2. a released package, its critical constraint reopened")
|
||||
body = {
|
||||
"id": "wpCR1", "project_id": "projA", "number": "CR-01",
|
||||
"subject": "energize MCC-4", "status": "In Progress",
|
||||
"assignee_id": "user_mix",
|
||||
"data": {"location": "B-100 / Level 2",
|
||||
"constraints": [{"name": "Power shutdown", "status": "cleared",
|
||||
"critical": True, "comment": ""}]}}
|
||||
code, _ = api(base, "/api/wps", root, "POST", body)
|
||||
chk("the package saves", code == 200, code)
|
||||
# The creation enqueues a wp_assigned mail delivered by a background
|
||||
# task; wait for it BEFORE clearing or it leaks into the reopen count.
|
||||
wait_for(lambda: len(sink.messages) >= 1, 10)
|
||||
code, _ = api(base, "/api/wps/wpCR1/status", root, "POST", {"status": "Issued"})
|
||||
chk("...and releases (the critical constraint is cleared)", code == 200, code)
|
||||
sink.messages.clear()
|
||||
|
||||
body["status"] = "Issued"
|
||||
body["data"]["constraints"][0]["status"] = "open"
|
||||
code, wp = api(base, "/api/wps", root, "POST", body)
|
||||
chk("reopening the critical constraint saves through the normal upsert",
|
||||
code == 200, code)
|
||||
|
||||
print("\n3. the mail reaches everyone the docstring promises")
|
||||
chk("three messages: assignee + PM + CM (the actor is excluded)",
|
||||
wait_for(lambda: len(sink.messages) == 3, 15), len(sink.messages))
|
||||
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||
chk("...the PM and CM are among them - THE BL-021 fix, sink-verified",
|
||||
"sue@example.test" in rcpts and "pat@example.test" in rcpts, ascii_(rcpts))
|
||||
chk("...and the assignee, exactly them, nobody twice",
|
||||
rcpts == ["mix@example.test", "pat@example.test", "sue@example.test"],
|
||||
ascii_(rcpts))
|
||||
text = next((m["text"] for m in sink.messages if "On hold:" in m["data"]), "")
|
||||
chk("the body names the constraint and the package, title included (CR-014)",
|
||||
"Power shutdown" in text and "CR-01" in text and "energize MCC-4" in text,
|
||||
ascii_(text, 300))
|
||||
chk("...and where the work happens, and a deep link to THAT package",
|
||||
"B-100 / Level 2" in text
|
||||
and "/wp-creation-index.html?project=projA&wp=wpCR1" in text, ascii_(text, 300))
|
||||
chk("...in the house convention (the footer this body alone used to lack)",
|
||||
"automated message from the Work Package Suite" in text)
|
||||
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=wpCR1"
|
||||
"&action=constraint_reopened", root)
|
||||
chk("the reopen is in the audit history", bool(ev), ascii_(ev[:1] if ev else ev))
|
||||
finally:
|
||||
sink.stop()
|
||||
if server:
|
||||
server.terminate()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -190,7 +190,10 @@ def run(page, base, tok, db_path):
|
||||
spans = len(re.findall(r"<span[^>]*onclick", code))
|
||||
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
||||
% spans)
|
||||
chk("...and the only one left in this file is the dashboard's", spans == 1, spans)
|
||||
# T9.5 converted the dashboard chip to a button, so the count is 0 now -
|
||||
# pinned there, because a new span-with-onclick would be a C1 regression.
|
||||
chk("...and none is left in this file at all (the chip became a button at T9.5)",
|
||||
spans == 0, spans)
|
||||
|
||||
# ── 2. the rail ──────────────────────────────────────────────────────────
|
||||
print("\n2. the rail is built from the cards")
|
||||
|
||||
@@ -366,13 +366,13 @@ def measurement(page, base, tok):
|
||||
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
|
||||
if over["scroll"] > over["client"] + 2:
|
||||
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
|
||||
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item
|
||||
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
|
||||
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
|
||||
# out again - which is the point of pinning rather than printing.
|
||||
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
|
||||
"fails when it is fixed",
|
||||
over["scroll"] > over["client"] + 2, over)
|
||||
# CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
|
||||
# cause was actually removed: the help-tip's CSS ::after escaped its badge to
|
||||
# the right, and rebuilding the component (S8) with a viewport-clamped bubble
|
||||
# ended the overflow. The check now asserts the FIX, so a regression reopens
|
||||
# BL-001 loudly instead of quietly re-widening the page.
|
||||
chk("BL-001 is closed: the creator does not overflow at 390px",
|
||||
over["scroll"] <= over["client"] + 2, over)
|
||||
|
||||
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
|
||||
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,
|
||||
|
||||
196
tests/helptip_check.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is every help-tip reachable by keyboard and by touch? — C1 + S8, T9.5.
|
||||
|
||||
The badges were <span> elements whose :focus CSS was dead code (no tabindex)
|
||||
and whose touch path did not exist - on tablets, Field View's surface. Now the
|
||||
component upgrades every badge to a button at load, and one viewport-clamped
|
||||
role=tooltip bubble serves them all. This probe drives a badge with REAL key
|
||||
events and a tap at 390px, then greps the app-wide metrics the audit document
|
||||
cites.
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
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
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
def ascii_(v, n=280):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.5):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def strip_js(src):
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
# ── 1. the greps the audit cites ──────────────────────────────────────────
|
||||
print("\n1. the audit's grep metrics")
|
||||
divspan = 0
|
||||
outline_bad = []
|
||||
for name in sorted(os.listdir(HTML)):
|
||||
if not name.endswith((".html", ".js", ".css")):
|
||||
continue
|
||||
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
|
||||
code = strip_js(src) if not name.endswith(".css") else re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
divspan += len(re.findall(r"<(div|span)[^>]*\bonclick=", code))
|
||||
for m in re.finditer(r"outline:\s*none", code):
|
||||
# The replacement can sit in the rule ABOVE (wp-chrome's search shell
|
||||
# rings on :focus-within, and ringing input + shell would draw two),
|
||||
# so the window looks both ways.
|
||||
ctx = code[max(0, m.start() - 400):m.start() + 260]
|
||||
tail = ctx[ctx.find("outline:") + 12:]
|
||||
if ("outline" not in tail and "box-shadow" not in ctx
|
||||
and "focus-within" not in ctx and "border" not in tail):
|
||||
outline_bad.append(name)
|
||||
chk("div/span click handlers app-wide: 0 (baseline 12/2)", divspan == 0, divspan)
|
||||
chk("outline:none without a replacement: 0", not outline_bad, outline_bad[:4])
|
||||
# The glossary pill classes are injected app-wide and MUST stay scoped:
|
||||
# a bare .pill-hold painted the creator's Issue (hold) status radio
|
||||
# error-red at all times (found 2026-08-20).
|
||||
help_src = io.open(os.path.join(HTML, "help.js"), encoding="utf-8").read()
|
||||
bare = re.findall(r"(?<!\.ui-help-pill)\.pill-[a-z]+(?=\s*\{)", help_src)
|
||||
chk("help.js pill classes are scoped to .ui-help-pill (no bare .pill-*)",
|
||||
not bare, bare[:4])
|
||||
chk("the audit document exists with the per-page table",
|
||||
"## Per-page results" in io.open(os.path.join(ROOT, "docs", "reference",
|
||||
"accessibility-audit.md"), encoding="utf-8").read())
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-tip-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.viewport(390, 844, mobile=True)
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
chk("the creator boots at 390px", wait_creator(page))
|
||||
settle(1.8)
|
||||
|
||||
# ── 2. every badge is a real button ──────────────────────────────────
|
||||
print("\n2. the component, upgraded")
|
||||
counts = json.loads(page.eval("""JSON.stringify({
|
||||
total: document.querySelectorAll('.help-tip').length,
|
||||
buttons: document.querySelectorAll('button.help-tip').length,
|
||||
spans: document.querySelectorAll('span.help-tip').length,
|
||||
})"""))
|
||||
chk("every help-tip on the page is a <button>; zero spans remain",
|
||||
counts["total"] > 0 and counts["spans"] == 0
|
||||
and counts["buttons"] == counts["total"], ascii_(counts))
|
||||
chk("...with an accessible name and a declared state",
|
||||
page.eval("""[...document.querySelectorAll('button.help-tip')]
|
||||
.every(b => b.getAttribute('aria-label') && b.getAttribute('aria-expanded') !== null)"""))
|
||||
|
||||
# ── 3. keyboard ───────────────────────────────────────────────────────
|
||||
print("\n3. keyboard")
|
||||
# Programmatic focus() fires no focusin unless the document HAS focus -
|
||||
# the exact trap form_structure_check documents. Emulate it, loudly.
|
||||
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
||||
chk("focus emulation is on, so a focus reading means something",
|
||||
page.eval("document.hasFocus()") is True)
|
||||
page.eval("""(() => {
|
||||
const b = [...document.querySelectorAll('button.help-tip')]
|
||||
.find(x => x.offsetParent !== null) || document.querySelector('button.help-tip');
|
||||
b.scrollIntoView({block:'center'}); b.focus();
|
||||
})()""")
|
||||
settle(0.4)
|
||||
chk("focusing a badge shows the tooltip",
|
||||
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
|
||||
and page.eval("(document.getElementById('wp-tip-bubble')||{}).textContent.length > 0"))
|
||||
chk("...as a role=tooltip the badge points at",
|
||||
page.eval("document.getElementById('wp-tip-bubble').getAttribute('role')") == "tooltip"
|
||||
and page.eval("document.activeElement.getAttribute('aria-describedby')") == "wp-tip-bubble")
|
||||
bubble = json.loads(page.eval("""JSON.stringify((() => {
|
||||
const r = document.getElementById('wp-tip-bubble').getBoundingClientRect();
|
||||
return {left: r.left, right: r.right};
|
||||
})())"""))
|
||||
chk("390px: the bubble is CLAMPED to the viewport (BL-001's cause, dead)",
|
||||
bubble["left"] >= 0 and bubble["right"] <= 390, ascii_(bubble))
|
||||
|
||||
# ── 4. touch ──────────────────────────────────────────────────────────
|
||||
print("\n4. touch")
|
||||
page.eval("document.activeElement.blur()")
|
||||
settle(0.3)
|
||||
page.eval("""(() => {
|
||||
const b = [...document.querySelectorAll('button.help-tip')]
|
||||
.find(x => x.offsetParent !== null);
|
||||
b.click();
|
||||
})()""")
|
||||
settle(0.4)
|
||||
chk("tapping a badge opens the tooltip and says so with aria-expanded",
|
||||
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
|
||||
and page.eval("!!document.querySelector(%s)"
|
||||
% json.dumps('button.help-tip[aria-expanded="true"]')))
|
||||
page.eval("document.body.click()")
|
||||
settle(0.3)
|
||||
chk("tapping elsewhere closes it",
|
||||
page.eval("!document.getElementById('wp-tip-bubble') || document.getElementById('wp-tip-bubble').hidden"))
|
||||
|
||||
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||
chk("no JavaScript errors anywhere in this run", not js_errors,
|
||||
ascii_(js_errors[:2]))
|
||||
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -460,8 +460,12 @@ def main():
|
||||
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
|
||||
bad = []
|
||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
# wp.status specifically - T8.3's coalescer sets a NOTIFICATION
|
||||
# row's .status (held.status = "pending"), which is outbox state,
|
||||
# not a release transition.
|
||||
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Attribute) and t.attr == "status"
|
||||
and isinstance(t.value, ast.Name) and t.value.id == "wp"
|
||||
for t in n.targets)]
|
||||
if not assigns:
|
||||
continue
|
||||
|
||||
91
tests/icon_check.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is there one icon system, with one meaning per glyph? — S6, T9.3.
|
||||
|
||||
The set mixed emoji and dingbats, and at least one glyph carried two meanings.
|
||||
Now: monochrome text-presentation glyphs only, mapped one-to-one in
|
||||
docs/reference/tokens.md. Emoji render as per-platform colour artwork - which
|
||||
is WHY the same glyph read as two things - so the enforceable form of "renders
|
||||
identically on Windows, macOS and a tablet" is: no emoji-range codepoint and
|
||||
no U+FE0F emoji-presentation selector anywhere in the UI source.
|
||||
|
||||
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from browser_check import chk, _PASS, _FAIL # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
# The approved system, verbatim from docs/reference/tokens.md.
|
||||
APPROVED = {
|
||||
0x2713, 0x2715, 0x26A0, 0x2298, 0x270E, 0x21BA, 0x21BB, 0x2699,
|
||||
0x2913, 0x2912, 0x25C6, 0x25B8, 0x24D8, 0x2190, 0x2039, 0x203A,
|
||||
0x2192, 0x2193, 0x25D4, 0x25A4, 0x25A6, 0x25A7, 0x283F, 0x25BE,
|
||||
0x2248, 0x2398, 0x29C9, 0x2022, 0x00B7, 0x2298,
|
||||
0x2302, 0x2315, 0x2399, 0x23FB, 0x25B2, 0x25BC, 0x25C9, 0x25F7,
|
||||
0x2630, 0x263A, 0x2692, 0x26BF,
|
||||
}
|
||||
# Emoji territory: anything here in UI source is a system violation.
|
||||
def is_emoji(o):
|
||||
return (0x1F000 <= o <= 0x1FAFF) or o in (0x2705, 0x274C, 0x26D4, 0x2B50,
|
||||
0x26A1, 0xFE0F, 0x2757, 0x2B55)
|
||||
|
||||
|
||||
def main():
|
||||
print("\n1. no emoji, anywhere in the UI")
|
||||
offenders = []
|
||||
glyphs_seen = set()
|
||||
for name in sorted(os.listdir(HTML)):
|
||||
if not name.endswith((".html", ".js")):
|
||||
continue
|
||||
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
|
||||
for ch in set(src):
|
||||
o = ord(ch)
|
||||
if is_emoji(o):
|
||||
offenders.append((name, "U+%04X" % o))
|
||||
if o > 0x2000 and o not in (0x2013, 0x2014, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2026):
|
||||
glyphs_seen.add(o)
|
||||
chk("no emoji-range codepoint and no U+FE0F selector survives in any page",
|
||||
not offenders, offenders[:8])
|
||||
|
||||
print("\n2. the mapping document")
|
||||
tokens = io.open(os.path.join(ROOT, "docs", "reference", "tokens.md"),
|
||||
encoding="utf-8").read()
|
||||
chk("the meaning-to-icon mapping is documented in tokens.md",
|
||||
"## Icons (S6 / T9.3)" in tokens and "U+2713" in tokens and "U+2298" in tokens)
|
||||
body = tokens[tokens.find("## Icons"):]
|
||||
rows = re.findall(r"^\| ([^|]+) \| [^|]+ \| (U\+[0-9A-F]{4}[^|]*) \|", body, re.M)
|
||||
meanings = [r[0].strip() for r in rows]
|
||||
chk("no meaning appears twice in the mapping", len(meanings) == len(set(meanings)),
|
||||
[m for m in meanings if meanings.count(m) > 1])
|
||||
codes = []
|
||||
for _, cp in rows:
|
||||
codes.extend(re.findall(r"U\+([0-9A-F]{4})", cp))
|
||||
chk("no glyph carries two meanings", len(codes) == len(set(codes)),
|
||||
[c for c in codes if codes.count(c) > 1])
|
||||
|
||||
print("\n3. what the pages use is what the document names")
|
||||
# Box-drawing comment art (U+2500-257F) and the A7 locale samples in
|
||||
# wp-format.js are not icons; everything else above U+2200 must be mapped.
|
||||
unmapped = sorted("U+%04X" % o for o in glyphs_seen
|
||||
if o >= 0x2200 and o not in APPROVED
|
||||
and not (0x2500 <= o <= 0x257F)
|
||||
and not (0x4E00 <= o <= 0xD7FF)) # CJK/Hangul: A7 locale data
|
||||
chk("every glyph in use above the punctuation range is in the approved set",
|
||||
not unmapped, unmapped[:10])
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -106,7 +106,7 @@ def main():
|
||||
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||
chk("...exactly them, actor excluded",
|
||||
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
||||
body = sink.messages[0]["data"] if sink.messages else ""
|
||||
body = sink.messages[0]["text"] if sink.messages else ""
|
||||
chk("the mail says old status, new status and who",
|
||||
"In Transit" in body and "Delivered" in body and "Root" in body,
|
||||
ascii_(body, 260))
|
||||
|
||||