Compare commits
46 Commits
b44afa7672
...
feat/ldaps
| Author | SHA1 | Date | |
|---|---|---|---|
| c99666796a | |||
| 60b5b0c1f2 | |||
| c6a100405d | |||
| 25bc5bcd3f | |||
| b97ccd7ad8 | |||
| 3f4cd7ac92 | |||
| 332b74e5de | |||
| eecd724e02 | |||
| 24fff382c8 | |||
| d261024139 | |||
| 79787b3e9f | |||
| e0008670fc | |||
| c47b2ae210 | |||
| dc0cee240e | |||
| 8d49fb9248 | |||
| 495d87dd72 | |||
| c5540ce6da | |||
| 9f91e9e26b | |||
| ab7bce9f5b | |||
| 8cfb4c1008 | |||
| 0de746bc62 | |||
| 2a3c4a83fa | |||
| 0577660c86 | |||
| 3c9343dfc8 | |||
| a8e28bf3ab | |||
| 29c4cd313e | |||
| 8fe7b25cd8 | |||
| 560f0cb3cc | |||
| 24f60151e5 | |||
| 031dc6b995 | |||
| 0f28a27441 | |||
| 16afc56c0a | |||
| 8efe624d5d | |||
| 24476c86a6 | |||
| cc761c8f7d | |||
| fb89b1f6e1 | |||
| 8663d81af3 | |||
| 8cf8c0f882 | |||
| 2a5f6b3549 | |||
| 454bfa0fe1 | |||
| 23ee0b052f | |||
| 771672273d | |||
| 6201fcfb4a | |||
| 0dcea8d725 | |||
| b83f2fd8d5 | |||
| 7ef1fcdd96 |
29
CLAUDE.md
@@ -70,6 +70,31 @@ Adding a raw hex value to a page stylesheet is a defect regardless of what the t
|
|||||||
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
|
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
|
||||||
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
|
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
|
||||||
|
|
||||||
|
## The authentication rules
|
||||||
|
|
||||||
|
Sign-in is an LDAPS bind against the domain (`D13`, `docs/waves/decisions-2026-08-21.md`).
|
||||||
|
Four things about it are load-bearing and look like tidying-up if you do not know why:
|
||||||
|
|
||||||
|
- **The empty-password guard in `ldap_auth.verify` runs before `bind()`.** An LDAP
|
||||||
|
simple bind with an empty password is an *anonymous* bind and it SUCCEEDS. Remove
|
||||||
|
that check and a blank password authenticates as any username submitted. It looks
|
||||||
|
redundant because `login()` checks too. Both stay.
|
||||||
|
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Never `CERT_NONE`, never
|
||||||
|
the system trust store (which trusts five other self-signed CAs on this estate).
|
||||||
|
`CERT_NONE` still encrypts, so it fails silently - what it loses is the ability to
|
||||||
|
tell a real DC from someone harvesting domain passwords.
|
||||||
|
- **`AUTH_MAX_ATTEMPTS` is 2, and that is arithmetic, not taste.** Failures are real
|
||||||
|
domain binds counting against the AD lockout policy (5 here), and 2 workers double
|
||||||
|
it: 2 x 2 = 4 < 5. Raising it, or adding a worker, makes `/api/auth/login` a way to
|
||||||
|
lock colleagues out of Windows.
|
||||||
|
- **Connect to `prime.local`, never a DC name or an IP.** Every DC certificate carries
|
||||||
|
the domain name in its SAN; an IP fails hostname validation, and the only way to
|
||||||
|
force it is to disable the check above.
|
||||||
|
|
||||||
|
There is **no break-glass account** - a misconfiguration locks out everyone including
|
||||||
|
admins. And roles are LOCAL: the directory supplies identity, this app supplies
|
||||||
|
authorization. Never read a role from AD.
|
||||||
|
|
||||||
## Accessibility is in scope
|
## Accessibility is in scope
|
||||||
|
|
||||||
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
||||||
@@ -90,10 +115,12 @@ A task is not done because the code is written. Every task file lists its own do
|
|||||||
checks. In addition, for any task touching the frontend:
|
checks. In addition, for any task touching the frontend:
|
||||||
|
|
||||||
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
|
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
|
||||||
|
Signing in needs a domain credential now (D13) — a local run reaches `prime.local`
|
||||||
|
from the host with no extra configuration. A container needs the `outbound` network.
|
||||||
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
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.
|
gloved-hands surface and is where the worst rendering was found.
|
||||||
3. Capture before and after screenshots into the PR.
|
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). Since D13 both need a **domain** credential, and `WP_SMOKE_PASSWORD` is now a real Windows password - never put one on a command line.
|
||||||
|
|
||||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||||
failed and why.
|
failed and why.
|
||||||
|
|||||||
@@ -1,103 +1,154 @@
|
|||||||
# Deploy: Work Package Suite — login portal update
|
# Deploy: Work Package Suite — domain sign-in (D13)
|
||||||
|
|
||||||
Instructions for the **Portainer admin** to take the new secure login portal live.
|
Instructions for the **Portainer admin** to take domain authentication live.
|
||||||
No prior context needed.
|
No prior context needed.
|
||||||
|
|
||||||
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||||
|
|
||||||
**What changed:** the app now has a username/password login. Going live needs:
|
> **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
|
||||||
1. one new environment variable,
|
> **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
|
||||||
2. a **rebuild** of the stack (not just a restart), and
|
> first admin created with `manage_users create-admin <user> --password …`. All of
|
||||||
3. creating the first admin account.
|
> that is gone. The app no longer stores a password of any kind, `create-admin` no
|
||||||
|
> longer exists, and following the old steps will fail at the first command. The
|
||||||
|
> superseded design is recorded in `docs/waves/decisions-2026-08-21.md` (D13).
|
||||||
|
|
||||||
|
**What changed:** signing in is now an **LDAPS bind against `prime.local`**. People
|
||||||
|
use their **Windows password**. The suite stores no credential, there is no password
|
||||||
|
reset, and accounts create themselves on first sign-in.
|
||||||
|
|
||||||
|
Going live needs:
|
||||||
|
|
||||||
|
1. two environment variables,
|
||||||
|
2. a **rebuild** of the stack (not just a restart),
|
||||||
|
3. one network check, and
|
||||||
|
4. promoting the first admin.
|
||||||
|
|
||||||
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||||
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
> bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
|
||||||
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
> will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
|
||||||
> be **rebuilt** from the latest `main`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Add an environment variable to the stack
|
## ⚠ Read this before you start
|
||||||
|
|
||||||
In the stack's **Environment variables** section, add:
|
**There is no break-glass account.** If the directory is unreachable, the CA bundle
|
||||||
|
path is wrong, or the required group is misconfigured, **nobody can sign in —
|
||||||
|
including you.** That was a deliberate decision, not an oversight. Recovery is to fix
|
||||||
|
the configuration and restart; there is no local password to fall back on.
|
||||||
|
|
||||||
|
So: do step 3 before you tell anyone the deploy is done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Add environment variables to the stack
|
||||||
|
|
||||||
|
In the stack's **Environment variables** section:
|
||||||
|
|
||||||
| Name | Value | Notes |
|
| Name | Value | Notes |
|
||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
|
||||||
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
| `LDAP_REQUIRED_GROUP` | `CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local` | AD group required to sign in. A full DN is best — it skips a directory lookup. Nested groups count. Leave empty to allow any domain account. |
|
||||||
|
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Unchanged. |
|
||||||
|
|
||||||
Generate the secret on the host with:
|
You do **not** need to set `LDAP_HOST`, `LDAP_DOMAIN` or `LDAP_CA_FILE`. Their
|
||||||
|
defaults are correct for this estate, and the CA bundle ships inside the image.
|
||||||
|
|
||||||
```bash
|
**Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
|
||||||
openssl rand -base64 48
|
set to `prime.local` on purpose: every DC's certificate carries that name in its SAN,
|
||||||
```
|
so the domain name both validates and load-balances across all six DCs. An IP fails
|
||||||
|
certificate validation outright, and the only way to force it through is to switch
|
||||||
|
validation off — which would let anyone on the network intercept **domain passwords**.
|
||||||
|
|
||||||
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
|
||||||
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
They configured the password-reset email, which no longer exists.
|
||||||
> workers. It must be set to a fixed value.
|
|
||||||
|
|
||||||
The existing database variables (`POSTGRES_*`) are unchanged.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Pull latest `main`, rebuild, and redeploy
|
## 2. Pull latest `main`, rebuild, and redeploy
|
||||||
|
|
||||||
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
- Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
|
||||||
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
- The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
|
||||||
`api` images.
|
the rebuild.
|
||||||
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
- A database migration drops the `users.password_hash` column. It runs automatically
|
||||||
automatically during the rebuild.
|
at container start. **Every account, role and project membership is preserved** —
|
||||||
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
it removes one column, not any rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Verify the containers
|
## 3. Verify BEFORE announcing it
|
||||||
|
|
||||||
- Confirm `wp_api` and the webserver container are both **running**.
|
**a. Did the API start at all?**
|
||||||
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
|
||||||
warning — it won't crash — but please confirm it's set.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Create the first admin account
|
|
||||||
|
|
||||||
The login system needs one admin user in the production (Postgres) database. Open the
|
|
||||||
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
docker compose logs api | grep -i "LDAP auth"
|
||||||
```
|
```
|
||||||
|
|
||||||
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
You want:
|
||||||
|
|
||||||
Non-interactive alternative:
|
```
|
||||||
|
LDAP auth enabled — ldaps://prime.local:636, domain prime.local, CA /app/server/certs/prime-ca-chain.pem, required group: CN=Prime Employees,…
|
||||||
|
```
|
||||||
|
|
||||||
|
If it says `LDAP auth DISABLED`, stop — nobody will be able to sign in. The message
|
||||||
|
names the reason.
|
||||||
|
|
||||||
|
**b. Can the container actually reach a domain controller?** This opens a TLS session
|
||||||
|
and validates the certificate **without binding**, so it touches no account and
|
||||||
|
cannot contribute to any lockout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
||||||
```
|
```
|
||||||
|
|
||||||
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
|
||||||
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
is missing the `outbound` network — `internal` has no default gateway and blocks the
|
||||||
|
LAN as well as the internet. If you get `62 (hostname mismatch)`, something is
|
||||||
|
pointing at an IP instead of `prime.local`.
|
||||||
|
|
||||||
|
**c. Sign in.** Use your own Windows username and password.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Confirm it works
|
## 4. Promote the first admin
|
||||||
|
|
||||||
1. Load the site's normal URL — it should redirect to a **login page**.
|
Roles are stored locally and are not read from AD, so someone has to be made an admin
|
||||||
2. Sign in with the admin account from step 4.
|
once. Sign in first — that creates your account — then:
|
||||||
3. That admin can then add all other users from the in-app **Admin → User
|
|
||||||
administration** page (top-right **Admin** link), so no further shell access is needed.
|
```bash
|
||||||
|
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
|
||||||
|
```
|
||||||
|
|
||||||
|
It asks for **your** domain username and password, binds to confirm who you are, and
|
||||||
|
prints `<user>: project_user -> admin`.
|
||||||
|
|
||||||
|
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
|
||||||
|
`create-admin`, `create` and `reset-password` no longer exist.
|
||||||
|
|
||||||
|
After that, admins manage everyone else from the in-app **Admin → User
|
||||||
|
administration** page. No further shell access needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What people will notice
|
||||||
|
|
||||||
|
- They sign in with their **Windows password**, not an app password.
|
||||||
|
- **"Forgot password?"** now goes to `https://primecontrols.okta.com/`. The app cannot
|
||||||
|
reset a password it does not hold.
|
||||||
|
- The **Change password** item is gone from the top-right menu.
|
||||||
|
- Anyone in the required group can sign in **without being added first** — their
|
||||||
|
account is created automatically. They will see **no projects** until an admin
|
||||||
|
grants access, which is intentional. New accounts appear in the Admin console and
|
||||||
|
each one is recorded in the audit log.
|
||||||
|
- Two wrong passwords and the app stops trying for a while. That is deliberate: every
|
||||||
|
failed attempt is a real domain bind and counts against the **AD lockout policy**,
|
||||||
|
so the app stops well short of locking anyone out of Windows.
|
||||||
|
|
||||||
## Reference — what's in this release
|
## Reference — what's in this release
|
||||||
|
|
||||||
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
- `server/ldap_auth.py` — the LDAPS client: bind, nested-group check, certificate validation.
|
||||||
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
- `server/app.py` — `login()` binds instead of comparing a hash; password endpoints removed.
|
||||||
route without a valid session.
|
- `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
|
||||||
- `server/manage_users.py` — the CLI used in step 4.
|
- `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
|
||||||
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
- `server/manage_users.py` — `promote` / `demote`, each requiring a domain bind.
|
||||||
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
- `html/login.html`, `login.js` — one view; "Forgot password?" points at Okta.
|
||||||
administration UI.
|
- Migration `b7e4f1a20c93` — drops `users.password_hash`.
|
||||||
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
|
||||||
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
|
||||||
|
|||||||
207
DEPLOYMENT.md
@@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
|||||||
# details.
|
# details.
|
||||||
# SMTP_PASSWORD=<smtp-app-password>
|
# SMTP_PASSWORD=<smtp-app-password>
|
||||||
|
|
||||||
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
|
# OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
|
||||||
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
|
# nested groups count. Empty means any domain account may sign in. This is the
|
||||||
# AUTH_RESET_MINUTES=60
|
# initial value; the live one is set in the Admin console.
|
||||||
# AUTH_RESET_COOLDOWN_SECONDS=120
|
# LDAP_REQUIRED_GROUP=CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local
|
||||||
|
#
|
||||||
|
# OPTIONAL — the rest of the directory settings. The defaults are correct for this
|
||||||
|
# estate and you should not normally set them. NEVER point LDAP_HOST at a DC name
|
||||||
|
# or an IP: see § Domain authentication below.
|
||||||
|
# LDAP_DOMAIN=prime.local
|
||||||
|
# LDAP_HOST=prime.local
|
||||||
|
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
The API builds its own DB connection string from the `POSTGRES_*`
|
The API builds its own DB connection string from the `POSTGRES_*`
|
||||||
@@ -84,13 +91,34 @@ ignored whenever the three `POSTGRES_*` values are present.
|
|||||||
|
|
||||||
Generate a strong password with `openssl rand -base64 32`.
|
Generate a strong password with `openssl rand -base64 32`.
|
||||||
|
|
||||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
> **Portainer note:** for a Git-based stack the stack's **Environment variables**
|
||||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
> section is not merely an alternative to `.env` — it is the ONLY route, because
|
||||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
> Portainer does not read a local `.env` at all. Every value the compose file
|
||||||
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
> references as `${VAR}` has to be set there or it arrives empty.
|
||||||
|
>
|
||||||
|
> The full list, and what an empty one costs you:
|
||||||
|
>
|
||||||
|
> | Variable | Required? | If unset |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | **yes** | the stack will not start |
|
||||||
|
> | `AUTH_SECRET_KEY` | **yes** | compose fails fast; the API refuses to start |
|
||||||
|
> | `LDAP_REQUIRED_GROUP` | **effectively yes** | no group gate — **every account in the domain may sign in**. Silent: sign-in works, so nothing looks wrong. |
|
||||||
|
> | `BACKUP_ENC_PASSPHRASE` | before real data | dumps are written unencrypted |
|
||||||
|
> | `SMTP_PASSWORD` | only with email on | notifications are recorded and never sent |
|
||||||
|
> | `MICRON_DB_URL` | optional | the asset picker degrades to manual entry |
|
||||||
|
>
|
||||||
|
> Paste values raw — it is a form field, not a shell, so no surrounding quotes.
|
||||||
|
> Quotes are not stripped and become part of the value: a quoted
|
||||||
|
> `LDAP_REQUIRED_GROUP` will not resolve, and a quoted `MICRON_DB_URL` will not
|
||||||
|
> parse.
|
||||||
|
>
|
||||||
|
> **`MICRON_DB_URL` must be URL-encoded** (`@` → `%40`, `#` → `%23`, `/` → `%2F`)
|
||||||
|
> because it is a full connection URL. `LDAP_REQUIRED_GROUP` must NOT be encoded —
|
||||||
|
> it is an LDAP distinguished name, and its spaces and commas are legal as they are.
|
||||||
|
|
||||||
These are the only credentials in the system, and they never appear in the
|
`POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
|
||||||
compose file or in git.
|
the password inside `MICRON_DB_URL` are the only credentials in the system, and none
|
||||||
|
of them appears in the compose file or in git.
|
||||||
|
|
||||||
## 3. Point your reverse proxy at the nginx container
|
## 3. Point your reverse proxy at the nginx container
|
||||||
|
|
||||||
@@ -257,7 +285,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) |
|
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `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` |
|
| `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 (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
||||||
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
| `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` |
|
| `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) |
|
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||||
@@ -296,6 +324,56 @@ docker compose up -d --build webserver # front-end change (html/) — rebuild
|
|||||||
docker compose up -d --build api # backend change (server/)
|
docker compose up -d --build api # backend change (server/)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Before deploying the D13 auth change — take a backup first
|
||||||
|
|
||||||
|
**Timing is the whole point.** The container's start command is
|
||||||
|
`alembic upgrade head && exec gunicorn`, so migrations run *seconds after you
|
||||||
|
redeploy*. Migration `b7e4f1a20c93` drops `users.password_hash`. There is no window
|
||||||
|
afterwards: back up **before** you redeploy, not after.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Record what you are rolling back TO. Do this first; it is easy to forget
|
||||||
|
# and impossible to reconstruct under pressure.
|
||||||
|
git -C /path/to/repo rev-parse --short HEAD
|
||||||
|
|
||||||
|
# 2. One-shot dump, using the sidecar that is already running.
|
||||||
|
docker compose exec backup sh /scripts/db-backup.sh
|
||||||
|
# -> ./backups/wpsuite-<UTC timestamp>.sql.gz[.enc] on the host
|
||||||
|
|
||||||
|
# 3. Take it OUT of the rotation. The scheduled job prunes to the newest
|
||||||
|
# BACKUP_KEEP (default 14) files matching wpsuite-*.sql.gz*, so on a daily
|
||||||
|
# cadence this dump is deleted in a fortnight. A prefix that does not match
|
||||||
|
# the glob is enough to protect it.
|
||||||
|
docker compose exec backup sh -c 'cd /backups && cp "$(ls -1t wpsuite-*.sql.gz* | head -1)" "pre-d13-$(ls -1t wpsuite-*.sql.gz* | head -1)"'
|
||||||
|
|
||||||
|
# 4. Prove it is readable BEFORE you deploy. An untested dump is not a backup.
|
||||||
|
docker compose exec backup sh -c 'openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in /backups/pre-d13-*.sql.gz.enc | gunzip -c | grep -c "INSERT INTO public.users"'
|
||||||
|
# (drop the openssl stage for an unencrypted .sql.gz)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two things about this particular dump
|
||||||
|
|
||||||
|
**It is the last copy of every password hash that will ever exist.** After the
|
||||||
|
migration the column is gone; this file is where those bcrypt hashes live from then
|
||||||
|
on. Make sure `BACKUP_ENC_PASSPHRASE` is set before step 2 — the script warns loudly
|
||||||
|
if it is not, and writes plaintext — and decide deliberately how long to keep the
|
||||||
|
file. bcrypt is not plaintext, but it is crackable offline given time and a copy.
|
||||||
|
|
||||||
|
**Restoring the database is not, by itself, a rollback.** The new code has no
|
||||||
|
`password_hash` in its model and the old code requires it, so a restore without a
|
||||||
|
matching code rollback leaves you with a schema and an application that disagree. A
|
||||||
|
real rollback is both, in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# redeploy the commit from step 1 (Portainer: point the stack back and rebuild)
|
||||||
|
docker compose exec backup sh /scripts/db-restore.sh /backups/pre-d13-wpsuite-<ts>.sql.gz.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
`db-restore.sh` dumps are taken with `--clean --if-exists`, so restoring **drops and
|
||||||
|
recreates** objects before loading. It overwrites whatever is currently there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Backups & retention
|
## Backups & retention
|
||||||
|
|
||||||
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||||
@@ -350,27 +428,96 @@ TLS / From address and flips the master toggle.
|
|||||||
package contents — so customer IP stays behind the login.
|
package contents — so customer IP stays behind the login.
|
||||||
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||||
|
|
||||||
### Self-service password reset
|
### Password reset — there isn't one
|
||||||
|
|
||||||
Turning email on also enables **Forgot password** on the login page. Until then the
|
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
|
||||||
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
|
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
|
||||||
console's **Reset password** button).
|
is the only self-service route; the app cannot reset a credential it does not hold.
|
||||||
|
|
||||||
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
|
Email still carries WP-assignment notifications and the critical-reopen mail.
|
||||||
(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
|
## Domain authentication (D13)
|
||||||
can't be used to discover usernames. Misses are recorded in the audit log
|
|
||||||
(`password_reset_miss`) instead.
|
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
|
||||||
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
|
password in the database and **no break-glass account**. If the domain is
|
||||||
so the form can't be used to flood someone's inbox. The throttle is per worker
|
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
||||||
and in-memory; the token expiry is the real control.
|
**nobody can sign in, including admins.**
|
||||||
- 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
|
**First thing to check on any sign-in problem** — the API logs one line at startup
|
||||||
account.
|
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
|
||||||
- Set `app_base_url` in the admin card, or the emailed link will be relative and
|
stack is diagnosable while nobody can log in:
|
||||||
therefore useless.
|
|
||||||
|
```bash
|
||||||
|
docker compose logs api | grep -i "LDAP auth"
|
||||||
|
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
|
||||||
|
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
|
||||||
|
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then prove the certificate path, without binding — this touches no account and so
|
||||||
|
cannot contribute to a lockout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
||||||
|
# want: Verify return code: 0 (ok)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three things that are not obvious
|
||||||
|
|
||||||
|
**Connect to the domain name, never a DC or an IP.** Every DC's certificate carries
|
||||||
|
`prime.local` in its SAN, so the domain name both passes hostname validation and
|
||||||
|
round-robins across all six DCs published in `_ldap._tcp.prime.local`. An IP gives
|
||||||
|
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
|
||||||
|
only way to force it through is to disable validation. Do not. Domain passwords
|
||||||
|
cross this link, and an unvalidated one can be terminated by anyone on the network
|
||||||
|
who then harvests them.
|
||||||
|
|
||||||
|
**The CA bundle is not a certificate issued to this app.** The API is the TLS
|
||||||
|
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
|
||||||
|
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
|
||||||
|
(2036) — public certificates with no private key. There is nothing to request from
|
||||||
|
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
|
||||||
|
Where-Object { $_.Thumbprint -in
|
||||||
|
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
|
||||||
|
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
|
||||||
|
```
|
||||||
|
|
||||||
|
exporting each as Base-64 and concatenating them into one file.
|
||||||
|
|
||||||
|
**The `outbound` network is required.** `internal` has no default gateway, which
|
||||||
|
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
|
||||||
|
reach `prime.local:636` without it. Its comment used to say it was optional if you
|
||||||
|
were not using the Micron asset picker; detaching it now breaks every sign-in.
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
|
||||||
|
Accounts are **created on first successful sign-in**, at `project_user` with **no
|
||||||
|
project access** — the person signs in and sees nothing until an admin grants it.
|
||||||
|
Roles are local and never read from AD, so an existing admin keeps admin.
|
||||||
|
|
||||||
|
The first admin is bootstrapped in two steps: sign in once, then
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api python -m server.manage_users promote <sAMAccountName>
|
||||||
|
```
|
||||||
|
|
||||||
|
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
|
||||||
|
are the other commands; `create-admin` and `create` no longer exist.
|
||||||
|
|
||||||
|
### The lockout arithmetic
|
||||||
|
|
||||||
|
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
|
||||||
|
preference. Failures are now domain binds, so they count against the **AD account
|
||||||
|
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
|
||||||
|
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
|
||||||
|
one under the threshold. **Raising this, or adding a worker, means redoing that
|
||||||
|
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
|
||||||
|
to lock a colleague out of Windows.
|
||||||
|
|
||||||
## Permissions roles
|
## Permissions roles
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ be built as written, or cannot be built once, until something else lands.
|
|||||||
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
|
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
|
||||||
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
|
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
|
||||||
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
|
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
|
||||||
|
| 10 | Domain authentication over LDAPS | `docs/waves/wave-10.md` | `D13` |
|
||||||
|
|
||||||
|
Wave 10 was added on August 21, 2026 and is not part of the original nine-wave sequence. It
|
||||||
|
is new scope (`docs/waves/decisions-2026-08-21.md`), not a reinterpretation of anything
|
||||||
|
above, and it depends only on wave 9 being merged rather than on any particular item in it.
|
||||||
|
|
||||||
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
|
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
|
||||||
roughly the first third of the effort. It is called out here because the Micron team is
|
roughly the first third of the effort. It is called out here because the Micron team is
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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
|
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
|
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
||||||
app bar — and therefore the banner — is deliberately skipped.
|
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.
|
||||||
|
|||||||
@@ -35,12 +35,39 @@ services:
|
|||||||
# default and enabled from the Admin console; this is the only email
|
# 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.
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
|
||||||
|
# has no local fallback, so a wrong value here means nobody can sign in.
|
||||||
|
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
|
||||||
|
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
|
||||||
|
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
|
||||||
|
LDAP_HOST: ${LDAP_HOST:-prime.local}
|
||||||
|
# Trust anchor for the DC certificate — public CA certs, baked into the image
|
||||||
|
# at server/certs/. Override only to point at a mounted bundle.
|
||||||
|
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
|
||||||
|
# AD group required to sign in. Empty = any domain account. This is the
|
||||||
|
# initial value; the live one is set in the Admin console (T10.5).
|
||||||
|
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
|
||||||
|
# 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
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy # waits for postgres to accept connections
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
# Reaching the Micron database — and, since D13, the domain controllers —
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
|
||||||
|
# the Micron asset picker"), but authentication now needs a route to
|
||||||
|
# prime.local:636. Without it every sign-in fails and there is no local
|
||||||
|
# password fallback to fall back to.
|
||||||
|
- outbound
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -98,4 +125,12 @@ networks:
|
|||||||
name: proxy
|
name: proxy
|
||||||
external: true
|
external: true
|
||||||
internal:
|
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 domain controllers (LDAPS, D13) and the Micron asset database.
|
||||||
|
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
|
||||||
|
# still have no way off the host. Required — see the note on the api service.
|
||||||
|
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 |
|
||||||
BIN
docs/reference/baseline/after-wave10/login-1440.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/reference/baseline/after-wave10/login-390.png
Normal file
|
After Width: | Height: | Size: 21 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 |
BIN
docs/reference/baseline/before-wave10/login-1440.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/reference/baseline/before-wave10/login-390.png
Normal file
|
After Width: | Height: | Size: 19 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:
|
anything that already existed:
|
||||||
|
|
||||||
```bash
|
```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/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/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
|
||||||
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
|
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
|
||||||
@@ -280,7 +280,7 @@ 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/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/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/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/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/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
|
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
|
||||||
@@ -301,6 +301,19 @@ Wave 9 adds these:
|
|||||||
```bash
|
```bash
|
||||||
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
|
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/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
|
**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
|
**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
|
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.
|
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
|
## 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
|
- **Found during:** T0.2
|
||||||
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
|
- **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
|
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
|
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
|
||||||
cannot be closed by silence.
|
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
|
### 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;
|
- **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets;
|
||||||
`C1`'s audit at `T9.5` confirms it app-wide.
|
`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
|
- **Found during:** T3.1
|
||||||
- **Where:** `html/help.js:79` (the injected `<style>`)
|
- **Where:** `html/help.js:79` (the injected `<style>`)
|
||||||
@@ -165,8 +172,9 @@ deliberately deferred.
|
|||||||
work, not a non-issue.
|
work, not a non-issue.
|
||||||
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
|
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
|
||||||
`docs/reference/tokens.md` §1.
|
`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
|
- **Found during:** T3.1
|
||||||
- **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150`
|
- **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
|
- **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`.
|
four-sheet surface. Both dialogs are rebuilt as accessible components under `C1`.
|
||||||
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
|
- **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
|
### 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
|
it in four waves, and it is measured every run now instead of once. Carried to
|
||||||
`T7.2`.
|
`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
|
- **Found during:** T3.1
|
||||||
- **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257`
|
- **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
|
- **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same
|
||||||
conversation as the green action buttons.
|
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.
|
- **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
|
- **Found during:** T3.2
|
||||||
- **Where:** `html/field.html:35` (`.pill.warn`)
|
- **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
|
- **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.
|
`--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.
|
- **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
|
### 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
|
- **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.
|
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
|
- **Found during:** T3.2
|
||||||
- **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav`
|
- **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
|
- **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.
|
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`.
|
- **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
|
- **Found during:** T3.2
|
||||||
- **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440`
|
- **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.
|
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
|
- **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.
|
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
|
### 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.
|
- **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.
|
- **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
|
- **Found during:** T5.1
|
||||||
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
|
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
|
||||||
@@ -340,6 +353,7 @@ deliberately deferred.
|
|||||||
unreviewable.
|
unreviewable.
|
||||||
- **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the
|
- **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.
|
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
|
### 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
|
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
|
||||||
target against the stripped figure.
|
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
|
- **Found during:** T5.3
|
||||||
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
|
- **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
|
**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
|
copy, so the workaround is in one place and disappears when the fixture is
|
||||||
fixed. Four probes is enough evidence: `T9.9` owns it.
|
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
|
- **Found during:** T5.6
|
||||||
- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by
|
- **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.
|
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
|
- **Suggested wave or follow-up:** wave 9. The fix is the four lines already written for
|
||||||
`gov_wosize`.
|
`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
|
### 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
|
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.
|
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
|
- **Found during:** T7.1
|
||||||
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
|
- **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
|
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
|
||||||
the prompt stops being a surprise.
|
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
|
- **Found during:** T7.6
|
||||||
- **Where:** `server/app.py`, `project_sop_team()`
|
- **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
|
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
|
||||||
the `tests/qa_gate_check.py` sink pattern.
|
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
|
- **Found during:** T7.2, re-measured at the wave 7 exit
|
||||||
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
|
- **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
|
becomes a small T9 task. The strict check stays red so the question cannot be
|
||||||
forgotten.
|
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)
|
- **Found during:** T9.2 (logged as that task's done-when requires)
|
||||||
- **Where:** future — dashboard / rollups
|
- **Where:** future — dashboard / rollups
|
||||||
@@ -502,8 +518,134 @@ deliberately deferred.
|
|||||||
hours exist on every package; nothing yet compares them. A productivity
|
hours exist on every package; nothing yet compares them. A productivity
|
||||||
factor (actual ÷ estimated, rolled up by discipline / building / type the way
|
factor (actual ÷ estimated, rolled up by discipline / building / type the way
|
||||||
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
|
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
|
||||||
The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`)
|
`/api/wps/metrics` already carries both sums, so this is a presentation
|
||||||
already carry both sums, so this is a presentation task, not a data one.
|
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
|
- **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.
|
a product conversation about where it displays and who reads it.
|
||||||
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
- **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 — CLOSED 2026-08-21 (removed; nothing referenced it)
|
||||||
|
|
||||||
|
- **Found during:** `T10.3` (D13), stripping the password code paths
|
||||||
|
- **Where:** `server/notify.py`, `send_now()`
|
||||||
|
- **What:** `send_now` sends one message immediately, outside the outbox queue. Its
|
||||||
|
only caller was `forgot_password`, because a reset link must not sit in a queue.
|
||||||
|
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
|
||||||
|
`server/` or `tests/` — verified by grep, not assumed.
|
||||||
|
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
|
||||||
|
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
|
||||||
|
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
|
||||||
|
in terms of password resets, which no longer exist. Keeping an unused sender that
|
||||||
|
bypasses the outbox is a liability, not an asset: the next person to need immediate
|
||||||
|
mail should write it against the requirement they actually have.
|
||||||
|
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
|
||||||
|
the outbox.
|
||||||
|
|
||||||
|
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind
|
||||||
|
|
||||||
|
- **Found during:** `T10.6` (D13), repointing "Forgot password?" at
|
||||||
|
`https://primecontrols.okta.com/`
|
||||||
|
- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
|
||||||
|
- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
|
||||||
|
runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
|
||||||
|
Windows password is still the one that binds), but its existence means an OIDC
|
||||||
|
authorization-code flow is available in principle. That would be strictly better on
|
||||||
|
three counts the LDAPS design cannot match: this app would never see a password at all,
|
||||||
|
MFA would come for free, and the domain-lockout hazard that forced
|
||||||
|
`AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
|
||||||
|
land on Okta rather than on a bind this endpoint makes.
|
||||||
|
- **Why not now:** D13 was decided and reaffirmed, T10.1–T10.4 are built and verified
|
||||||
|
against the live domain, and swapping the mechanism mid-wave is exactly the reordering
|
||||||
|
`CLAUDE.md` forbids. Recording it is not the same as reopening it.
|
||||||
|
- **Suggested wave or follow-up:** its own item and its own decision, with Nick and
|
||||||
|
whoever administers the Okta tenant. Not a widening of D13.
|
||||||
|
|
||||||
|
### BL-028 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
|
||||||
|
|
||||||
|
- **Found during:** `T10.7` (D13), running the full suite
|
||||||
|
- **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
|
||||||
|
- **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
|
||||||
|
is not configured, but `start_server` passes the ambient environment through. On a
|
||||||
|
developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
|
||||||
|
anyone who has ever used the asset picker — the API is genuinely configured, returns
|
||||||
|
real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
|
||||||
|
premise is violated by the environment it runs in.
|
||||||
|
- **Fix:** SET `MICRON_DB_URL` empty for that server — do not pop it. `server/db.py`
|
||||||
|
calls `load_dotenv()` at import, and python-dotenv only skips keys already present in
|
||||||
|
`os.environ`, so a *popped* variable is restored from the developer's `.env` inside the
|
||||||
|
subprocess and the test runs against the real catalog anyway. An empty string counts as
|
||||||
|
present and therefore wins. `start_server` does exactly this for `LDAP_REQUIRED_GROUP`
|
||||||
|
(`T10.7`), after the pop-based version was caught doing the wrong thing.
|
||||||
|
- **Why not now:** it is not this wave's defect and the fix belongs with whoever owns
|
||||||
|
the asset picker's tests. Recorded so the failure is not mistaken for D13 fallout.
|
||||||
|
- **Suggested wave or follow-up:** next housekeeping pass.
|
||||||
|
|
||||||
|
### BL-029 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
|
||||||
|
|
||||||
|
- **Found during:** `T10.7` (D13), running the full suite
|
||||||
|
- **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
|
||||||
|
- **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
|
||||||
|
to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
|
||||||
|
last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
|
||||||
|
by the LDAPS branch.
|
||||||
|
- **The real question is which is wrong.** `C4`'s recorded exception allows rgba
|
||||||
|
**alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
|
||||||
|
too strict and should match a colour literal rather than the `rgba(` token. If not,
|
||||||
|
the shadow needs a token. Either way it is a one-line change plus a decision, and
|
||||||
|
the decision is not this wave's to make.
|
||||||
|
- **Why not now:** drive-by fixes to the token system are what `CLAUDE.md` forbids, and
|
||||||
|
this one needs the C4 exception interpreted rather than guessed.
|
||||||
|
- **Suggested wave or follow-up:** next housekeeping pass, with `C4` re-read first.
|
||||||
|
|
||||||
|
### BL-030 — `token_version` is now near-vestigial; decide whether it earns its place
|
||||||
|
|
||||||
|
- **Found during:** `T10.7` (D13), closing the done-when that assumed a role change bumps it
|
||||||
|
- **Where:** `server/models.py` (`User.token_version`), `server/auth.py` (`create_token`,
|
||||||
|
`get_current_user`), `server/manage_users.py` (`_set_active`)
|
||||||
|
- **What:** `token_version` existed to invalidate live sessions when a password changed.
|
||||||
|
D13 removed passwords, and **nothing in `app.py` bumps it any more** — not
|
||||||
|
`set_user_role`, not `set_user_active`. Nor do they need to: `get_current_user` loads
|
||||||
|
the account from the database on every request, so a role change or a deactivation
|
||||||
|
takes effect on the next request regardless. `T10.3`'s note that "role changes and
|
||||||
|
deactivation should bump it" describes an intention, not the code.
|
||||||
|
- **Its one remaining trigger** is the bump added to `manage_users._set_active` in
|
||||||
|
`T10.9`, which is belt-and-braces rather than load-bearing — `is_active` alone already
|
||||||
|
refuses the request.
|
||||||
|
- **The question:** is there still a case for invalidating a live cookie *without* also
|
||||||
|
disabling the account? If yes, wire it to something (a "sign out everywhere" control is
|
||||||
|
the usual shape) and say so. If no, the column, the claim, and the check are three
|
||||||
|
places carrying a mechanism nothing triggers.
|
||||||
|
- **Why not now:** it is a design question about session handling, not an auth-wave bug,
|
||||||
|
and the mechanism works correctly — it is exercised in `ldap_auth_check`.
|
||||||
|
- **Suggested wave or follow-up:** next housekeeping pass.
|
||||||
|
|||||||
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`.
|
||||||
210
docs/waves/decisions-2026-08-21.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Decisions — August 21, 2026
|
||||||
|
|
||||||
|
One item, and it is the largest single change to the auth model since the login portal
|
||||||
|
shipped. Like the August 18 and August 20 sets it is a **new item** with its own `D` id,
|
||||||
|
not a reinterpretation of an existing one. `D1`–`D12` are taken; this is `D13`.
|
||||||
|
|
||||||
|
Raised by Cody Schaefer on Aug 21 2026 while asking how the suite handles HTTPS. Nothing in
|
||||||
|
`IMPLEMENTATION.md`, in `CR`/`F`/`S`/`A`/`B`/`C`, or in `docs/waves/backlog.md` covers
|
||||||
|
authentication against the domain — so this is new scope, and it gets a new ID rather than
|
||||||
|
being folded into the login-portal work that produced `server/auth.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D13 — Authentication moves to the domain over LDAPS
|
||||||
|
|
||||||
|
- **Amends:** the authentication model shipped in `DEPLOY-login-portal.md` (bcrypt hashes in
|
||||||
|
`users.password_hash`, verified in-process). That document describes what is being
|
||||||
|
replaced, not what is wrong — it was correct for a suite with no directory behind it.
|
||||||
|
- **Surface:** `server/` (`auth.py`, `app.py`, `models.py`, `manage_users.py`, `notify.py`,
|
||||||
|
a new `ldap_auth.py`, a new migration), `html/` (`login.html`, `login.js`, `admin.js`,
|
||||||
|
`users.js`), `requirements.txt`, `docker-compose.yml`, `Dockerfile`, deployment docs.
|
||||||
|
- **Wave:** 10 (`docs/waves/wave-10.md`). Depends on wave 9 merged, which it is.
|
||||||
|
|
||||||
|
### The decision
|
||||||
|
|
||||||
|
The suite stops storing passwords. A sign-in becomes a **simple bind to
|
||||||
|
`ldaps://prime.local:636`** as `<username>@prime.local` using the password the person typed.
|
||||||
|
A successful bind is the authentication. `users.password_hash` is dropped from the schema.
|
||||||
|
|
||||||
|
Four parts, all four required for the item to be done:
|
||||||
|
|
||||||
|
1. **LDAPS bind replaces local password verification.** `password_hash` is removed from the
|
||||||
|
model and from the database by migration. No password is stored, hashed or otherwise.
|
||||||
|
2. **Accounts are provisioned just-in-time.** A successful bind for a username with no
|
||||||
|
`users` row creates one, at the default role, with `full_name`/`email` read from the
|
||||||
|
directory.
|
||||||
|
3. **A required group gates login.** An AD group is configured; a bind that succeeds but
|
||||||
|
whose account is not in that group is refused. Membership is evaluated including nested
|
||||||
|
groups.
|
||||||
|
4. **Existing accounts keep their roles, and roles stay local.** An existing `admin` stays
|
||||||
|
an admin on first directory login. Granting admin to an existing account continues to
|
||||||
|
work from the Admin console. The directory supplies *identity*; this app supplies
|
||||||
|
*authorization*.
|
||||||
|
|
||||||
|
### Why LDAPS and not the certificate already in play
|
||||||
|
|
||||||
|
Recorded because the question was asked directly and the answer is not obvious.
|
||||||
|
|
||||||
|
The site's serving certificate is a **Let's Encrypt** cert (`CN=wp.controls.dev`, issued by
|
||||||
|
`Let's Encrypt YE2`, expiring 2026-11-08) held by an **OpenResty** instance at
|
||||||
|
`192.168.3.56` that is not part of this repo. It is a public domain-validated certificate.
|
||||||
|
It attests that whoever presented it controls DNS for `wp.controls.dev`; it carries no user
|
||||||
|
identity and no relationship to `prime.local`. There is no configuration that turns it into
|
||||||
|
a domain credential, so cert-based auth was never available "for free".
|
||||||
|
|
||||||
|
Client-certificate auth (mTLS) was considered and rejected for this wave: TLS terminates two
|
||||||
|
hops upstream at OpenResty, so the API never sees the handshake, and doing it in-app would
|
||||||
|
mean bypassing the proxy and losing the CSP/HSTS headers and static serving with it.
|
||||||
|
|
||||||
|
### What was verified before writing this (Aug 21 2026)
|
||||||
|
|
||||||
|
| Fact | Value |
|
||||||
|
|---|---|
|
||||||
|
| LDAPS reachable | `192.168.3.37:636` open, TLS 1.3, `TLS_AES_256_GCM_SHA384` |
|
||||||
|
| DC cert issuer | `CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local` |
|
||||||
|
| Root of that chain | `CN=PRIME CONTROLS ROOT CA` (self-signed, expires 2051-09-09) |
|
||||||
|
| Issuing CA expiry | 2036-09-09 |
|
||||||
|
| DC cert SAN | `DR-DC10Core.prime.local`, `prime.local`, `PRIME` |
|
||||||
|
| DCs published in `_ldap._tcp.prime.local` | six — `nla-dc10`, `lew-dc20`, `dr-dc30-core`, `lew-dc40`, `SABINEDC`, `dr-dc10core` |
|
||||||
|
| Chain validates against root+issuing bundle | yes — `Verify return code: 0 (ok)` |
|
||||||
|
|
||||||
|
Two consequences of that table, both binding on the build:
|
||||||
|
|
||||||
|
- **Connect to `prime.local`, not to a DC name or an IP.** Every DC's certificate carries
|
||||||
|
`prime.local` in its SAN, so the domain name both passes hostname validation and
|
||||||
|
round-robins across all six DCs. Verified: `prime.local` gives `0 (ok)`; the raw IP
|
||||||
|
`192.168.3.37` gives `62 (hostname mismatch)`, because there is no IP SAN.
|
||||||
|
- **The trust anchor is a CA certificate, not a certificate issued to this app.** The API is
|
||||||
|
the TLS *client*; clients present nothing. It needs `PRIME CONTROLS ROOT CA` plus
|
||||||
|
`PRIME CONTROLS ISSUING CA 1` as a PEM bundle, which is public information. No CSR, no
|
||||||
|
enrollment, no private key, nothing to request from IT.
|
||||||
|
|
||||||
|
### Non-negotiables
|
||||||
|
|
||||||
|
These are the ways this change goes wrong, and each has a done-when check in wave 10.
|
||||||
|
|
||||||
|
- **An empty password must be rejected before `bind()` is called.** In LDAP a simple bind
|
||||||
|
with an empty password is an *anonymous* bind and it **succeeds**. Without an explicit
|
||||||
|
guard, a blank password authenticates as any username submitted. This is the single
|
||||||
|
highest-severity failure mode in the item and it gets its own test.
|
||||||
|
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Not `CERT_NONE`, and not the
|
||||||
|
system trust store. `CERT_NONE` still encrypts, so it fails silently — what it loses is
|
||||||
|
the ability to distinguish the real DC from an attacker who terminates the TLS session,
|
||||||
|
harvests the domain password and relays the bind onward. Since domain credentials now
|
||||||
|
cross that channel, a compromise escalates from "this app" to Windows, mail and file
|
||||||
|
shares. The system store is refused separately because it currently trusts five other
|
||||||
|
self-signed CAs (`prime-DR-CAPRIME-CA`, `prime-DR-CA_PRIME-CA`, `prime-DR-DC20-CA`,
|
||||||
|
`PRIME CONTROLS ISSUING CA 2`, and a stray `L55401TDKLY3.prime.local` machine cert in
|
||||||
|
Trusted Root).
|
||||||
|
- **The app's lockout must trip below the domain's.** `LOGIN_MAX_ATTEMPTS` currently writes
|
||||||
|
to the local `users` row. Once failures are binds, they count against the **AD** lockout
|
||||||
|
policy, so an unauthenticated caller hammering `/api/auth/login` can lock real domain
|
||||||
|
accounts out of Windows. The local throttle must stop calling the DC before the domain
|
||||||
|
threshold is reached.
|
||||||
|
- **Never leak which usernames exist.** `login()` today equalises response timing on purpose
|
||||||
|
so a caller cannot enumerate accounts. Directory error 49 sub-codes (`52e` bad password,
|
||||||
|
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
|
||||||
|
reach the response body.
|
||||||
|
|
||||||
|
### Answered August 21, 2026 — both were raised as open and both were decided
|
||||||
|
|
||||||
|
**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk
|
||||||
|
was stated. There is no emergency local account, no env-var bypass, and no CLI-minted
|
||||||
|
session. The consequence is explicit and belongs in the runbook rather than being discovered:
|
||||||
|
**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is
|
||||||
|
misconfigured, nobody can sign in — including admins — and no amount of shell access fixes
|
||||||
|
it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is
|
||||||
|
therefore not a nicety; with no fallback it is the only thing standing between a typo in the
|
||||||
|
group field and a total outage.
|
||||||
|
|
||||||
|
Three things follow, and they are done-when checks in wave 10 rather than advice:
|
||||||
|
|
||||||
|
- The startup log must state whether LDAP is configured and reachable, so a broken deploy is
|
||||||
|
visible in `docker compose logs api` and not only at the login box.
|
||||||
|
- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable.
|
||||||
|
- The group setting cannot be saved without proving the saving admin is a member.
|
||||||
|
|
||||||
|
**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind
|
||||||
|
can only carry one identifier, and AD accepts the UPN form — so the bind is
|
||||||
|
`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local
|
||||||
|
row is a separate question, and it uses **both**: after a successful bind the directory's
|
||||||
|
`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local
|
||||||
|
row on either, case-insensitively. That is what keeps an existing admin's role whether their
|
||||||
|
hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is
|
||||||
|
never a valid bind string. It is a matching key only.
|
||||||
|
- If someone types an address at the login box, the local part is used as the
|
||||||
|
`sAMAccountName` — **one** bind attempt, never several, because each failed bind counts
|
||||||
|
against the domain lockout policy. That assumes the mail local part equals the
|
||||||
|
`sAMAccountName`. Where it does not, the person must type their short logon name; this is
|
||||||
|
logged when it happens and documented in `T10.8`.
|
||||||
|
- The production `users` table should still be compared against AD before this deploys. A
|
||||||
|
row matching on neither key gets a *second*, JIT-provisioned account at the default role
|
||||||
|
rather than keeping its admin. Matching on two keys narrows that risk; it does not remove
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
- mTLS / client-certificate authentication (see above).
|
||||||
|
- Kerberos / SPNEGO single sign-on. It is the better long-term answer for domain-joined
|
||||||
|
desktops and needs a keytab, an SPN and browser trust configuration; it is not this item.
|
||||||
|
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
|
||||||
|
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
|
||||||
|
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D14 — The CLI authenticates against the domain, and stops creating accounts
|
||||||
|
|
||||||
|
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
|
||||||
|
console*. It said nothing about `manage_users.py`, which had no authentication of any
|
||||||
|
kind. Requiring one is a new requirement, so it gets its own id rather than widening
|
||||||
|
criterion 4.
|
||||||
|
- **Surface:** `server/manage_users.py`
|
||||||
|
- **Task:** `T10.9`
|
||||||
|
|
||||||
|
### The decision
|
||||||
|
|
||||||
|
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
|
||||||
|
successful sign-in, so creating them by hand is redundant. Removing them also closes a
|
||||||
|
class of problem: every row now originates from a bind, so a username cannot be typed
|
||||||
|
in wrong and end up orphaned from the directory identity it was meant to match. That
|
||||||
|
risk now applies only to rows the old CLI already created.
|
||||||
|
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
|
||||||
|
supplies authorization, and this is where authorization is assigned from a shell.
|
||||||
|
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
|
||||||
|
There is deliberately no `--password` flag: that would put a live domain password into
|
||||||
|
shell history and into `ps` output for every other user on the box.
|
||||||
|
4. **`list` needs no credential**, so an outage stays diagnosable.
|
||||||
|
|
||||||
|
### Bootstrapping the first admin, which changed shape
|
||||||
|
|
||||||
|
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
|
||||||
|
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
|
||||||
|
password; there is no password now, and no account to create.
|
||||||
|
|
||||||
|
### What this is worth, stated plainly
|
||||||
|
|
||||||
|
Anyone with a shell on the api container can still write to the `users` table directly
|
||||||
|
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
|
||||||
|
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made from a
|
||||||
|
shell was invisible in `AuditLog` while the same change through the console was recorded;
|
||||||
|
now both are recorded and both name a person. Any-domain-user was accepted as sufficient
|
||||||
|
(no privileged group exists on this estate, and machine access is already restricted to a
|
||||||
|
few people), which was decided knowing the above.
|
||||||
|
|
||||||
|
### Two deliberate divergences from the API
|
||||||
|
|
||||||
|
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
|
||||||
|
everyone out of the console, this tool has to still work — otherwise the only route to
|
||||||
|
fixing the lockout is the thing the lockout prevents.
|
||||||
|
- **Changing your OWN role is permitted here.** `set_user_role` in `app.py` forbids it to
|
||||||
|
stop an admin locking themselves out of the console. Here it is the entire bootstrap
|
||||||
|
path, so it is allowed and recorded with `{"self": true}` in the audit detail.
|
||||||
|
|
||||||
|
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
|
||||||
|
administered, and there is no password login left to recover through.
|
||||||
@@ -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
|
- [ ] `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`)
|
- [ ] `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
|
- [ ] 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`
|
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`
|
||||||
|
|||||||
399
docs/waves/wave-10.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Wave 10 — Domain authentication over LDAPS
|
||||||
|
|
||||||
|
**Items:** `D13`, `D14`
|
||||||
|
**Depends on:** wave 9 merged (it is — `a8e28bf`)
|
||||||
|
|
||||||
|
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
|
||||||
|
before starting, particularly the **Non-negotiables** section, which is where this change
|
||||||
|
goes wrong if it goes wrong.
|
||||||
|
|
||||||
|
**This wave is field-visible in one direction only.** Nobody gains a screen. People sign in
|
||||||
|
with their Windows password instead of an app password, the "Forgot password?" link
|
||||||
|
disappears, and admins stop issuing passwords. Everything else is invisible — which means
|
||||||
|
the done-when checks are the only evidence the wave worked.
|
||||||
|
|
||||||
|
> **BEFORE PRODUCTION: `LDAP_REQUIRED_GROUP` must be set.** It is empty by default,
|
||||||
|
> and empty means *no group gate* — every account in `prime.local` may sign in. The
|
||||||
|
> successful live sign-in on Aug 24 was made without it, so it proved the bind, the
|
||||||
|
> certificate chain and JIT provisioning, but **not** the group check: that path never
|
||||||
|
> executed. The group gate is covered against the fake directory in `ldap_auth_check`
|
||||||
|
> and has never run against the real directory. The startup line says which state you
|
||||||
|
> are in — `required group: (none configured — every domain account may sign in)`.
|
||||||
|
|
||||||
|
**Build order is task order** for once. `T10.1` is a standalone module with no callers and
|
||||||
|
should merge first; nothing else can be tested until it exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.1 — The LDAPS client
|
||||||
|
|
||||||
|
- **Items:** `D13` (1)
|
||||||
|
- **Depends on:** nothing
|
||||||
|
- **Blocks:** T10.2, T10.4, T10.5, T10.7
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** new `server/ldap_auth.py`, `server/requirements.txt`, `docker-compose.yml`,
|
||||||
|
`Dockerfile`, `server/.env.example`
|
||||||
|
|
||||||
|
**Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
|
||||||
|
container has no CA bundle, and the `api` service sits on the `internal` network, which has
|
||||||
|
no default gateway and therefore no route to `192.168.3.x` at all.
|
||||||
|
|
||||||
|
**Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
|
||||||
|
`server/ldap_auth.py` exposing two functions and nothing else:
|
||||||
|
|
||||||
|
- `verify(username, password) -> LdapResult | None` — simple bind as
|
||||||
|
`f"{username}@{domain}"` against `ldaps://{host}:636`.
|
||||||
|
- `member_of(conn, group) -> bool` — nested-group aware, via
|
||||||
|
`LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
|
||||||
|
membership only and will wrongly refuse anyone in a nested group.
|
||||||
|
|
||||||
|
Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
|
||||||
|
`prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
|
||||||
|
`outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
|
||||||
|
it, and the comment there already explains the gateway-less `internal` network. Mount or
|
||||||
|
`COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
|
||||||
|
|
||||||
|
Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
|
||||||
|
why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
|
||||||
|
across resolved addresses, because round-robin will hand out a rebooting DC's address.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] `ldap3` is pinned to an exact version in `requirements.txt`
|
||||||
|
- [x] an empty or whitespace-only password returns failure **without calling `bind()`** — proven with `Connection` nulled, so any call to `bind()` would raise
|
||||||
|
- [x] an empty username returns failure without calling `bind()`
|
||||||
|
- [x] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
|
||||||
|
- [x] no code path sets `CERT_NONE`, and none falls back to the system trust store — asserted by an AST walk in `ldap_auth_check`, not a grep: the docstring names it to explain the ban
|
||||||
|
- [~] `member_of` returns true for an account in a **nested** child of the required group — **partially closed Aug 24.** The transitive matching rule was exercised against the live directory for a DIRECT member (`CN=Prime Employees`, 2,003 members) and returned a match, so the rule and the filter are correct on this estate. A genuinely NESTED case (member of a group that is a member of the required group) still has no test, because no such account was to hand. `member_of` now also warns loudly if the transitive query returns nothing where a direct check succeeds, which is how a broken nested lookup would announce itself rather than refusing real members silently.
|
||||||
|
- [x] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing — `selftest()` to the raw IP returns `untrusted`; to `prime.local`, `0 (ok)`
|
||||||
|
- [ ] `docker compose exec api openssl s_client -connect prime.local:636 -CAfile $LDAP_CA_FILE` reports `Verify return code: 0 (ok)`
|
||||||
|
- [x] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.2 — The login path binds instead of hashing
|
||||||
|
|
||||||
|
- **Items:** `D13` (1)
|
||||||
|
- **Depends on:** T10.1
|
||||||
|
- **Blocks:** T10.3, T10.4
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
||||||
|
|
||||||
|
**Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
|
||||||
|
`user.password_hash`. The lockout counter it maintains is about to start counting *domain*
|
||||||
|
bind failures, which changes what that counter is for.
|
||||||
|
|
||||||
|
**Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
|
||||||
|
the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
|
||||||
|
account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
|
||||||
|
**before** the domain policy is reached and short-circuits without calling the DC: the
|
||||||
|
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
|
||||||
|
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
||||||
|
|
||||||
|
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
|
||||||
|
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
|
||||||
|
stating whether LDAP is configured and whether the DC answered, because with no fallback a
|
||||||
|
misconfigured deploy is indistinguishable from a forgotten password at the login box.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] the API logs one line at startup saying whether LDAP is configured and which group gates sign-in — configuration only, so an unreachable DC cannot hang startup. This is what `DEPLOYMENT.md`, `DEPLOY-login-portal.md` and `server/README.md` all send people to first; it was specified in this task on Aug 21 and not actually written until Aug 24.
|
||||||
|
- [x] a correct domain password signs in and sets the session cookie — confirmed against the live domain Aug 24, and in `ldap_auth_check`
|
||||||
|
- [x] a wrong password is refused with the generic message
|
||||||
|
- [x] a blank password is refused (guards `T10.1` from the caller's side too)
|
||||||
|
- [x] a user not in the required group is refused even though the bind succeeded
|
||||||
|
- [x] `is_active = false` locally still refuses, independent of the directory — a disabled account is refused 403 even though the bind succeeds
|
||||||
|
- [x] the local throttle trips below the domain lockout threshold and stops calling the DC — proved by presenting a CORRECT password once the budget is spent: a 429 for a credential that would otherwise work is only possible if the throttle runs before the directory is consulted
|
||||||
|
- [x] no response body distinguishes "no such user" from "wrong password"
|
||||||
|
- [x] error-49 sub-codes appear in the log and nowhere in any response — asserted both ways: `_err49` parses a real AD message, and no sub-code appears in any response body
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.3 — Drop `password_hash`
|
||||||
|
|
||||||
|
- **Items:** `D13` (1)
|
||||||
|
- **Depends on:** T10.2
|
||||||
|
- **Blocks:** T10.6, T10.8
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
|
||||||
|
`server/manage_users.py`
|
||||||
|
|
||||||
|
**Problem:** With binds doing the work, every password code path is dead weight and a
|
||||||
|
liability. It is also the criterion that makes this item irreversible, so it lands on its own
|
||||||
|
commit.
|
||||||
|
|
||||||
|
**Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
|
||||||
|
revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
|
||||||
|
`alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
|
||||||
|
`verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
|
||||||
|
`create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
|
||||||
|
`/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
|
||||||
|
`/api/auth/password`, `/api/auth/users/{user_id}/password`, and the `_reset_last` throttle
|
||||||
|
with `reset_body`. Remove `reset-password` from `manage_users.py` and the password prompt
|
||||||
|
from `create` / `create-admin`.
|
||||||
|
|
||||||
|
`token_version` **stays.** It is still the session-revocation mechanism — role changes and
|
||||||
|
deactivation should bump it even though password changes no longer exist.
|
||||||
|
|
||||||
|
**Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
|
||||||
|
is criterion 4 and must keep working.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration — only the migration and one docstring naming the dropped column
|
||||||
|
- [x] `alembic upgrade head` then `downgrade -1` round-trips on SQLite and on Postgres (16.15, the compose image — Aug 24; needed a pre-existing T8.6 migration bug fixed first, see `495d87d`)
|
||||||
|
- [x] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back — verified on SQLite and Postgres
|
||||||
|
- [x] `token_version` still invalidates an already-issued session — but **not** via a role change, which was the wrong premise. Nothing in `app.py` bumps it any more: `get_current_user` re-reads the account every request, so `role` and `is_active` changes take effect immediately without it. Its one remaining trigger is `manage_users` on disable. Tested by bumping it directly: the old cookie 401s and every other session is untouched. See `BL-030`.
|
||||||
|
- [x] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
|
||||||
|
- [x] no CLI command prompts for a password — superseded by `T10.9`, which removed `create-admin` and `create` outright; the first admin is now bootstrapped by signing in and then `promote`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.4 — Just-in-time provisioning, without trampling existing accounts
|
||||||
|
|
||||||
|
- **Items:** `D13` (2, 4)
|
||||||
|
- **Depends on:** T10.2
|
||||||
|
- **Blocks:** T10.7
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
||||||
|
|
||||||
|
**Problem:** Criteria 2 and 4 pull in opposite directions. Provisioning on first login must
|
||||||
|
create accounts that do not exist, and must not touch the role of accounts that do — an
|
||||||
|
existing `admin` signing in for the first time after this wave must still be an admin
|
||||||
|
afterwards.
|
||||||
|
|
||||||
|
**Do:** On a successful bind that passes the group check, look the account up with
|
||||||
|
`auth.find_user` (already case-insensitive across username **and** email). If it exists,
|
||||||
|
update only `last_login_at` and — if empty locally — `full_name` and `email` from the
|
||||||
|
directory. **Never write `role`.** If it does not exist, create it at
|
||||||
|
`ROLE_PROJECT_USER` with `full_name`/`email` from the directory.
|
||||||
|
|
||||||
|
**A JIT account gets NO project access, and that is deliberate.** An earlier draft of this
|
||||||
|
task said to honour the `auto_add_projects` machinery so a new account "lands in the right
|
||||||
|
projects" — that was wrong about how the flag works. `auto_add_projects` is evaluated when a
|
||||||
|
**project** is created (`app.py:245`), marking accounts that should join every *new* job; it
|
||||||
|
cannot retroactively add a new account to existing ones. There is no correct default, so
|
||||||
|
least privilege applies: the account exists, can sign in, and sees nothing until someone
|
||||||
|
grants access. That is a real UX cliff — a successful sign-in into an empty app — so it has
|
||||||
|
to be visible to admins rather than silent, which is what the `AuditLog` row is for.
|
||||||
|
|
||||||
|
Note the flush-order warning in the `models.py` docstring: `create_user` in `app.py` handles
|
||||||
|
account-then-membership correctly in one flush — follow it if you add rows.
|
||||||
|
|
||||||
|
Write an `AuditLog` row for each JIT creation. An account appearing without an administrator
|
||||||
|
creating it is exactly the kind of event that record exists for.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
|
||||||
|
- [x] `full_name` and `email` are populated from the directory on creation
|
||||||
|
- [x] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed — asserted in `ldap_auth_check` against a real server
|
||||||
|
- [x] an existing account with a locally-set `full_name` does not have it overwritten
|
||||||
|
- [x] a JIT account has NO `ProjectMember` rows and sees no projects
|
||||||
|
- [x] the new account appears in the Admin console user list so access can be granted — asserted through `GET /api/auth/users`, the request the console makes
|
||||||
|
- [x] each JIT creation writes an `AuditLog` row
|
||||||
|
- [x] a failed bind creates **no** row
|
||||||
|
- [x] a bind that succeeds but fails the group check creates **no** row
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.5 — CLOSED, NOT BUILT (Aug 24 2026): the required group stays an env var
|
||||||
|
|
||||||
|
- **Items:** `D13` (3)
|
||||||
|
- **Status:** **won't build.** `LDAP_REQUIRED_GROUP` in the environment is the answer.
|
||||||
|
|
||||||
|
**Do not build this later by reading the original task and assuming it was skipped.**
|
||||||
|
It was proposed, examined and rejected on purpose, and the reasoning is below.
|
||||||
|
|
||||||
|
**What it was going to be:** the required group moved out of the environment into an
|
||||||
|
Admin console setting, with a validate-on-save guard that resolved the group in the
|
||||||
|
directory and confirmed the saving admin was a member.
|
||||||
|
|
||||||
|
**Why it is not being built:**
|
||||||
|
|
||||||
|
1. **The console requirement was invented here, not asked for.** D13 criterion 3 says
|
||||||
|
*"An AD group is configured"* — not "configurable from the console". The env var
|
||||||
|
satisfies the criterion as written.
|
||||||
|
2. **The validate-on-save guard existed only to defend against a risk the console
|
||||||
|
itself introduced.** A feature whose complexity exists to defend against itself is
|
||||||
|
usually the wrong feature.
|
||||||
|
3. **The lockout scenario it defended against is already handled.** A group that does
|
||||||
|
not resolve raises `LookupError` in `member_of`, which `verify()` maps to
|
||||||
|
`GROUP_NOT_FOUND`, which `is_config_problem` classifies as ours — so `login()`
|
||||||
|
answers **503**, not 401, and the log says *"required group 'X' does not resolve in
|
||||||
|
DC=prime,DC=local — refusing the sign-in. This is a configuration fault, not a bad
|
||||||
|
password."* A genuine non-member still gets 401. The two are already distinguishable
|
||||||
|
in both the log and the response.
|
||||||
|
4. **A redeploy is deliberate and reviewable; a text box is not.** The group is set
|
||||||
|
once and effectively never changes — it is not SMTP configuration.
|
||||||
|
5. **The console version creates a circular failure.** Fixing a lockout would require
|
||||||
|
the console the lockout prevents you from reaching. Editing the env var does not.
|
||||||
|
|
||||||
|
**What is genuinely lost, and accepted:**
|
||||||
|
|
||||||
|
- **Discoverability.** An app admin cannot see which group is required without
|
||||||
|
Portainer or shell access. A read-only line in the Admin console diagnostics would
|
||||||
|
give the useful half without the dangerous half; it was offered and declined on
|
||||||
|
Aug 24 as not needed.
|
||||||
|
- **Deploy-time validation.** Nothing confirms the group resolves until the first
|
||||||
|
sign-in attempt. This is not fixable: resolving a group needs an authenticated
|
||||||
|
search, anonymous bind is disabled on this estate, and there is no service account
|
||||||
|
by design. The first-attempt 503 is the earliest possible detection.
|
||||||
|
|
||||||
|
### T10.6 — Strip the password UI
|
||||||
|
|
||||||
|
- **Items:** `D13` (1)
|
||||||
|
- **Depends on:** T10.3
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/login.html`, `html/login.js`, `html/admin.js`, `html/users.js`,
|
||||||
|
`html/auth-guard.js`
|
||||||
|
|
||||||
|
**Problem:** `login.html` has three views — sign-in, forgot-password, set-new-password — and
|
||||||
|
two of them now point at endpoints that no longer exist. `auth-guard.js` has a
|
||||||
|
change-password dialog, and the user-admin UI has a "reset password" action per row.
|
||||||
|
|
||||||
|
**Decided Aug 21: "Forgot password?" is KEPT and repointed at
|
||||||
|
`https://primecontrols.okta.com/`.** An earlier draft of this task removed the link, and a
|
||||||
|
plain sentence saying "contact IT" was proposed instead. Okta is the better answer — it is a
|
||||||
|
real self-service path, and with no app password and no break-glass it is the only recovery
|
||||||
|
route that exists. Note this is the first sign of an Okta tenancy on this estate; see the
|
||||||
|
new backlog entry.
|
||||||
|
|
||||||
|
Mechanics that matter: it is a plain `<a href>`, not a form post, so the `form-action 'self'`
|
||||||
|
in the CSP does not apply and no `navigate-to` directive is set — off-origin link navigation
|
||||||
|
is allowed as-is. `target="_blank"` needs `rel="noopener noreferrer"`, and there must be NO
|
||||||
|
click handler on `#forgot-link`: the old one called `preventDefault()` to swap views, and
|
||||||
|
leaving it would silently swallow the navigation.
|
||||||
|
|
||||||
|
**Do:** Remove the `#view-forgot` and `#view-reset` sections and the reset-token handling in
|
||||||
|
`login.js`, and repoint `#forgot-link` as above. Remove the change-password dialog from `auth-guard.js`
|
||||||
|
(`backlog.md:180` refers to it) and the per-row password reset from the users UI. Keep the
|
||||||
|
sign-in form, and relabel the password field's hint to say it is the Windows/domain password
|
||||||
|
— people need to know which password to type.
|
||||||
|
|
||||||
|
Keep the role-granting controls exactly as they are. That is criterion 4.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
|
||||||
|
- [x] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab — by inspection of the markup: `target="_blank"` with `rel="noopener noreferrer"`
|
||||||
|
- [x] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
|
||||||
|
- [x] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
|
||||||
|
- [x] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it) — `url_state_check` drives the real form end to end; the `role="alert"` region is untouched
|
||||||
|
- [x] the password field says which password to enter — see the screenshots
|
||||||
|
- [x] no dead `<a href="#">` or handler remains for a removed view
|
||||||
|
- [x] granting admin to an existing user still works from the console — asserted through `POST /api/auth/users/{id}/role`, the request `users.js` sends, and the role really changes
|
||||||
|
- [x] exercised at 390px and at 1440px, screenshots in the PR — `docs/reference/baseline/before-wave10/` and `after-wave10/`, captured Aug 24 with `tests/baseline_shots.py`. "Before" comes from a detached worktree at `main` (`a8e28bf`) so each half was shot against its own server.
|
||||||
|
- [x] no raw hex added to any stylesheet (the token rule) — no CSS was added at all — the hint reuses the `.hint` class the page already had
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.7 — Make the suite testable without a domain controller
|
||||||
|
|
||||||
|
- **Items:** `D13` (1, 2, 3)
|
||||||
|
- **Depends on:** T10.1, T10.4
|
||||||
|
- **Blocks:** T10.8
|
||||||
|
- **Surface:** `tests/` + `server/`
|
||||||
|
- **Files:** `server/ldap_auth.py`, `tests/browser_check.py`, `tests/launcher_check.py`,
|
||||||
|
`tests/console_dialogs_check.py`, `tests/url_state_check.py`, `server/smoketest.py`,
|
||||||
|
`server/seed_demo.py`, new `tests/ldap_auth_check.py`
|
||||||
|
|
||||||
|
**Problem:** This is the task most likely to be underestimated. Four existing checks build
|
||||||
|
users with `password_hash=auth.hash_password(PW)` and sign in over HTTP;
|
||||||
|
`console_dialogs_check.py` drives the password-reset prompt specifically. `smoketest.py` and
|
||||||
|
`seed_demo.py` both sign in. None of them can reach a DC, and CI has no domain.
|
||||||
|
|
||||||
|
**Do:** Make the bind injectable — a module-level seam in `ldap_auth.py` that a test can
|
||||||
|
substitute (a fake directory: usernames, passwords, groups, full names), selected by an env
|
||||||
|
var that is refused when a real `DATABASE_URL` is configured, mirroring how
|
||||||
|
`auth._load_secret` refuses an ephemeral key in production. Port the four checks onto it.
|
||||||
|
Delete the password-reset half of `console_dialogs_check.py` — the flow it covers no longer
|
||||||
|
exists — and say so in the PR rather than leaving a skipped test.
|
||||||
|
|
||||||
|
Add `tests/ldap_auth_check.py` covering the non-negotiables from the decision doc: empty
|
||||||
|
password, empty username, `CERT_REQUIRED` asserted by inspecting the constructed `Tls`,
|
||||||
|
nested group membership, group-check refusal creating no user, and existing-admin role
|
||||||
|
preservation.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] the stub backend cannot be selected when a non-SQLite `DATABASE_URL` is set — asserted by a test
|
||||||
|
- [x] `tests/ldap_auth_check.py` covers the cases above — 20/20
|
||||||
|
- [x] the empty-password case is proved by nulling `Connection`, so any call to `bind()` would raise — it asserts the guard returns *before* the transport, not merely that the result is a failure
|
||||||
|
- [x] `smoketest.py` and `seed_demo.py` document which credentials they now need (T10.8)
|
||||||
|
- [x] the removed password-reset checks are called out, not silently dropped — `console_dialogs_check.py`'s docstring records the coverage loss and where the prompt kit is still covered
|
||||||
|
- [x] the full `tests/` suite passes with no DC reachable — **1284/1288 checks across 40 files**, Aug 24. Two files fail, both proven pre-existing and unrelated (`BL-028` `assets_check`, `BL-029` `generalinfo_check`): `git diff main...HEAD` shows this branch touches neither file. `token_check.py` is not a member of the suite — it is a capture/diff tool that requires `--out` or `--compare`, and a sweep script that runs it bare gets a usage message and exit 2.
|
||||||
|
|
||||||
|
**Scope note.** This task was estimated as far larger than it turned out to be. The
|
||||||
|
premise was that four checks sign in and would all need the seam; in fact `seed()` mints
|
||||||
|
a token with `auth.create_token()` and sets the cookie directly, so **no** browser check
|
||||||
|
signs in except `url_state_check`'s deep-link case. `browser_check` and `launcher_check`
|
||||||
|
needed one kwarg deleted each — and since 39 files import `seed`/`start_server` from
|
||||||
|
`browser_check`, that single line unblocked nearly the whole suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.8 — Documentation and the deploy runbook
|
||||||
|
|
||||||
|
- **Items:** `D13`
|
||||||
|
- **Depends on:** T10.3, T10.5, T10.7
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** docs
|
||||||
|
- **Files:** `DEPLOYMENT.md`, `server/README.md`, `DEPLOY-login-portal.md`, `CLAUDE.md`,
|
||||||
|
`IMPLEMENTATION.md`
|
||||||
|
|
||||||
|
**Problem:** `DEPLOY-login-portal.md` documents creating the first admin with a password and
|
||||||
|
is the page an admin will reach for. `DEPLOYMENT.md` describes `AUTH_SECRET_KEY` and SMTP but
|
||||||
|
knows nothing about a directory. `CLAUDE.md`'s verification section tells anyone touching the
|
||||||
|
frontend to run the smoke test, which changes here.
|
||||||
|
|
||||||
|
**Do:** Document the LDAP variables, how to produce the CA bundle from the two thumbprints
|
||||||
|
in the decision doc, and the `prime.local`-not-an-IP rule with the reason. Add the
|
||||||
|
`openssl s_client -CAfile` check as the first-line diagnostic. Rewrite the
|
||||||
|
`DEPLOY-login-portal.md` bootstrap step: the first admin is now an existing directory account
|
||||||
|
promoted with `manage_users.py`, not an account created with a password. State plainly what
|
||||||
|
happens when the DC is unreachable, whatever `T10.2` decides.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md`
|
||||||
|
- [x] the CA bundle procedure is reproducible by an admin who has not read this thread — thumbprints and a `Get-ChildItem` one-liner in `DEPLOYMENT.md`
|
||||||
|
- [x] `DEPLOY-login-portal.md` no longer instructs anyone to set a password — rewritten, with a note saying what it replaced so an admin holding the old copy is not misled
|
||||||
|
- [x] the DC-unreachable behaviour is stated explicitly, with the diagnostic commands
|
||||||
|
- [x] `IMPLEMENTATION.md` section 4 lists wave 10
|
||||||
|
- [x] no doc still claims passwords are stored as bcrypt hashes (swept; remaining matches all say the opposite)
|
||||||
|
- [x] `CLAUDE.md` carries the four load-bearing auth rules, next to the token rule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10.9 — D14: the CLI authenticates, and stops creating accounts
|
||||||
|
|
||||||
|
- **Items:** `D14`
|
||||||
|
- **Depends on:** T10.3
|
||||||
|
- **Blocks:** T10.8
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/manage_users.py`
|
||||||
|
|
||||||
|
**Problem:** `manage_users.py` writes to the `users` table with no authentication at all.
|
||||||
|
It also still offers `create-admin` / `create`, which are redundant now that accounts
|
||||||
|
provision themselves — and worse than redundant, because a hand-typed username can end up
|
||||||
|
matching no directory identity.
|
||||||
|
|
||||||
|
**Do:** As stated in `D14`. Remove the two create commands, add `promote` / `demote`, gate
|
||||||
|
every state-changing command on a prompted domain bind, and write an `AuditLog` row naming
|
||||||
|
the operator. Write the audit row by hand rather than importing `log_event` from `app.py` —
|
||||||
|
that would pull FastAPI and the whole application into a CLI startup for one INSERT.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] `create-admin`, `create` and `reset-password` are rejected as invalid choices
|
||||||
|
- [x] `list` works with no credential and with no LDAP configured
|
||||||
|
- [x] a state-changing command with LDAP misconfigured refuses instead of proceeding
|
||||||
|
- [x] there is no `--password` flag on any command
|
||||||
|
- [x] `promote` raises a role; `demote` returns an account to `project_user`
|
||||||
|
- [x] promoting YOURSELF is allowed and recorded with `self: true`
|
||||||
|
- [x] the last active admin cannot be demoted
|
||||||
|
- [x] an unknown account gives an error that says accounts are made on first sign-in
|
||||||
|
- [x] every change writes an `AuditLog` row naming the operator
|
||||||
|
- [x] verified against a real domain bind — `promote` and `demote` confirmed working Aug 24 2026
|
||||||
@@ -307,10 +307,10 @@ criteria turned out wrong, inputs still outstanding, and follow-ups logged along
|
|||||||
|
|
||||||
## Wave 9 exit criteria
|
## Wave 9 exit criteria
|
||||||
|
|
||||||
- [ ] the export matches the final structure
|
- [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)
|
||||||
- [ ] one icon system, one sample-data affordance
|
- [x] one icon system, one sample-data affordance (`icon_check.py`, `sample_check.py`)
|
||||||
- [ ] accessibility metrics hit target or are documented
|
- [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)
|
||||||
- [ ] the primary flow works at 390px
|
- [x] the primary flow works at 390px (`mobile_check.py`, 24 checks, all seven pages; screenshots committed)
|
||||||
- [ ] archived projects are readable by project admins and invisible to everyone else (`D7`)
|
- [x] archived projects are readable by project admins and invisible to everyone else (`archived_check.py`, 15 checks)
|
||||||
- [ ] the backlog has no entry still pointing at wave 9
|
- [x] the backlog has no entry still pointing at wave 9 (nine closed at T9.9, each with its measurement)
|
||||||
- [ ] every item is reconciled - all 65
|
- [x] every item is reconciled — all 65 (`docs/reference/completion.md`)
|
||||||
|
|||||||
@@ -217,6 +217,7 @@
|
|||||||
|
|
||||||
<script src="wp-usage.js"></script>
|
<script src="wp-usage.js"></script>
|
||||||
<script src="console-util.js"></script>
|
<script src="console-util.js"></script>
|
||||||
|
<script src="wp-dialog.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
<!-- 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
|
page could never show a project and always read "Select a project" (F1). Must
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ async function checkHealth(){
|
|||||||
b.className='banner'; b.textContent='Checking…';
|
b.className='banner'; b.textContent='Checking…';
|
||||||
const { status, json } = await api('GET','/api/health');
|
const { status, json } = await api('GET','/api/health');
|
||||||
if(status===200 && json && json.ok){
|
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){
|
} 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){
|
} 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 {
|
} 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(){
|
async function seedDemo(){
|
||||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
let r = await api('GET','/api/health');
|
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'});
|
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);
|
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'}}});
|
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).');
|
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'});
|
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);
|
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||||
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
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();
|
snapshot();
|
||||||
}
|
}
|
||||||
async function cleanDemo(){
|
async function cleanDemo(){
|
||||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
if(!(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='';
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
// 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.
|
// this button — the default list hides it and nothing else here can delete it.
|
||||||
const r = await api('GET','/api/projects?archived=all');
|
const r = await api('GET','/api/projects?archived=all');
|
||||||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
if(r.status!==200){ demoLog('✕ API unreachable (HTTP '+r.status+').'); return; }
|
||||||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
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();
|
snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,14 +177,14 @@ async function loadProjects(){
|
|||||||
const { status, json } = await api('GET','/api/projects?archived=all');
|
const { status, json } = await api('GET','/api/projects?archived=all');
|
||||||
if(status===403){
|
if(status===403){
|
||||||
banner.className='banner bad';
|
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;
|
wrap.innerHTML=''; return;
|
||||||
}
|
}
|
||||||
if(status===401){
|
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)){
|
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';
|
banner.style.display='none';
|
||||||
_adminProjects = json;
|
_adminProjects = json;
|
||||||
@@ -250,10 +252,12 @@ async function archiveProject(id, name, archived){
|
|||||||
'• Nothing is deleted. Unarchive here at any time to bring it back.'
|
'• Nothing is deleted. Unarchive here at any time to bring it back.'
|
||||||
: 'Unarchive “'+name+'”?\n\n'+
|
: 'Unarchive “'+name+'”?\n\n'+
|
||||||
'It becomes visible in the pickers again and can be edited as normal.';
|
'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});
|
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
|
||||||
if(status===200) loadProjects();
|
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
|
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
|
||||||
@@ -261,13 +265,16 @@ async function archiveProject(id, name, archived){
|
|||||||
// enough to collide with one of them later. The -Admin suffix also says which of the
|
// 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.
|
// two project deletions this is — the console's, not a project member's.
|
||||||
async function deleteProjectAdmin(id, name){
|
async function deleteProjectAdmin(id, name){
|
||||||
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
|
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 '+
|
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||||
'(database cascade). This cannot be undone.\n\n'+
|
'(database cascade). This cannot be undone.\n\n'+
|
||||||
'If you only want it out of the way, cancel and use Archive instead.')) return;
|
'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);
|
const { status, json } = await api('DELETE','/api/projects/'+id);
|
||||||
if(status===200) loadProjects();
|
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 ─────────────────────────────────────────────
|
// ── default members on new projects ─────────────────────────────────────────────
|
||||||
@@ -284,14 +291,14 @@ async function loadDefaultMembers(){
|
|||||||
const { status, json } = await api('GET','/api/auth/users');
|
const { status, json } = await api('GET','/api/auth/users');
|
||||||
if(status===403){
|
if(status===403){
|
||||||
banner.className='banner bad';
|
banner.className='banner bad';
|
||||||
banner.textContent='❌ Your account is not an admin, so you 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;
|
wrap.innerHTML=''; return;
|
||||||
}
|
}
|
||||||
if(status===401){
|
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)){
|
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';
|
banner.style.display='none';
|
||||||
_defMemUsers = json;
|
_defMemUsers = json;
|
||||||
@@ -373,7 +380,8 @@ async function setAutoAdd(id, username){
|
|||||||
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||||
renderDefaultMembers();
|
renderDefaultMembers();
|
||||||
} else {
|
} 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();
|
loadDefaultMembers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,7 +568,7 @@ async function saveLocalization(){
|
|||||||
const m = document.getElementById('l10n-msg');
|
const m = document.getElementById('l10n-msg');
|
||||||
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
|
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
|
||||||
} else {
|
} else {
|
||||||
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
|
msg.textContent = '✕ '+((json && json.detail) || ('HTTP '+status));
|
||||||
msg.style.color = 'var(--red)';
|
msg.style.color = 'var(--red)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,8 +635,8 @@ async function saveSettings(){
|
|||||||
async function testEmail(){
|
async function testEmail(){
|
||||||
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
|
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', {});
|
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)'; }
|
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)'; }
|
else { msg.textContent = '✕ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
|
||||||
}
|
}
|
||||||
async function loadNotifications(){
|
async function loadNotifications(){
|
||||||
const box = document.getElementById('notif-box'); if(!box) return;
|
const box = document.getElementById('notif-box'); if(!box) return;
|
||||||
|
|||||||
@@ -60,63 +60,6 @@
|
|||||||
.then(function () { window.location.replace('login.html'); });
|
.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); });
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── permissions helpers ────────────────────────────────────────────────────
|
// ── permissions helpers ────────────────────────────────────────────────────
|
||||||
// The server enforces all of this; these are for hiding controls the signed-in
|
// The server enforces all of this; these are for hiding controls the signed-in
|
||||||
// user can't use, so nobody clicks a button just to get a 403.
|
// user can't use, so nobody clicks a button just to get a 403.
|
||||||
|
|||||||
@@ -213,3 +213,9 @@ select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--
|
|||||||
@media (max-width:620px){
|
@media (max-width:620px){
|
||||||
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
.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 { 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.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.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); }
|
.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 { 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); }
|
.fld-empty a { color: var(--cds-link-primary); }
|
||||||
@@ -66,9 +66,9 @@
|
|||||||
.fld-toast.show { opacity: 1; }
|
.fld-toast.show { opacity: 1; }
|
||||||
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
|
/* 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;
|
.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; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -149,11 +149,11 @@ function renderDetail() {
|
|||||||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||||||
p.files.map(function (f) {
|
p.files.map(function (f) {
|
||||||
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
|
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>' : '') +
|
}).join('') + '</div>' : '') +
|
||||||
'<div class="fld-sec"><h3>Add field update</h3>' +
|
'<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>' +
|
'<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>' +
|
'<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 style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|||||||
199
html/help.js
@@ -12,67 +12,174 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'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 ────────────────────────────────────────────────────────────────
|
// ── styles ────────────────────────────────────────────────────────────────
|
||||||
var css = `
|
var css = `
|
||||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
/* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
|
||||||
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
|
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; }
|
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
||||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
.help-tip:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
|
||||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
|
.help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
|
||||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
#wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
|
||||||
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
background:var(--cds-background-inverse); color:var(--cds-text-inverse);
|
||||||
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
|
padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
|
||||||
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
|
box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
|
||||||
|
|
||||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||||
.ui-help-overlay.open{ display:flex; }
|
.ui-help-overlay.open{ display:flex; }
|
||||||
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
|
.ui-help-modal{ background: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;
|
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; }
|
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-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
|
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
|
||||||
font-size:13px; outline:none; background:#f7f8fa; }
|
font-size:13px; outline:none; background:var(--cds-layer-accent); }
|
||||||
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
.ui-help-search input:focus{ border-color: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:#525252; line-height:1; }
|
.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-wrap{ display:flex; flex:1; min-height:0; }
|
||||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
.ui-help-nav{ width:230px; flex:none; border-right:1px solid 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:#27313f; text-decoration:none; font-size:13px;
|
.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; }
|
cursor:pointer; margin-bottom:1px; }
|
||||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
.ui-help-nav a:hover{ background:var(--cds-layer-hover); }
|
||||||
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
|
.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-nav a.nohit{ display:none; }
|
||||||
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||||
.ui-help-sec{ margin-bottom:30px; }
|
.ui-help-sec{ margin-bottom:30px; }
|
||||||
.ui-help-sec.hide{ display:none; }
|
.ui-help-sec.hide{ display:none; }
|
||||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
|
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color: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:#0f62fe; }
|
.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:#27313f; }
|
.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 ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||||
.ui-help-content li{ margin-bottom:5px; }
|
.ui-help-content li{ margin-bottom:5px; }
|
||||||
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
.ui-help-content code{ background: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 table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||||
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
|
.ui-help-content th, .ui-help-content td{ border:1px solid var(--cds-border-subtle); padding:6px 9px; text-align:left; vertical-align:top; }
|
||||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
.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; }
|
.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; }
|
/* Scoped to .ui-help-pill: this block is injected on EVERY page, and the
|
||||||
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
creator's Issue (hold) status radio also carries the class pill-hold - the
|
||||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
bare selector painted that radio error-red at all times, selected or not
|
||||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
(found by Nick 2026-08-20; the collision dates to the login-portal era). */
|
||||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
|
.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; }
|
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||||
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
|
.ui-help-noresult{ display:none; color:var(--cds-text-secondary); font-size:14px; padding:10px 2px; }
|
||||||
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
.ui-help-content mark{ background: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%;
|
.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); }
|
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){
|
@media (max-width:760px){
|
||||||
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
|
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid var(--cds-border-subtle); }
|
||||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||||
.ui-help-head{ flex-wrap:wrap; }
|
.ui-help-head{ flex-wrap:wrap; }
|
||||||
}`;
|
}`;
|
||||||
@@ -93,7 +200,7 @@
|
|||||||
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<h4>Moving around</h4>
|
<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>
|
<h4>Quick start</h4>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
|
<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>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>
|
<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>
|
</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: `
|
{ id: 'projects', title: 'Projects', body: `
|
||||||
<h3>Projects</h3>
|
<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>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>
|
<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>
|
</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: `
|
{ id: 'wps', title: 'Work Packages', body: `
|
||||||
<h3>Creating Work Packages</h3>
|
<h3>Creating Work Packages</h3>
|
||||||
@@ -141,7 +248,7 @@
|
|||||||
<h4>Key fields</h4>
|
<h4>Key fields</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
<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>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>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>
|
<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>
|
<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>
|
</ul>
|
||||||
<h4>Saving</h4>
|
<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: `
|
{ id: 'statuses', title: 'Statuses', body: `
|
||||||
<h3>Work Package statuses</h3>
|
<h3>Work Package statuses</h3>
|
||||||
@@ -223,7 +330,7 @@
|
|||||||
|
|
||||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||||
<h3>Dashboard & metrics</h3>
|
<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>
|
<h4>Metric cards (click to filter)</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
<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>
|
<h4>Breakdowns & gates</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
<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>
|
</ul>
|
||||||
<h4>The table</h4>
|
<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>
|
<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: `
|
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||||
<h3>Samples, import / export & comments</h3>
|
<h3>Samples, import / export & comments</h3>
|
||||||
<h4>Load sample</h4>
|
<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>
|
<h4>Import / Export</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
<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>
|
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h4>Comments & feedback</h4>
|
<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>
|
<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: `
|
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||||
<h3>Tips & keyboard shortcuts</h3>
|
<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>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>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>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>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>
|
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||||
</table>` },
|
</table>` },
|
||||||
|
|||||||
@@ -427,6 +427,19 @@
|
|||||||
══════════════════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════════════════ -->
|
||||||
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
|
<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 -->
|
<!-- (a) no projects at all -->
|
||||||
<section class="section first-run" id="first-run" hidden>
|
<section class="section first-run" id="first-run" hidden>
|
||||||
<h2>No projects yet</h2>
|
<h2>No projects yet</h2>
|
||||||
@@ -580,12 +593,37 @@
|
|||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
|
<script src="wp-dialog.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||||
const esc = ProjectData.esc;
|
const esc = ProjectData.esc;
|
||||||
let _projects = [];
|
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(){
|
function initProjects(){
|
||||||
|
renderArchivedProjects();
|
||||||
ProjectData.list().then(list => {
|
ProjectData.list().then(list => {
|
||||||
_projects = list || [];
|
_projects = list || [];
|
||||||
// A deep link names the project explicitly, and every other page in the
|
// 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){
|
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
||||||
WPUrl.replace({ 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);
|
if(dropped) ProjectData.setActive(null);
|
||||||
_listLoaded = true;
|
_listLoaded = true;
|
||||||
renderProjectEntry();
|
renderProjectEntry();
|
||||||
@@ -1004,7 +1047,7 @@
|
|||||||
const text = document.getElementById('comment-text').value.trim();
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
alert('Please enter feedback.');
|
toast('Please enter feedback.', 'alert');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,7 +1068,7 @@
|
|||||||
function exportFeedback() {
|
function exportFeedback() {
|
||||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||||
const data = saved ? JSON.parse(saved) : [];
|
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 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 blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -1043,7 +1086,7 @@
|
|||||||
try {
|
try {
|
||||||
const inc = JSON.parse(r.result);
|
const inc = JSON.parse(r.result);
|
||||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
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');
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||||
allComments = saved ? JSON.parse(saved) : [];
|
allComments = saved ? JSON.parse(saved) : [];
|
||||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
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++; } });
|
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));
|
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||||
loadComments();
|
loadComments();
|
||||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||||
} catch (e) { alert('Could not read that file.'); }
|
} catch (e) { toast('Could not read that file.', 'alert'); }
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
};
|
};
|
||||||
r.readAsText(f);
|
r.readAsText(f);
|
||||||
|
|||||||
@@ -112,47 +112,16 @@
|
|||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">Use your Windows password — the same one you use to sign in to your computer.</div>
|
||||||
<button id="submit" type="submit">Sign in</button>
|
<button id="submit" type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
|
<!-- D13: there is no app password to reset. Self-service goes to Okta.
|
||||||
</section>
|
A plain external link, not a form post — CSP sets form-action 'self'
|
||||||
|
and does not set navigate-to, so link navigation off-origin is allowed.
|
||||||
<!-- FORGOT PASSWORD (email reset) -->
|
rel="noopener noreferrer" because target="_blank" without it hands the
|
||||||
<section id="view-forgot" style="display:none">
|
opened page a window.opener handle back to this one. -->
|
||||||
<h1>Reset password</h1>
|
<p class="center"><a href="https://primecontrols.okta.com/" id="forgot-link" class="link"
|
||||||
<p class="sub">We'll email you a link to set a new one.</p>
|
target="_blank" rel="noopener noreferrer">Forgot password?</a></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>
|
</section>
|
||||||
|
|
||||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||||
|
|||||||
149
html/login.js
@@ -1,26 +1,20 @@
|
|||||||
/* Login page logic for the Work Package Suite.
|
/* Login page logic for the Work Package Suite.
|
||||||
|
|
||||||
Three views on one page:
|
One view. Sign in posts to /api/auth/login, the server authenticates by binding
|
||||||
• sign in posts to /api/auth/login. On success the server sets an
|
to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
|
||||||
HttpOnly session cookie (not readable here — that's the
|
not readable from here, which is the point — after which we redirect to ?next=
|
||||||
point) and we redirect to ?next= or the home page.
|
or the home page. The password entered is the person's WINDOWS password.
|
||||||
• 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
|
There is no forgot-password flow and no reset view: the suite holds no password
|
||||||
from the address bar so it isn't left in history or copied out of the bar. */
|
to reset. "Forgot password?" is a plain external link to Okta in login.html, so
|
||||||
|
there is deliberately no click handler for it here — one that called
|
||||||
|
preventDefault() would swallow the navigation. */
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var errorBox = document.getElementById('error');
|
var errorBox = document.getElementById('error');
|
||||||
var okBox = document.getElementById('ok');
|
var okBox = document.getElementById('ok');
|
||||||
|
|
||||||
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 byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
@@ -28,11 +22,6 @@
|
|||||||
errorBox.textContent = msg;
|
errorBox.textContent = msg;
|
||||||
errorBox.classList.add('show');
|
errorBox.classList.add('show');
|
||||||
}
|
}
|
||||||
function showOk(msg) {
|
|
||||||
errorBox.classList.remove('show');
|
|
||||||
okBox.textContent = msg;
|
|
||||||
okBox.classList.add('show');
|
|
||||||
}
|
|
||||||
function clearBanners() {
|
function clearBanners() {
|
||||||
errorBox.classList.remove('show');
|
errorBox.classList.remove('show');
|
||||||
okBox.classList.remove('show');
|
okBox.classList.remove('show');
|
||||||
@@ -49,10 +38,6 @@
|
|||||||
return 'index.html';
|
return 'index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetToken() {
|
|
||||||
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function postJson(url, payload) {
|
function postJson(url, payload) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -70,18 +55,11 @@
|
|||||||
return (typeof d === 'string' && d) ? d : fallback;
|
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 ────────────────────────────────────────────────────────────────
|
// ── sign in ────────────────────────────────────────────────────────────────
|
||||||
var form = byId('login-form');
|
var form = byId('login-form');
|
||||||
var submitBtn = byId('submit');
|
var submitBtn = byId('submit');
|
||||||
// Guarded because a cached older login.html may not have the reset views; an
|
// Guarded: an unguarded addEventListener on null would break sign-in itself if a
|
||||||
// unguarded addEventListener on null would break sign-in itself.
|
// cached older login.html were served.
|
||||||
if (!form || !submitBtn) return;
|
if (!form || !submitBtn) return;
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -98,6 +76,10 @@
|
|||||||
if (res.status === 401) showError('Invalid username or password.');
|
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 === 403) showError(detail(res, 'Your account is disabled.'));
|
||||||
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
|
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
|
||||||
|
// 503 means the directory is unreachable or misconfigured — OUR fault, not a
|
||||||
|
// wrong password. Saying so stops people hunting for a password they no
|
||||||
|
// longer have while a deploy is broken.
|
||||||
|
else if (res.status === 503) showError(detail(res, 'Sign-in is temporarily unavailable. Contact IT.'));
|
||||||
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
|
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Sign in';
|
submitBtn.textContent = 'Sign in';
|
||||||
@@ -109,107 +91,4 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 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');
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -52,6 +52,15 @@
|
|||||||
.catch(function () { return readLocal(); });
|
.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) {
|
get: function (id) {
|
||||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
@@ -207,8 +216,12 @@
|
|||||||
var d = sopRow.data; // { sop, state } as written by pushSOP
|
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.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));
|
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||||
|
// 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');
|
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}).catch(function () {})
|
}).catch(function () {})
|
||||||
);
|
);
|
||||||
jobs.push(
|
jobs.push(
|
||||||
@@ -345,6 +358,12 @@
|
|||||||
// and one badge. The 'storage' listener below stays: it is what keeps two
|
// and one badge. The 'storage' listener below stays: it is what keeps two
|
||||||
// TABS in step, which is a different thing and still happens.
|
// TABS in step, which is a different thing and still happens.
|
||||||
var _badgeHideTimer = null;
|
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) {
|
function renderSyncBadge(c) {
|
||||||
if (!document.body) return;
|
if (!document.body) return;
|
||||||
var el = document.getElementById('wp-sync-badge');
|
var el = document.getElementById('wp-sync-badge');
|
||||||
@@ -356,9 +375,10 @@
|
|||||||
el.setAttribute('role', 'status');
|
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;' +
|
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;' +
|
'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);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
|
if (!c) return; // the eager DOMContentLoaded mount: holder only, no state yet
|
||||||
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
||||||
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
|
// 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
|
// reason is the only thing that tells the user what to do (e.g. the project is
|
||||||
@@ -378,16 +398,16 @@
|
|||||||
if (c.dead) {
|
if (c.dead) {
|
||||||
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved</span>' +
|
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>' : '');
|
(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) {
|
} else if (c.failed) {
|
||||||
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying';
|
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) {
|
} else if (c.pending) {
|
||||||
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
|
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 {
|
} else {
|
||||||
el.textContent = '✓ Everything sent to the project';
|
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);
|
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,11 +143,8 @@
|
|||||||
--wp-status-error-bg: #fff1f1;
|
--wp-status-error-bg: #fff1f1;
|
||||||
--wp-status-warning-bg: #fdf6dd;
|
--wp-status-warning-bg: #fdf6dd;
|
||||||
--wp-status-warning-text: #8e6a00;
|
--wp-status-warning-text: #8e6a00;
|
||||||
/* Four points from --wp-status-warning-text and doing the same job, on the
|
/* BL-009, CLOSED at T9.9 (C4): the ninth amber (--wp-status-warning-text-alt,
|
||||||
field view's warn pill. Almost certainly a typo rather than a decision, but
|
#8a6d00, four points from this one) is deleted; its consumers use this. */
|
||||||
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;
|
|
||||||
/* Carbon green-70. The value is Carbon, the role is not — Carbon has no
|
/* 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.
|
"hover for a green fill", because green is not one of its action colours.
|
||||||
Declared in no sheet today; written raw in five places. */
|
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-bg: var(--cds-support-error);
|
||||||
--wp-btn-danger-fill-fg: var(--cds-text-on-color);
|
--wp-btn-danger-fill-fg: var(--cds-text-on-color);
|
||||||
|
|
||||||
/* -- the second blue -------------------------------------------------------
|
/* BL-008, CLOSED at T9.9 (C4, approved Aug 18): the second brand blue is
|
||||||
#2563d6, not #0f62fe. Fills .sop-inherited — every field a work package
|
gone. .sop-inherited now tints with THE blue at the same 7% alpha. */
|
||||||
inherited from its SOP — at 7% alpha, which is why nobody has noticed a
|
--wp-sop-inherited-bg: rgba(15, 98, 254, .07);
|
||||||
second brand blue. Named here so it is visible; swapped at T3.5 (BL-008). */
|
|
||||||
--wp-sop-inherited-bg: rgba(37, 99, 214, .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 */
|
/* Typography */
|
||||||
|
|||||||
@@ -26,9 +26,8 @@
|
|||||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
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-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); }
|
#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
|
/* The create form is a lot of fields; let them wrap and
|
||||||
let the project picker take a full row of its own. */
|
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{ margin-top:var(--s2); }
|
||||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||||
@@ -88,7 +87,6 @@
|
|||||||
<input id="nu-email" placeholder="Email" 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-role" title="Permissions — what this account may do"></select>
|
||||||
<select id="nu-project-role" title="Job function on the project"></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>
|
||||||
<div id="nu-projects">
|
<div id="nu-projects">
|
||||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||||
@@ -102,6 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="console-util.js"></script>
|
<script src="console-util.js"></script>
|
||||||
|
<script src="wp-dialog.js"></script>
|
||||||
<script src="users.js"></script>
|
<script src="users.js"></script>
|
||||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
<!-- 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
|
page could never show a project and always read "Select a project" (F1). Must
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function boot(){
|
|||||||
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
|
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
|
||||||
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
|
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
|
||||||
if(status !== 200){
|
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.');
|
' Showing the directory read-only.');
|
||||||
} else {
|
} else {
|
||||||
renderScope();
|
renderScope();
|
||||||
@@ -85,7 +85,7 @@ async function loadUsers(){
|
|||||||
const wrap = document.getElementById('users-table');
|
const wrap = document.getElementById('users-table');
|
||||||
const { status, json } = await api('GET','/api/auth/users');
|
const { status, json } = await api('GET','/api/auth/users');
|
||||||
if(status !== 200 || !Array.isArray(json)){
|
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;
|
wrap.innerHTML = ''; return;
|
||||||
}
|
}
|
||||||
banner('users-banner','', '');
|
banner('users-banner','', '');
|
||||||
@@ -183,11 +183,12 @@ function managerRow(u){
|
|||||||
: projRoleReadonly(u, can, why);
|
: projRoleReadonly(u, can, why);
|
||||||
|
|
||||||
const actions = [];
|
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)+')">'+
|
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||||
(u.is_active?'Disable':'Enable')+'</button>');
|
(u.is_active?'Disable':'Enable')+'</button>');
|
||||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</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>');
|
// D13: no self-service action left on your own row — the domain owns the password
|
||||||
|
// and role changes are never self-applied.
|
||||||
|
if(me) actions.push('<span class="note" style="margin:0" title="Your own account">you</span>');
|
||||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||||
|
|
||||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||||
@@ -254,38 +255,32 @@ function projAccessCell(u){
|
|||||||
// ── row actions ───────────────────────────────────────────────────────────────
|
// ── row actions ───────────────────────────────────────────────────────────────
|
||||||
// Each one reloads on failure so a control can never sit there showing a value the
|
// Each one reloads on failure so a control can never sit there showing a value the
|
||||||
// server refused.
|
// 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){
|
async function toggleActive(id, makeActive){
|
||||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||||
if(status === 200) loadUsers();
|
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){
|
async function changeRole(id, role, username){
|
||||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
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();
|
loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeProjectRole(id, project_role, username){
|
async function changeProjectRole(id, project_role, username){
|
||||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
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();
|
loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id, username){
|
async function deleteUser(id, username){
|
||||||
if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
|
if(!(await wpConfirmDialog({title:'Delete user',
|
||||||
'This cannot be undone — disable the account instead if you only want to block sign-in.')) return;
|
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);
|
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||||
if(status === 200) loadUsers();
|
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 ────────────────────────────────────────────────────────────────────
|
// ── create ────────────────────────────────────────────────────────────────────
|
||||||
@@ -338,27 +333,25 @@ async function createUser(){
|
|||||||
const msg = document.getElementById('users-create-msg');
|
const msg = document.getElementById('users-create-msg');
|
||||||
const val = id => (document.getElementById(id)||{}).value || '';
|
const val = id => (document.getElementById(id)||{}).value || '';
|
||||||
const username = val('nu-username').trim();
|
const username = val('nu-username').trim();
|
||||||
const password = val('nu-password');
|
|
||||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||||
.map(c => c.value);
|
.map(c => c.value);
|
||||||
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
||||||
if(!username){ say('var(--red)','Username is required.'); return; }
|
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){
|
if(_scope.scope !== 'all' && !project_ids.length){
|
||||||
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
||||||
}
|
}
|
||||||
say('var(--muted)','Creating…');
|
say('var(--muted)','Creating…');
|
||||||
const { status, json } = await api('POST','/api/auth/users',{
|
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(),
|
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
|
||||||
role: val('nu-role'), project_role: val('nu-project-role'),
|
role: val('nu-role'), project_role: val('nu-project-role'),
|
||||||
});
|
});
|
||||||
if(status === 200){
|
if(status === 200){
|
||||||
say('var(--green)','✅ Created '+username+'.');
|
say('var(--green)','✓ Created '+username+'.');
|
||||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
|
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else {
|
} 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 +361,7 @@ async function createUser(){
|
|||||||
// more the person is on, and a save leaves those others untouched.
|
// more the person is on, and a save leaves those others untouched.
|
||||||
async function manageProjects(id, username){
|
async function manageProjects(id, username){
|
||||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
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);
|
openProjectModal(id, username, json);
|
||||||
}
|
}
|
||||||
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||||
@@ -453,7 +446,7 @@ function openProjectModal(userId, username, data){
|
|||||||
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
|
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
|
||||||
{ project_ids: ids, roles: roleMap });
|
{ project_ids: ids, roles: roleMap });
|
||||||
if(status === 200){ closeProjectModal(); loadUsers(); }
|
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
|
const nameCell = t.custom
|
||||||
? `<div style="display:flex; gap:6px; align-items:center;">
|
? `<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;">
|
<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>`
|
||||||
: `<div style="font-weight:600;">${t.name}</div>`;
|
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -669,7 +669,7 @@ function renderWPTypes(){
|
|||||||
});
|
});
|
||||||
const addRow = document.createElement('div');
|
const addRow = document.createElement('div');
|
||||||
addRow.style.cssText = 'margin-top:0.85rem;';
|
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);
|
container.appendChild(addRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +955,7 @@ function renderCustomConstraints(){
|
|||||||
<strong>${escAttr(c.name)}</strong>
|
<strong>${escAttr(c.name)}</strong>
|
||||||
<span style="display:flex; align-items:center; gap:0.75rem;">
|
<span style="display:flex; align-items:center; gap:0.75rem;">
|
||||||
${criticalToggle(c.name, true, !!c.critical)}
|
${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>
|
</span>
|
||||||
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||||
}
|
}
|
||||||
@@ -980,11 +980,13 @@ function toggleConstraint(name){
|
|||||||
function showConstraintLibrary(){
|
function showConstraintLibrary(){
|
||||||
const modal = document.getElementById('constraint-modal');
|
const modal = document.getElementById('constraint-modal');
|
||||||
const lib = document.getElementById('constraint-library');
|
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=>`
|
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>
|
<strong>${c}</strong>
|
||||||
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
|
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Add to this project</div>
|
||||||
</div>
|
</button>
|
||||||
`).join('');
|
`).join('');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
@@ -1559,7 +1561,10 @@ if(typeof WPUrl !== 'undefined'){
|
|||||||
// NAVIGATE, so Back would bounce forward again and the button would appear
|
// 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
|
// 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.
|
// 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});
|
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,13 @@ body {
|
|||||||
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||||
and T5.8's step validation use the same component. */
|
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
|
/* NAVIGATION
|
||||||
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
|
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
|
steps the proposal's beside-the-fields actions meant scrolling to save; the
|
||||||
|
|||||||
@@ -319,3 +319,20 @@
|
|||||||
the gate panel is what does the explaining. */
|
the gate panel is what does the explaining. */
|
||||||
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
|
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
|
||||||
.nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); }
|
.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-err').textContent='';
|
||||||
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
|
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
|
||||||
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
|
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
|
||||||
|
document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':'';
|
||||||
ov.classList.add('open');
|
ov.classList.add('open');
|
||||||
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
|
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
|
// prompt() said string or null; so does this - and a validate() answer renders
|
||||||
// AT the input instead of round-tripping through another dialog.
|
// AT the input instead of round-tripping through another dialog.
|
||||||
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
|
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=>{
|
document.addEventListener('keydown', e=>{
|
||||||
const ov=document.getElementById('wp-dialog');
|
const ov=document.getElementById('wp-dialog');
|
||||||
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
|
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
|
||||||
@@ -435,7 +440,7 @@ function applySOP(){
|
|||||||
applyKind();
|
applyKind();
|
||||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
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(); }
|
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||||||
updateNumber(); updateReleaseBanner();
|
updateNumber(); updateReleaseBanner();
|
||||||
// CR-006. Last, after every builder has rendered its card — applying it earlier
|
// 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'; };
|
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||||||
show('kind-row', bimProj);
|
show('kind-row', bimProj);
|
||||||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
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('material-card', !ewp); // bill of materials
|
||||||
show('mimo-card', !ewp); // kitting / MIMO
|
show('mimo-card', !ewp); // kitting / MIMO
|
||||||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
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('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
|
||||||
el.classList.toggle('locked-field', false);
|
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');
|
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])}` : '';
|
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
|
||||||
}
|
}
|
||||||
function editQuality(id){
|
function editQuality(id){
|
||||||
@@ -542,15 +547,20 @@ function editQuality(id){
|
|||||||
}
|
}
|
||||||
function renderCtxBar(){
|
function renderCtxBar(){
|
||||||
const bar=document.getElementById('ctx-bar');
|
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){
|
if(!SOP){
|
||||||
bar.innerHTML = activeProjectId
|
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 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.</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;
|
return;
|
||||||
}
|
}
|
||||||
const p=SOP.project||{}, g=SOP.governance||{};
|
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>`:'';
|
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-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>`;
|
<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();
|
const srcs=sopLinkedSources();
|
||||||
if(!srcs.length){ box.innerHTML=''; return; }
|
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>`+
|
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(){
|
function renderSpecFolderLink(){
|
||||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
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.textContent = 'None — this package can be released as soon as its constraints are cleared.';
|
||||||
el.style.color = '';
|
el.style.color = '';
|
||||||
} else if(blocking.length){
|
} 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.';
|
'. Release is gated until they are Closed.';
|
||||||
el.style.color = 'var(--red)';
|
el.style.color = 'var(--red)';
|
||||||
} else {
|
} 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');
|
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
|
||||||
// Interim: assets are linked manually back to controls.dev. A future direct
|
// Assets are picked from the Micron asset catalog, a SQL Server database this app
|
||||||
// integration will let the user pick them from a list instead of pasting links.
|
// 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(){
|
function buildAssets(){
|
||||||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||||||
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
if(!pkgAssets.length){
|
||||||
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
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>`;
|
||||||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
return;
|
||||||
<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>`;
|
pkgAssets.forEach((a,i)=>{
|
||||||
tb.appendChild(tr); });
|
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 ──────────────────────────────────────────────────────────────
|
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||||||
function buildAttach(){
|
function buildAttach(){
|
||||||
@@ -1241,7 +1580,7 @@ function wpFilesMirror(){
|
|||||||
function wpFilesRender(){
|
function wpFilesRender(){
|
||||||
const box=document.getElementById('wp-file-list'); if(!box) return;
|
const box=document.getElementById('wp-file-list'); if(!box) return;
|
||||||
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
|
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>
|
<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"
|
<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)">
|
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
|
||||||
@@ -1310,7 +1649,7 @@ function renderSopFileFolders(){
|
|||||||
const srcs=sopLinkedSources();
|
const srcs=sopLinkedSources();
|
||||||
box.innerHTML = srcs.length
|
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="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>`;
|
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||||
}
|
}
|
||||||
function toggleSopFilePanel(){
|
function toggleSopFilePanel(){
|
||||||
@@ -1417,7 +1756,7 @@ function readiness(){
|
|||||||
function updateReleaseBanner(){
|
function updateReleaseBanner(){
|
||||||
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
||||||
let cls, txt;
|
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'){
|
else if(st==='Ready for QA'){
|
||||||
// CR-014: the QA decision lives where the state is announced. Accept moves to
|
// 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
|
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
|
||||||
@@ -1764,6 +2103,10 @@ function collectPackage(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function savePackage(view){
|
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;
|
if(!wpValidateForm()) return;
|
||||||
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
// 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
|
// 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
|
// because "the toggle does nothing" and "the toggle governs one row" look the
|
||||||
// same from outside.
|
// 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){
|
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>`;
|
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><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`);
|
||||||
add('assets', 'Assets (controls.dev)', t+`</tbody></table>`);
|
add('assets', 'Assets', t+`</tbody></table>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let scopeHtml;
|
let scopeHtml;
|
||||||
@@ -1935,7 +2280,11 @@ function renderPackage(pkg){
|
|||||||
}
|
}
|
||||||
function printPackage(){
|
function printPackage(){
|
||||||
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
|
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();
|
w.document.close(); w.print();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2585,12 +2934,12 @@ const WP_NAV_CRITICAL_CSS = `
|
|||||||
body{--nav-w:288px;}
|
body{--nav-w:288px;}
|
||||||
body.wp-nav-collapsed{--nav-w:56px;}
|
body.wp-nav-collapsed{--nav-w:56px;}
|
||||||
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
|
.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;
|
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:var(--wp-nav-bg);
|
||||||
border-right:1px solid #e0e0e0;}
|
border-right:1px solid var(--cds-border-subtle);}
|
||||||
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
|
.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%;
|
.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;}
|
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;}
|
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-body{min-width:0;flex:1 1 auto;}
|
||||||
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
.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
|
// 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
|
// 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.
|
// it doesn't shuffle when a package is added or deleted.
|
||||||
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
|
const WP_BADGE_COLORS = (() => {
|
||||||
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
|
// 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){
|
function wpBadgeColor(p){
|
||||||
const key = (p.number || p.id || '') + '';
|
const key = (p.number || p.id || '') + '';
|
||||||
let h = 0;
|
let h = 0;
|
||||||
@@ -2790,7 +3150,7 @@ function renderWpNav(){
|
|||||||
const h = live[live.length - 1];
|
const h = live[live.length - 1];
|
||||||
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
|
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
|
||||||
: 'no reason recorded — log it from the status control';
|
: '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>';
|
+ esc(why) + '</span>';
|
||||||
}
|
}
|
||||||
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
||||||
@@ -2911,7 +3271,18 @@ function loadPackageIntoForm(p){
|
|||||||
onClashChange();
|
onClashChange();
|
||||||
applyKind();
|
applyKind();
|
||||||
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
|
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);
|
set('wp_wbs',p.wbs);
|
||||||
document.getElementById('wp_kit_status').value=p.kitStatus||'';
|
document.getElementById('wp_kit_status').value=p.kitStatus||'';
|
||||||
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
|
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'));
|
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
||||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||||
// collections
|
// 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();
|
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();
|
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();
|
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');
|
setRadio('status','Draft');
|
||||||
numberDims={}; buildNumberDims();
|
numberDims={}; buildNumberDims();
|
||||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||||
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
|
pkgAssets=[]; buildAssets();
|
||||||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||||
pkgWorkSteps=['']; buildWorkSteps();
|
pkgWorkSteps=['']; buildWorkSteps();
|
||||||
@@ -3406,10 +3777,22 @@ function renderDashboard(){
|
|||||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||||
${card('Est. hrs', Math.round(estH))}
|
${card('Est. hrs', Math.round(estH))}
|
||||||
${card('Actual hrs', Math.round(actH))}
|
${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>`;
|
</div>`;
|
||||||
|
|
||||||
// status + discipline breakdown chips (status chips also filter the board)
|
// 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('')
|
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'):'');
|
+ (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>';
|
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
|
// gating panel — what's blocking release, from the server
|
||||||
const gated=m.gating||[];
|
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>`+
|
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>
|
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('')+
|
<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('')+
|
||||||
@@ -3633,7 +4016,9 @@ async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filte
|
|||||||
if(!mine){ toast('No comments to clear.', 'alert'); return; }
|
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;
|
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(); }
|
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 ─────────────────────────────────────────────────────────────
|
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
||||||
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
||||||
@@ -3830,6 +4215,16 @@ function bootData(){
|
|||||||
loadLocations(); // CR-004: the option lists come from the server
|
loadLocations(); // CR-004: the option lists come from the server
|
||||||
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||||||
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
|
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();
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
|
|||||||
@@ -303,12 +303,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ASSETS (controls.dev) -->
|
<!-- ASSETS (Micron asset catalog) -->
|
||||||
<div class="card" id="asset-card">
|
<div class="card" id="asset-card">
|
||||||
<div class="sub-heading">Assets</div>
|
<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 managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
|
<div class="notice">Every work package is based on one or more assets 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="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
<div class="asset-pick" id="asset-pick">
|
||||||
<button class="add-btn" onclick="addAsset()">+ Add asset</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- DISCIPLINES -->
|
<!-- DISCIPLINES -->
|
||||||
@@ -440,12 +452,12 @@
|
|||||||
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
<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="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>
|
||||||
<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="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 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>
|
</div>
|
||||||
|
|
||||||
@@ -476,7 +488,7 @@
|
|||||||
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
||||||
<div style="display:flex;gap:10px">
|
<div style="display:flex;gap:10px">
|
||||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
<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>
|
</div></div>
|
||||||
|
|
||||||
<!-- DASHBOARD -->
|
<!-- DASHBOARD -->
|
||||||
@@ -591,7 +603,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COMMENTS DRAWER -->
|
<!-- 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">
|
<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-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>
|
<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 +628,7 @@
|
|||||||
<span class="sticky-status" id="sticky-status"></span>
|
<span class="sticky-status" id="sticky-status"></span>
|
||||||
<div class="sticky-actions">
|
<div class="sticky-actions">
|
||||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
after .main is what lets it be a sticky column without wrapping the layout. */
|
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; }
|
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
|
||||||
.main { min-width: 0; max-width: none; 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; }
|
transition: padding-left .18s ease; }
|
||||||
|
|
||||||
.section { display: none; }
|
.section { display: none; }
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||||
|
|
||||||
/* ── NAV ── */
|
/* ── 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 {
|
.btn {
|
||||||
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
|
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;
|
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; }
|
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||||
|
|
||||||
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
||||||
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
|
.ctx-bar { max-width: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; }
|
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
||||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||||
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
||||||
@@ -575,7 +575,7 @@
|
|||||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||||
.sop-hint { color:var(--accent) !important; }
|
.sop-hint { color:var(--accent) !important; }
|
||||||
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
|
.release-banner { max-width: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; }
|
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-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); }
|
.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) */
|
/* Dev mode (comment 7) */
|
||||||
.logo-wrap { position:relative; display:flex; align-items:center; }
|
.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; }
|
background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; }
|
||||||
.dev-toggle:hover { opacity:0.35; }
|
.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; }
|
.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
|
/* 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 -
|
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. */
|
which is a third of what F6 was measuring, arriving by a different door. */
|
||||||
|
/* 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 { padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
}
|
||||||
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
||||||
.card.collapsed .sub-heading { margin-bottom: 0; }
|
.card.collapsed .sub-heading { margin-bottom: 0; }
|
||||||
|
|
||||||
@@ -906,6 +914,38 @@
|
|||||||
border:1px solid var(--border); border-radius:3px; }
|
border:1px solid var(--border); border-radius:3px; }
|
||||||
.pp-free .field-hint { margin-top:4px; }
|
.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) */
|
/* Critical constraint marker (from the SOP) */
|
||||||
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
|
.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);
|
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] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||||
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||||
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||||
.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-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-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; }
|
.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;' +
|
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,' +
|
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
|
||||||
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
'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 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:#525252;margin:14px 0 4px;font-weight:600;';
|
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:#6f6f6f;margin-bottom:6px;';
|
var hint = 'font-size:11.5px;color:var(--cds-text-helper);margin-bottom:6px;';
|
||||||
ov.innerHTML =
|
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="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 #e0e0e0;font-weight:700;">Language & time</div>' +
|
'<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 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>' +
|
'<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>' +
|
'<label style="' + lbl + '">Language & number format</label>' +
|
||||||
@@ -135,19 +135,19 @@
|
|||||||
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
||||||
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
||||||
'Calendar dates like a due date are never shifted.</div>' +
|
'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>' +
|
||||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
'<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 #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
'<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:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</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>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
|
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
|
||||||
function msg(text, ok) {
|
function msg(text, ok) {
|
||||||
var e = document.getElementById('wp-prefs-msg');
|
var e = document.getElementById('wp-prefs-msg');
|
||||||
e.style.display = 'block'; e.textContent = text;
|
e.style.display = 'block'; e.textContent = text;
|
||||||
e.style.background = ok ? '#defbe6' : '#fff1f1';
|
e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)';
|
||||||
e.style.color = ok ? '#0e6027' : '#da1e28';
|
e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
|
||||||
}
|
}
|
||||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||||
document.body.appendChild(ov);
|
document.body.appendChild(ov);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{ id: 'scope', label: 'Scope of Work',
|
{ id: 'scope', label: 'Scope of Work',
|
||||||
note: 'The ordered steps the crew performs, and the labour estimate.' },
|
note: 'The ordered steps the crew performs, and the labour estimate.' },
|
||||||
{ id: 'assets', label: 'Assets',
|
{ 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',
|
{ id: 'materials', label: 'Materials',
|
||||||
note: 'The bill of materials that feeds kitting.' },
|
note: 'The bill of materials that feeds kitting.' },
|
||||||
{ id: 'kitting', label: 'Kitting',
|
{ id: 'kitting', label: 'Kitting',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
{ section: 'People' },
|
{ section: 'People' },
|
||||||
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
||||||
sub: 'Who\'s on the project' },
|
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',
|
sub: 'Settings & diagnostics',
|
||||||
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
||||||
// Account actions, inherited from the flat user menu that used to sit in the app
|
// 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
|
// 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.
|
// rather than being lost with it. `action` items render as buttons, not links.
|
||||||
{ section: 'Account' },
|
{ section: 'Account' },
|
||||||
{ action: 'wpPreferences', icon: '⌚', label: 'Language & time',
|
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
||||||
sub: 'Dates, numbers and time zone' },
|
sub: 'Dates, numbers and time zone' },
|
||||||
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function esc(v) {
|
function esc(v) {
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
|||||||
# How long a login lasts before re-authentication (hours). Default 12.
|
# How long a login lasts before re-authentication (hours). Default 12.
|
||||||
# AUTH_SESSION_HOURS=12
|
# AUTH_SESSION_HOURS=12
|
||||||
|
|
||||||
|
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
|
||||||
|
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
|
||||||
|
# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
|
||||||
|
# is deliberately no local break-glass account (decided Aug 21 2026 — see
|
||||||
|
# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
|
||||||
|
# the API logs one line saying whether LDAP is configured and reachable.
|
||||||
|
#
|
||||||
|
# Connect to the DOMAIN NAME, never a DC hostname and never an IP. Every DC's
|
||||||
|
# certificate carries `prime.local` in its SAN, so the domain name both passes
|
||||||
|
# hostname validation and round-robins across all six DCs. An IP fails with
|
||||||
|
# `hostname mismatch` (there is no IP SAN) and the only way to force it through is
|
||||||
|
# to disable validation, which must not happen — domain passwords cross this link.
|
||||||
|
# LDAP_DOMAIN=prime.local
|
||||||
|
# LDAP_HOST=prime.local
|
||||||
|
# LDAP_PORT=636
|
||||||
|
|
||||||
|
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
|
||||||
|
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
|
||||||
|
# app, nothing to request from IT. The repo ships a verified copy and the default
|
||||||
|
# points at it, so you only set this to override with a mounted file.
|
||||||
|
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
|
||||||
|
|
||||||
|
# An AD group required to sign in. Empty means every domain account may sign in.
|
||||||
|
# This is the INITIAL value and the fallback; the live value is set in the Admin
|
||||||
|
# console, which refuses to save a group that does not resolve or that the saving
|
||||||
|
# admin is not a member of. Nested groups count.
|
||||||
|
# LDAP_REQUIRED_GROUP=WP-Suite-Users
|
||||||
|
|
||||||
|
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
|
||||||
|
# apply to a rejected password — each failed bind counts against the domain lockout
|
||||||
|
# policy, so guessing would lock real accounts out of Windows.
|
||||||
|
# LDAP_TIMEOUT_SECONDS=8
|
||||||
|
# LDAP_CONNECT_RETRIES=2
|
||||||
|
|
||||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||||
# console (Notifications & email card), where the SMTP host/port/from-address
|
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||||
@@ -30,3 +64,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
|||||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||||
# on and SMTP is configured.
|
# on and SMTP is configured.
|
||||||
# SMTP_PASSWORD=your-smtp-app-password
|
# 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.
|
||||||
|
|||||||
118
server/README.md
@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
|
|||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
| POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
|
||||||
| POST | `/api/auth/logout` | clear the session cookie |
|
| POST | `/api/auth/logout` | clear the session cookie |
|
||||||
| GET | `/api/auth/me` | the logged-in user |
|
| GET | `/api/auth/me` | the logged-in user |
|
||||||
| POST | `/api/auth/password` | change your own password |
|
|
||||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
| POST | `/api/auth/users` | pre-create an account (**admin**) — optional; accounts self-provision on first sign-in |
|
||||||
|
| POST | `/api/auth/users/{id}/role` | change an account's permissions role (**admin**) |
|
||||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||||
| GET | `/api/sops` | list SOP summaries |
|
| GET | `/api/sops` | list SOP summaries |
|
||||||
@@ -40,9 +40,14 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Login portal (user accounts)
|
## Sign-in (domain authentication, D13)
|
||||||
|
|
||||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
**The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
|
||||||
|
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
|
||||||
|
person typed — their Windows password. A successful bind is the authentication.
|
||||||
|
See `server/ldap_auth.py`; the schema has no `password_hash` column.
|
||||||
|
|
||||||
|
Sign-in issues a signed JWT
|
||||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||||
@@ -53,8 +58,43 @@ 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
|
`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.
|
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
|
**The directory supplies identity; this app supplies authorization.** Roles live in
|
||||||
`admin` (may manage users) and `user`.
|
the local `users` table and are never read from AD — so an existing admin stays an
|
||||||
|
admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
|
||||||
|
|
||||||
|
**Accounts are created on first successful sign-in.** Anyone who binds successfully
|
||||||
|
and is in the required group gets a `users` row at `project_user` with **no project
|
||||||
|
access** — they can sign in and will see nothing until an admin grants access. That
|
||||||
|
is least privilege, and it is deliberate; the creation is written to the audit log
|
||||||
|
so it is visible rather than silent.
|
||||||
|
|
||||||
|
**A required AD group gates sign-in.** `LDAP_REQUIRED_GROUP` (a group name or a full
|
||||||
|
DN; nested groups count). Empty means any domain account may sign in.
|
||||||
|
|
||||||
|
**There is no password reset and no break-glass.** The login page links to
|
||||||
|
`https://primecontrols.okta.com/` for password self-service. If the domain is
|
||||||
|
unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
||||||
|
**nobody can sign in, including admins** — the API logs one line at startup saying
|
||||||
|
whether LDAP is configured and reachable, so check `docker compose logs api` first.
|
||||||
|
|
||||||
|
**Connect to the domain name, never a DC hostname or an IP.** Every DC certificate
|
||||||
|
carries `prime.local` in its SAN, so the domain name both passes hostname validation
|
||||||
|
and round-robins across all six DCs. An IP fails with `hostname mismatch` — there is
|
||||||
|
no IP SAN — and the only way to force it through is to disable validation, which
|
||||||
|
must never happen: domain passwords cross this link.
|
||||||
|
|
||||||
|
**The trust anchor is a CA certificate, not one issued to this app.** The API is the
|
||||||
|
TLS *client*, and clients present nothing. `server/certs/prime-ca-chain.pem` holds
|
||||||
|
`PRIME CONTROLS ROOT CA` + `PRIME CONTROLS ISSUING CA 1` — public certificates, no
|
||||||
|
private key, nothing to request from IT. Override the path with `LDAP_CA_FILE`.
|
||||||
|
|
||||||
|
Diagnose the connection without touching an account (no bind, so it cannot
|
||||||
|
contribute to a lockout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
||||||
|
# want: Verify return code: 0 (ok)
|
||||||
|
```
|
||||||
|
|
||||||
### Set the signing secret
|
### Set the signing secret
|
||||||
|
|
||||||
@@ -65,30 +105,70 @@ without it the API uses a random per-process key, so logins reset on restart.
|
|||||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create the first admin
|
### Bootstrap the first admin
|
||||||
|
|
||||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
Two steps, in this order. There is no `create-admin` any more — there is no password
|
||||||
shell (run from the **project root**, like uvicorn):
|
to set and no account to create.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
# 1. Sign in to the app once. That provisions your account at project_user.
|
||||||
# prompts for a password (min 8 chars)
|
# 2. Promote it:
|
||||||
|
docker compose exec api python -m server.manage_users promote alice
|
||||||
```
|
```
|
||||||
|
|
||||||
In Docker:
|
It prompts for **your** domain username and password, binds to confirm who you are,
|
||||||
|
and prints `alice: project_user -> admin`.
|
||||||
|
|
||||||
```bash
|
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
|
||||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
|
||||||
```
|
console.
|
||||||
|
|
||||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
**Every command that changes anything requires a domain bind** (D14), prompted —
|
||||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
there is deliberately no `--password` flag, which would put a live domain password
|
||||||
API (or you can keep using the CLI).
|
into shell history and `ps` output. `list` needs no credential so an outage stays
|
||||||
|
diagnosable. The bind here does **not** apply the required-group gate, so a mistyped
|
||||||
|
group cannot lock you out of the tool that fixes it.
|
||||||
|
|
||||||
|
Be clear on what the bind is worth: anyone with a shell here can still write to the
|
||||||
|
`users` table with `psql`. It is defence in depth and, mostly, **accountability** —
|
||||||
|
every role change now writes an audit row naming a person, which shell changes
|
||||||
|
previously did not.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
|
> ### A SQLite database created before D13 will reject new sign-ins
|
||||||
|
>
|
||||||
|
> `Base.metadata.create_all()` creates missing tables; it never alters existing ones.
|
||||||
|
> So a `wpsuite.db` built before D13 still has `users.password_hash` declared
|
||||||
|
> `NOT NULL` with no default, while the current model has no such column — and an
|
||||||
|
> INSERT that omits it is rejected:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> IntegrityError: NOT NULL constraint failed: users.password_hash
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Accounts already in the file keep working, so **you** can sign in and nothing looks
|
||||||
|
> wrong. It breaks the moment a *new* person signs in, because provisioning them is an
|
||||||
|
> INSERT — and it surfaces as an HTTP **500**, not a 401, so it reads as a server fault
|
||||||
|
> rather than anything to do with the schema.
|
||||||
|
>
|
||||||
|
> Such a database also has no `alembic_version` table, so `alembic upgrade head` would
|
||||||
|
> try to replay the baseline against tables that already exist. Stamp it first:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> python -m alembic -c server/alembic.ini stamp a1b8c6d4e2f9 # the revision before the drop
|
||||||
|
> python -m alembic -c server/alembic.ini upgrade head # runs only the drop
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> That keeps whatever is in the file. Deleting the database also works and
|
||||||
|
> `create_all()` rebuilds a correct schema, but it throws away your test data.
|
||||||
|
>
|
||||||
|
> Production is unaffected: it runs Postgres and the container applies migrations at
|
||||||
|
> start, so the column is dropped properly there.
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ def upgrade() -> None:
|
|||||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||||
sa.Column('description', sa.String(length=300), 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('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'). sa.text() emits raw SQL, and Postgres refuses
|
||||||
|
# an integer default on a boolean column: "column active is of type boolean
|
||||||
|
# but default expression is of type integer". SQLite accepts 1 happily, so
|
||||||
|
# this passed every local test and failed only on the real engine. The
|
||||||
|
# sibling migration e2a4c7d91b30 does the identical column correctly.
|
||||||
|
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""drop users.password_hash — D13 / T10.3
|
||||||
|
|
||||||
|
Authentication moved to an LDAPS simple bind against the domain (see
|
||||||
|
server/ldap_auth.py), so the suite no longer holds a credential of any kind.
|
||||||
|
|
||||||
|
THIS MIGRATION DESTROYS DATA AND CANNOT BE UNDONE IN ANY MEANINGFUL SENSE.
|
||||||
|
`downgrade()` recreates the column, but every hash in it is gone — and even a
|
||||||
|
restored hash would be useless, because nothing reads the column any more. The
|
||||||
|
downgrade exists so the revision is well-formed and so an operator can step back
|
||||||
|
past it, not because stepping back restores the old login. To actually revert to
|
||||||
|
local passwords you have to revert the application code and reset every password
|
||||||
|
by hand.
|
||||||
|
|
||||||
|
The column is recreated NULLABLE on downgrade, deliberately. The baseline schema
|
||||||
|
declared it NOT NULL, but there are no values to put back, so a NOT NULL column
|
||||||
|
with no server default would refuse to add itself on any table that has rows.
|
||||||
|
|
||||||
|
DO NOT WRAP THIS IN batch_alter_table. An earlier version of this migration did,
|
||||||
|
and it silently deleted every row of `project_members` on SQLite.
|
||||||
|
|
||||||
|
Why: alembic's batch mode emulates ALTER on SQLite by rebuilding the table —
|
||||||
|
create a new one, copy the rows, DROP the original, rename. `server/alembic/env.py`
|
||||||
|
imports the engine from `server/db.py`, which registers a `connect` listener setting
|
||||||
|
`PRAGMA foreign_keys=ON`, so that DROP TABLE cascades through
|
||||||
|
`project_members.user_id`, which is declared `ondelete="CASCADE"`. Every project
|
||||||
|
membership in the database goes with it, with no error and nothing in the log.
|
||||||
|
|
||||||
|
Turning the pragma off around the batch is not a fix either: `PRAGMA foreign_keys`
|
||||||
|
is a no-op inside a transaction, and alembic runs migrations in one.
|
||||||
|
|
||||||
|
The real answer is that the rebuild is unnecessary. SQLite gained native
|
||||||
|
ALTER TABLE ... DROP COLUMN in 3.35 (2021); this runtime has 3.42 and Postgres has
|
||||||
|
always had it. A plain drop_column touches one table and cascades nowhere.
|
||||||
|
|
||||||
|
Revision ID: b7e4f1a20c93
|
||||||
|
Revises: a1b8c6d4e2f9
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = 'b7e4f1a20c93'
|
||||||
|
down_revision = 'a1b8c6d4e2f9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Plain, un-batched, on both engines. See the docstring: batching this destroys
|
||||||
|
# project_members on SQLite.
|
||||||
|
op.drop_column('users', 'password_hash')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.add_column('users', sa.Column('password_hash', sa.String(length=200),
|
||||||
|
nullable=True))
|
||||||
503
server/app.py
@@ -9,6 +9,8 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
|||||||
Interactive docs: http://<host>/api/docs
|
Interactive docs: http://<host>/api/docs
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
@@ -26,7 +28,11 @@ from sqlalchemy import select, delete, func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .db import Base, engine, get_db
|
from .db import Base, engine, get_db
|
||||||
from . import models, auth, notify
|
from . import models, auth, notify, ldap_auth, assets_db
|
||||||
|
|
||||||
|
# Same naming as the other modules' loggers (wpsuite.auth / .ldap / .notify), so a
|
||||||
|
# deployment can raise the level on one subsystem without raising it on all of them.
|
||||||
|
log = logging.getLogger("wpsuite.api")
|
||||||
|
|
||||||
# Schema management:
|
# Schema management:
|
||||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||||
@@ -39,7 +45,30 @@ if engine.dialect.name == "sqlite":
|
|||||||
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
|
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
|
||||||
# so enable them only on the SQLite dev fallback (production runs on Postgres).
|
# so enable them only on the SQLite dev fallback (production runs on Postgres).
|
||||||
_docs_enabled = engine.dialect.name == "sqlite"
|
_docs_enabled = engine.dialect.name == "sqlite"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lifespan(_app):
|
||||||
|
"""Say, once, whether anyone can sign in at all.
|
||||||
|
|
||||||
|
D13 removed the local password path and left no break-glass, so a broken LDAP
|
||||||
|
configuration and a forgotten password look identical at the login box. This
|
||||||
|
line is what tells an operator which one they have, and DEPLOYMENT.md,
|
||||||
|
DEPLOY-login-portal.md and server/README.md all send people here first:
|
||||||
|
|
||||||
|
docker compose logs api | grep -i "LDAP auth"
|
||||||
|
|
||||||
|
Configuration only — it opens no connection and binds nothing, so startup stays
|
||||||
|
fast and cannot be made to hang by an unreachable domain controller. Use
|
||||||
|
`ldap_auth.selftest()` for a reachability check; it validates the certificate
|
||||||
|
without binding, so it cannot contribute to a lockout either.
|
||||||
|
"""
|
||||||
|
log.info("%s", ldap_auth.describe())
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
lifespan=_lifespan,
|
||||||
title="Work Package Suite API",
|
title="Work Package Suite API",
|
||||||
docs_url="/api/docs" if _docs_enabled else None,
|
docs_url="/api/docs" if _docs_enabled else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
@@ -458,15 +487,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
|||||||
return (base + path) if base else path
|
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:
|
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
|
# Number, title and location — customer context, allowed since the
|
||||||
# customer IP inside the app, behind login).
|
# 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
|
who = actor.full_name or actor.username
|
||||||
name = assignee.full_name or assignee.username
|
name = assignee.full_name or assignee.username
|
||||||
return (
|
return (
|
||||||
f"Hi {name},\n\n"
|
f"Hi {name},\n\n"
|
||||||
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
|
f"{who} assigned you a work package: {wp_titled(wp)}.\n"
|
||||||
f"Open the Work Package Suite to view and action it:\n{link}\n\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."
|
f"— This is an automated message from the Work Package Suite."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -585,8 +640,10 @@ class LoginIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NewUserIn(BaseModel):
|
class NewUserIn(BaseModel):
|
||||||
|
# No password: D13 authenticates against the domain, so an administrator
|
||||||
|
# pre-creating an account only supplies identity and authorization. The person
|
||||||
|
# signs in with their Windows password, or is provisioned on first sign-in.
|
||||||
username: str
|
username: str
|
||||||
password: str
|
|
||||||
full_name: str = ""
|
full_name: str = ""
|
||||||
email: str = ""
|
email: str = ""
|
||||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||||
@@ -608,24 +665,6 @@ class PreferencesIn(BaseModel):
|
|||||||
timezone: Optional[str] = None
|
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):
|
class ActiveIn(BaseModel):
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
@@ -648,42 +687,214 @@ class AutoAddIn(BaseModel):
|
|||||||
role: str = ""
|
role: str = ""
|
||||||
|
|
||||||
|
|
||||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
# D13 / T10.2 — THIS NUMBER IS NOT A UX PREFERENCE, IT IS A SAFETY LIMIT.
|
||||||
|
#
|
||||||
|
# Failures are now LDAP binds, so every one counts against the DOMAIN account
|
||||||
|
# lockout policy. This estate's AD threshold is 5. The throttle below is
|
||||||
|
# per-process and the API runs 2 gunicorn workers, so a local limit of N lets up
|
||||||
|
# to 2N binds reach a domain controller: 2 x 2 = 4, one under the threshold.
|
||||||
|
#
|
||||||
|
# It defaulted to 5 before this task, which would have allowed up to 10 binds and
|
||||||
|
# locked the account out of WINDOWS — twice over — before the local lockout ever
|
||||||
|
# engaged. If you raise this, or add a worker, redo the arithmetic first.
|
||||||
|
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "2"))
|
||||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
||||||
|
|
||||||
|
# Pre-account throttle, keyed by USERNAME (not by client IP — an attacker rotating
|
||||||
|
# IPs would sail past an IP-keyed limit, and it is the domain account we are
|
||||||
|
# protecting, not this endpoint's capacity).
|
||||||
|
#
|
||||||
|
# The DB counter on `users` is shared across workers and persists, but it can only
|
||||||
|
# work for an account that already has a local row. Under D13 accounts are created
|
||||||
|
# on first successful login, so a REAL domain account can be hammered before any
|
||||||
|
# row exists — this dict covers exactly that window. Per-worker and lost on
|
||||||
|
# restart, which is why LOGIN_MAX_ATTEMPTS is halved rather than trusted.
|
||||||
|
_bind_attempts: dict[str, list[float]] = {}
|
||||||
|
_BIND_WINDOW_SECONDS = LOGIN_LOCKOUT_MINUTES * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_throttled(sam: str) -> bool:
|
||||||
|
"""True if this username has already used its bind budget in the window.
|
||||||
|
Checked BEFORE any call to the directory."""
|
||||||
|
now = monotonic()
|
||||||
|
key = (sam or "").strip().lower()
|
||||||
|
hits = [t for t in _bind_attempts.get(key, []) if now - t < _BIND_WINDOW_SECONDS]
|
||||||
|
_bind_attempts[key] = hits
|
||||||
|
if len(_bind_attempts) > 5000: # bound the dict on a long-lived worker
|
||||||
|
for k in [k for k, v in _bind_attempts.items()
|
||||||
|
if not v or now - max(v) > _BIND_WINDOW_SECONDS]:
|
||||||
|
_bind_attempts.pop(k, None)
|
||||||
|
return len(hits) >= LOGIN_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def _record_bind_failure(sam: str) -> None:
|
||||||
|
_bind_attempts.setdefault((sam or "").strip().lower(), []).append(monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_bind_failures(sam: str) -> None:
|
||||||
|
_bind_attempts.pop((sam or "").strip().lower(), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_directory_account(db: Session, result) -> Optional[models.User]:
|
||||||
|
"""Find the local row for a directory identity — D13 criterion 4.
|
||||||
|
|
||||||
|
Matched on `sAMAccountName` OR the directory's `mail`, because existing accounts
|
||||||
|
were created by hand with `manage_users.py` and some were typed as short logon
|
||||||
|
names while others were typed as email addresses. Matching on both is what keeps
|
||||||
|
an existing admin's role instead of handing them a second, default-role account.
|
||||||
|
|
||||||
|
`auth.find_user` already compares case-insensitively against username AND email,
|
||||||
|
so each call covers two columns; the second call is for the case where the local
|
||||||
|
username is the person's address and the directory only told us their sAMAccountName.
|
||||||
|
"""
|
||||||
|
user = auth.find_user(db, result.sam)
|
||||||
|
if user is None and result.mail:
|
||||||
|
user = auth.find_user(db, result.mail)
|
||||||
|
if user is not None:
|
||||||
|
log.info("matched directory identity %r to existing local account %r by mail",
|
||||||
|
result.sam, user.username)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_from_directory(db: Session, result) -> models.User:
|
||||||
|
"""Create a local account for someone who just authenticated and has no row.
|
||||||
|
|
||||||
|
Lands at `project_user` with NO project memberships. That is least privilege and
|
||||||
|
it is deliberate, but it means the person signs in successfully into an empty
|
||||||
|
app until an admin grants access — so it is written to the audit log rather than
|
||||||
|
happening silently. `auto_add_projects` cannot help here: it is evaluated when a
|
||||||
|
PROJECT is created, to mark who joins every new job, and cannot retroactively add
|
||||||
|
a new account to jobs that already exist.
|
||||||
|
"""
|
||||||
|
u = models.User(
|
||||||
|
id=gen_id("user"),
|
||||||
|
username=result.sam,
|
||||||
|
email=result.mail or "",
|
||||||
|
full_name=result.full_name or "",
|
||||||
|
role=auth.ROLE_PROJECT_USER,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
db.flush() # see the flush-order note in models.py's docstring
|
||||||
|
log_event(db, u.username, "user_provisioned", "user", u.id, summary=u.username,
|
||||||
|
detail={"source": "directory", "role": u.role, "upn": result.upn,
|
||||||
|
"projects": 0, "note": "created on first successful sign-in"})
|
||||||
|
log.info("provisioned local account %r from the directory at role %r with no "
|
||||||
|
"project access — an admin must grant access before they see anything",
|
||||||
|
u.username, u.role)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
"""Authenticate against the domain (D13 / T10.2) and set the session cookie.
|
||||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
|
||||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
The suite stores no passwords: this is an LDAPS simple bind as
|
||||||
user = auth.find_user(db, body.username)
|
`<sAMAccountName>@prime.local`, and a successful bind IS the authentication.
|
||||||
|
See server/ldap_auth.py for the transport and its two hard guards.
|
||||||
|
|
||||||
|
ORDER MATTERS HERE. Every throttle check runs BEFORE the directory is touched,
|
||||||
|
because a failed bind counts against the DOMAIN lockout policy — so this
|
||||||
|
endpoint must not be usable to lock a colleague out of Windows. Nothing below
|
||||||
|
reaches `ldap_auth.verify` until the local budget has been checked twice: once
|
||||||
|
against the persistent per-account counter, once against the pre-account
|
||||||
|
window that covers usernames with no local row yet.
|
||||||
|
|
||||||
|
Three outcomes, deliberately distinguished:
|
||||||
|
401 the credential was rejected, or the account is not in the required
|
||||||
|
group. One generic message for every case — the response must never
|
||||||
|
reveal whether an account exists (see `_ERR49` in ldap_auth: the useful
|
||||||
|
detail goes to the log).
|
||||||
|
403 the local account exists and is disabled. Independent of the directory.
|
||||||
|
503 OUR fault — LDAP unconfigured, unreachable, untrusted, or the required
|
||||||
|
group does not resolve. D13 left no password fallback, so this must not
|
||||||
|
masquerade as 401: "your password is wrong" sends people hunting for a
|
||||||
|
password they no longer have, while the real problem is a broken deploy.
|
||||||
|
"""
|
||||||
now = models.utcnow()
|
now = models.utcnow()
|
||||||
# Always run the hash comparison first — even for missing or locked accounts —
|
sam = ldap_auth.normalize_username(body.username)
|
||||||
# so response timing doesn't leak which usernames exist. verify_password
|
if not sam or not (body.password or "").strip():
|
||||||
# tolerates an empty hash.
|
# No directory call for empty input. ldap_auth.verify guards this too; the
|
||||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
# duplication is intentional, since an empty password would otherwise be an
|
||||||
|
# anonymous bind and anonymous binds SUCCEED.
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
user = auth.find_user(db, sam)
|
||||||
|
|
||||||
|
# ── throttle 1: the persistent per-account lockout ────────────────────────
|
||||||
locked = user.locked_until if user else None
|
locked = user.locked_until if user else None
|
||||||
if locked is not None and locked.tzinfo is None:
|
if locked is not None and locked.tzinfo is None:
|
||||||
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
|
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes
|
||||||
if locked is not None and locked > now:
|
if locked is not None and locked > now:
|
||||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||||
if not user or not valid:
|
|
||||||
|
# ── throttle 2: the pre-account window ────────────────────────────────────
|
||||||
|
if _bind_throttled(sam):
|
||||||
|
log.warning("refusing to bind for %r — local attempt budget (%d) spent; "
|
||||||
|
"protecting the domain account from lockout", sam, LOGIN_MAX_ATTEMPTS)
|
||||||
|
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||||
|
|
||||||
|
# ── the directory ─────────────────────────────────────────────────────────
|
||||||
|
settings = notify.get_settings(db)
|
||||||
|
result = ldap_auth.verify(sam, body.password,
|
||||||
|
required_group=settings.get("ldap_required_group"))
|
||||||
|
|
||||||
|
if result.is_config_problem:
|
||||||
|
# Ours, not theirs. Never counted against the user, never a 401.
|
||||||
|
log.error("sign-in unavailable — %s: %s", result.reason, result.detail)
|
||||||
|
raise HTTPException(status_code=503,
|
||||||
|
detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||||
|
|
||||||
|
if not result.ok:
|
||||||
|
# WARNING, not INFO: this is the line an operator needs when someone
|
||||||
|
# cannot sign in, and nothing configures the root logger — under plain
|
||||||
|
# uvicorn an INFO record from wpsuite.* goes nowhere, so the reason was
|
||||||
|
# invisible in exactly the situation it exists for.
|
||||||
|
log.warning("sign-in refused for %r (%s: %s)", sam, result.reason, result.detail)
|
||||||
|
_record_bind_failure(sam)
|
||||||
if user:
|
if user:
|
||||||
user.failed_attempts = (user.failed_attempts or 0) + 1
|
user.failed_attempts = (user.failed_attempts or 0) + 1
|
||||||
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
||||||
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
||||||
user.failed_attempts = 0
|
user.failed_attempts = 0
|
||||||
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
|
log_event(db, user.username, "login_locked", "user", user.id,
|
||||||
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
|
summary=user.username,
|
||||||
|
detail={"minutes": LOGIN_LOCKOUT_MINUTES, "reason": result.reason})
|
||||||
db.commit()
|
db.commit()
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
# ── authenticated ─────────────────────────────────────────────────────────
|
||||||
|
_clear_bind_failures(sam)
|
||||||
|
|
||||||
|
# The pre-bind lookup used the typed name only. Now that the directory has told
|
||||||
|
# us the account's real sAMAccountName and mail, re-match on both (T10.4).
|
||||||
|
if user is None:
|
||||||
|
user = _match_directory_account(db, result)
|
||||||
|
|
||||||
|
provisioned = user is None
|
||||||
|
if provisioned:
|
||||||
|
user = _provision_from_directory(db, result)
|
||||||
|
else:
|
||||||
|
# NEVER touch `role` here. An existing admin stays an admin — that is D13
|
||||||
|
# criterion 4, and it is the whole reason this branch is separate from the
|
||||||
|
# one above. Fill in identity fields only where they are empty locally, so a
|
||||||
|
# name deliberately set in the console is not overwritten by the directory.
|
||||||
|
if not user.full_name and result.full_name:
|
||||||
|
user.full_name = result.full_name
|
||||||
|
if not user.email and result.mail:
|
||||||
|
user.email = result.mail
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
|
# Checked after provisioning so a brand-new account (is_active defaults True)
|
||||||
|
# is not caught by it, and after the role branch so a disabled admin is still
|
||||||
|
# refused. Local state overrides the directory: disabling here is how you
|
||||||
|
# revoke access to THIS app without touching the domain account.
|
||||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||||
|
|
||||||
user.failed_attempts = 0
|
user.failed_attempts = 0
|
||||||
user.locked_until = None
|
user.locked_until = None
|
||||||
user.last_login_at = now
|
user.last_login_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
token = auth.create_token(user)
|
token = auth.create_token(user)
|
||||||
auth.set_session_cookie(response, request, token)
|
auth.set_session_cookie(response, request, token)
|
||||||
return {"user": user.to_dict()}
|
return {"user": user.to_dict()}
|
||||||
@@ -695,115 +906,6 @@ def logout(response: Response):
|
|||||||
return {"ok": True}
|
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] = {}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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/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.",
|
|
||||||
)
|
|
||||||
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."}
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/me")
|
@app.get("/api/auth/me")
|
||||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||||
"""Who is logged in. The frontend guard calls this on every page load.
|
"""Who is logged in. The frontend guard calls this on every page load.
|
||||||
@@ -857,22 +959,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
|
|||||||
return {"user": user.to_dict()}
|
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 ─────────────────────────────────────────────────────────
|
# ── User administration ─────────────────────────────────────────────────────────
|
||||||
# Two kinds of caller reach these routes: an app admin, who manages every account,
|
# 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.
|
# and a Project Super User, who manages the accounts on the projects they administer.
|
||||||
@@ -960,9 +1046,6 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
|
|||||||
|
|
||||||
@app.post("/api/auth/users")
|
@app.post("/api/auth/users")
|
||||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
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)
|
|
||||||
allowed = grantable_roles(actor)
|
allowed = grantable_roles(actor)
|
||||||
if body.role not in allowed:
|
if body.role not in allowed:
|
||||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||||
@@ -994,7 +1077,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
|||||||
username=body.username.strip(),
|
username=body.username.strip(),
|
||||||
email=body.email.strip(),
|
email=body.email.strip(),
|
||||||
full_name=body.full_name.strip(),
|
full_name=body.full_name.strip(),
|
||||||
password_hash=auth.hash_password(body.password),
|
|
||||||
role=body.role,
|
role=body.role,
|
||||||
project_role=body.project_role.strip()[:120],
|
project_role=body.project_role.strip()[:120],
|
||||||
)
|
)
|
||||||
@@ -1021,24 +1103,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
|||||||
return directory_entry(db, u, actor)
|
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")
|
@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)):
|
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)
|
u = load_target_user(db, user_id)
|
||||||
@@ -1294,6 +1358,15 @@ def list_projects(
|
|||||||
elif archived != "all":
|
elif archived != "all":
|
||||||
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
||||||
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
|
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]
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -1588,15 +1661,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
|||||||
).first()
|
).first()
|
||||||
if not sop:
|
if not sop:
|
||||||
return []
|
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]
|
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"]:
|
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
|
"""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'] -
|
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
|
project_sop_team above read the flat shape until BL-021 was fixed
|
||||||
has (BL-021, logged, not fixed here)."""
|
(2026-08-20); both now read nested-first, exactly alike."""
|
||||||
if not project_id:
|
if not project_id:
|
||||||
return []
|
return []
|
||||||
sop = db.scalars(
|
sop = db.scalars(
|
||||||
@@ -1643,14 +1723,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
|
|||||||
|
|
||||||
def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
|
def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
|
||||||
actor: "models.User", link: str) -> str:
|
actor: "models.User", link: str) -> str:
|
||||||
# A WP number and a deep link - NOT the package contents. The task text asked
|
# Number, title and location ride in the body — the 2026-08-20 decision
|
||||||
# for location and a scope summary, but the done-when list (and the standing
|
# restored the location the T7.6 done-when had excluded. The SCOPE summary
|
||||||
# rule) says no customer IP in a message body; the link is the summary.
|
# stays out: scope text is document content, and the link is its summary.
|
||||||
who = actor.full_name or actor.username
|
who = actor.full_name or actor.username
|
||||||
name = user.full_name or user.username
|
name = user.full_name or user.username
|
||||||
return (
|
return (
|
||||||
f"Hi {name},\n\n"
|
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"It is in the QA queue waiting to be accepted or returned.\n\n"
|
||||||
f"Open it here:\n{link}\n\n"
|
f"Open it here:\n{link}\n\n"
|
||||||
f"— This is an automated message from the Work Package Suite."
|
f"— This is an automated message from the Work Package Suite."
|
||||||
@@ -1663,7 +1744,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
|
|||||||
name = user.full_name or user.username
|
name = user.full_name or user.username
|
||||||
return (
|
return (
|
||||||
f"Hi {name},\n\n"
|
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"The reason is recorded on the package.\n\n"
|
||||||
f"Open it here:\n{link}\n\n"
|
f"Open it here:\n{link}\n\n"
|
||||||
f"— This is an automated message from the Work Package Suite."
|
f"— This is an automated message from the Work Package Suite."
|
||||||
@@ -1696,18 +1778,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage",
|
|||||||
|
|
||||||
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
||||||
actor: "models.User", link: str) -> str:
|
actor: "models.User", link: str) -> str:
|
||||||
# Constraint names and a WP number only — no package contents, same rule as the
|
# Constraint names, number, title and location — customer context per the
|
||||||
# assignment mail.
|
# 2026-08-20 decision. No package contents; the link carries those.
|
||||||
who = user.full_name or user.username
|
who = user.full_name or user.username
|
||||||
by = actor.full_name or actor.username
|
by = actor.full_name or actor.username
|
||||||
which = ", ".join(names)
|
which = ", ".join(names)
|
||||||
return (
|
return (
|
||||||
f"Hi {who},\n\n"
|
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"after it was released to the field, so the package is on hold.\n\n"
|
||||||
f"Constraint: {which}\n"
|
f"Constraint: {which}\n"
|
||||||
|
+ _where_line(wp) +
|
||||||
f"Reopened by: {by}\n\n"
|
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 +1808,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
|
|||||||
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
|
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
|
||||||
return (
|
return (
|
||||||
f"Hi {name},\n\n"
|
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"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
|
||||||
f"Delivery location: {delivery}.\n\n"
|
f"Delivery location: {delivery}.\n\n"
|
||||||
f"Open it here:\n{link}\n\n"
|
f"Open it here:\n{link}\n\n"
|
||||||
@@ -1740,7 +1824,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage",
|
|||||||
needed_line = f" needed by {needed}" if needed else ""
|
needed_line = f" needed by {needed}" if needed else ""
|
||||||
return (
|
return (
|
||||||
f"Hi {name},\n\n"
|
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"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
|
||||||
f"Delivery location: {delivery}.\n\n"
|
f"Delivery location: {delivery}.\n\n"
|
||||||
f"Open it here:\n{link}\n\n"
|
f"Open it here:\n{link}\n\n"
|
||||||
@@ -3383,6 +3467,29 @@ def list_comments(
|
|||||||
return [c.to_dict() for c in rows]
|
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 ──────────────────
|
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||||
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
# 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
|
# 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."}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Authentication for the Work Package Suite.
|
"""Authentication for the Work Package Suite.
|
||||||
|
|
||||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
Authentication is an LDAPS simple bind against the domain (D13); this module owns
|
||||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
everything *after* that. A successful sign-in issues a signed JWT that rides in an
|
||||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
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
|
server-side session store — every request is checked by verifying the cookie's
|
||||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ Security model:
|
|||||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||||
|
• Roles are LOCAL. The directory supplies identity; this app decides what that
|
||||||
|
identity may do, which is why an existing admin keeps admin (D13 criterion 4).
|
||||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||||
set; if it is missing we fall back to a random per-process key (which logs a
|
set; if it is missing we fall back to a random per-process key (which logs a
|
||||||
warning and invalidates every session on restart) so dev still works.
|
warning and invalidates every session on restart) so dev still works.
|
||||||
@@ -35,10 +37,11 @@ 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
|
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||||
membership rows — this module only decides which roles carry the power at all.
|
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
|
There is no password and no password reset: D13 replaced local credentials with an
|
||||||
to the account's address. It is single-use by construction — it embeds the user's
|
LDAPS bind (server/ldap_auth.py). People change their password with the domain, and
|
||||||
`token_version`, which is bumped when the password changes, so a used or
|
the login page points them at Okta. `token_version` survives as the
|
||||||
superseded link stops validating.
|
session-revocation mechanism — a role change or a deactivation must take effect on
|
||||||
|
sessions that have already been issued.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -46,7 +49,6 @@ import logging
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, Request, Response, status
|
from fastapi import Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -61,8 +63,6 @@ COOKIE_NAME = "wp_session"
|
|||||||
JWT_ALG = "HS256"
|
JWT_ALG = "HS256"
|
||||||
# How long a login lasts before the user must sign in again.
|
# How long a login lasts before the user must sign in again.
|
||||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||||
# How long an emailed password-reset link stays valid.
|
|
||||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
|
||||||
|
|
||||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
@@ -121,29 +121,6 @@ def is_project_admin(user: "models.User") -> bool:
|
|||||||
# same question used to exist here and silently disagreed with the scoped one, which
|
# 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.
|
# locked per-project super users out of the routes they were entitled to.
|
||||||
|
|
||||||
# Password policy (shared by the API and the CLI).
|
|
||||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
|
||||||
_COMMON_PASSWORDS = {
|
|
||||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
|
||||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
|
||||||
"iloveyou1", "abc12345", "qwertyuiop",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
|
||||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
|
||||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
|
||||||
if len(pw) < MIN_PASSWORD_LEN:
|
|
||||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
|
||||||
low = pw.lower()
|
|
||||||
if username and low == username.strip().lower():
|
|
||||||
return "Password must not be the same as the username."
|
|
||||||
if email and low == email.strip().lower():
|
|
||||||
return "Password must not be the same as the email."
|
|
||||||
if low in _COMMON_PASSWORDS:
|
|
||||||
return "That password is too common — choose something less guessable."
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||||
_EXEMPT_EXACT = {
|
_EXEMPT_EXACT = {
|
||||||
@@ -184,22 +161,6 @@ def _load_secret() -> str:
|
|||||||
SECRET_KEY = _load_secret()
|
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 ──────────────────────────────────────────────────────────────────
|
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||||
def create_token(user: "models.User") -> str:
|
def create_token(user: "models.User") -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -227,33 +188,6 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
return claims
|
return claims
|
||||||
|
|
||||||
|
|
||||||
def create_reset_token(user: "models.User") -> str:
|
|
||||||
"""Short-lived, single-use token for an emailed password-reset link.
|
|
||||||
|
|
||||||
Single-use falls out of `ver`: completing a reset bumps the user's
|
|
||||||
token_version, so the link (and any older link) no longer validates."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
payload = {
|
|
||||||
"typ": "pwreset",
|
|
||||||
"sub": user.id,
|
|
||||||
"ver": user.token_version or 0,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(minutes=RESET_MINUTES),
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_reset_token(token: str) -> Optional[dict]:
|
|
||||||
"""Claims for a valid, unexpired reset token, else None."""
|
|
||||||
try:
|
|
||||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
return None
|
|
||||||
if claims.get("typ") != "pwreset":
|
|
||||||
return None
|
|
||||||
return claims
|
|
||||||
|
|
||||||
|
|
||||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||||
def _is_https(request: Request) -> bool:
|
def _is_https(request: Request) -> bool:
|
||||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||||
|
|||||||
66
server/certs/prime-ca-chain.pem
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# CN=PRIME CONTROLS ROOT CA
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFHzCCAwegAwIBAgIQOnMIcdMiP6pELiXfh2ZmLzANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQD
|
||||||
|
ExZQUklNRSBDT05UUk9MUyBST09UIENBMCAXDTIxMDkwOTEzMjM1OFoYDzIwNTEwOTA5MTMzMzU2
|
||||||
|
WjAhMR8wHQYDVQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOC
|
||||||
|
Ag8AMIICCgKCAgEAri9Vkf+l3bnjGXWMEX15BYRnQTSKUStctG2NBppJ/lwj2LbHAvHf6HdkJAxx
|
||||||
|
2lqDiG0R+D9NcGEi427XOQ78Nc9aJe4jOwip5u5Md6Szwmu4QDRjUy11dDBvRoftD550052O1WOV
|
||||||
|
0OY5hxcZIo7bOfqDLesHG/Y74GJvrYai/4xq440uN6iTaMmsfzbIahIXP39NhuW4i4dgkQkRSfqX
|
||||||
|
y8i3AGS8WhpViVvIlMgGXRrCcBW8MnVp32OvKE2MqQnjZI2i5f8wMT4L0J0DXlSPbV6FPLdpBXl5
|
||||||
|
+OX+9qcEH6hI+Mv8sC/xGLAt/wwBP6E2kM2lovGGIUhBsay0UM03PJsh4r6q3HV5gpH6uYZlOgOY
|
||||||
|
UW59pNT/VyESdZb4kfEGHNHrHy3uNb8Q71UzQxrq/UVKXnBUMN/1PszV4YA08ZaYf2EGItZoNM3v
|
||||||
|
uTOJIifgtAo1DxaTygmglgk8CHlaN4IU8jotrzdksLYQ9MQaD5xnYADCnIf1eZ5VCF+fhpgQOCCV
|
||||||
|
4TfJ20rR14+jy6L4yKYGgtvg8H+hvhucOOnktpV/zLv4H9pItIzbQPuxNdrEmco2i7zrsMuaNYD/
|
||||||
|
tmbGf4mhy20zFOkdOjXGeb4cTF4HIBh3aue2T7o08vi1pkVN+8RXa7lZNbLJnpgtfTr9DKBpW2Fd
|
||||||
|
LqAb1zN2Azorg3kCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
|
||||||
|
BBYEFMoWYtRcmyQQmB3AD4DPiau0r9XRMBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBCwUA
|
||||||
|
A4ICAQB0XhWlrK81ZKW9bBlbQe/i6js7ciDhwObgKB0FGZJH8rqK5HFwM/wZbh9h0t+YPk/ev6oG
|
||||||
|
BXU0rJUr1YVMERBb37c/rHBU4Bnv9r6cLhsfiTTcVGqZTM7JE9JfdmJQ10V1awSFoxU1cYMSzot9
|
||||||
|
TyrspkZJ6JFJH2wdhZirIySQHH5WuqEOdG6g9/bDW2X27B0S62s8WNIfP+gugkTDkiHQvoUDi8GY
|
||||||
|
/DA29lZTKPI8PhmKYZvw+fi1weqIYsFLk9pvHW21nIv6qAT7nfuGvuUR5siB7sFbw80XEX6Em8O0
|
||||||
|
0lnP6u+vecBqLK7D34X+aupDlkZlZdPoa0EaTwvTO8pkecZCdMLcTkB2quc/fcyCmVdj4CGn3yND
|
||||||
|
cUUR5wZbHtuCU27Rc4d3rY0gxPNpK3EXkTSOQL7BgR6EkwwNwUqeYmFb/SyXYeSqpdChvsWKgRrP
|
||||||
|
+8n27SeJ01ezk8GMDC0YcOJAAHxXqqOAJo1IwxVvpRkoljKIAprQwHAGUNTmKy+SJXT73/LpPTX+
|
||||||
|
RnbNTG27UfYONh5/DdkGc1wwmIp1X1a1tAb3MNRasiBGlYJ3NGDHVUwgEtmcVeVXWsj1ZhaXq2YX
|
||||||
|
TFsDl9m1IMhFD1n8pLJTLd7AT8Exxga+OzFjMzDu0uzKw3KoAVIX/IgfybdKexVT4Z+nBCY9N+n9
|
||||||
|
/vpujg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
# CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIH/jCCBeagAwIBAgITdQAAAAI3j8Wt9kAshQAAAAAAAjANBgkqhkiG9w0BAQsFADAhMR8wHQYD
|
||||||
|
VQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMB4XDTIxMDkwOTE0NDUxM1oXDTM2MDkwOTE0NTUx
|
||||||
|
M1owVDEVMBMGCgmSJomT8ixkARkWBWxvY2FsMRUwEwYKCZImiZPyLGQBGRYFcHJpbWUxJDAiBgNV
|
||||||
|
BAMTG1BSSU1FIENPTlRST0xTIElTU1VJTkcgQ0EgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
|
||||||
|
AgoCggIBANvhsMj7RooZ+FzAExgWLex++F1QsdDqcGkwqjaUI5AziI4yb/FMqnO93Sus60hiqql+
|
||||||
|
hfxW/a55huBfo54kOyp5oWAjd0akfJQvFY8sXsWIGz07wiy0msvZDtHdBXy561zb0SSNpJS1Vceb
|
||||||
|
ihyDvM5Tyc6QAzO1u76QJPkegn+v6lpWmodba0kWVvRg/P3SLCnq1LdU8oA1VhIiCkCbdry0Syop
|
||||||
|
bVX0+evzINGmv81VbHct8ptZTBLctM11C16IwPEmntvOqVKpT166/aUO8FMqyHQQZ32ZHBp6ORNu
|
||||||
|
WlCN/EDdgT2s7Vy9/7Hr6gy1IPjEGNYq1yYxcpth+6/RgvxFnt9SWr7B9qKtYe1rN8r+6qYVw4ne
|
||||||
|
c0L7vRvw8HTjOJ9EQnPfID9+34Y8OQkqIHnnF80yjMGCdvh/tR/tXsUlz+Byt9m5MFdhPE22MX1x
|
||||||
|
jyCJFug1Ufso8toAVZcavsRfX0ygeUkv+zZDZKHmF910rB8Cdb980cpKtFvx/v0BNWnzgAhgGkqO
|
||||||
|
ttUf0PKUeGDIW6DMUKhs5JM7ED5rkepvNG5vePDVy/YidhbM4x1ph+HFVkHgT6rd1M4NeCm818C0
|
||||||
|
MVsofXdOOdbjmkQyxPGukl9EG1ukYMfUIQpFozTbbHKNUDfqcdHu8Qm39K7opwxMOiLprXGRSgx2
|
||||||
|
vMHI82mtAgMBAAGjggL6MIIC9jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU94Bw+7t8JXHx
|
||||||
|
VZoWN/pFMo005ZwwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
|
||||||
|
EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUyhZi1FybJBCYHcAPgM+Jq7Sv1dEwggEvBgNVHR8EggEm
|
||||||
|
MIIBIjCCAR6gggEaoIIBFoaByGxkYXA6Ly8vQ049UFJJTUUlMjBDT05UUk9MUyUyMFJPT1QlMjBD
|
||||||
|
QSxDTj1QUklNRS1ST09UQ0EsQ049Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNl
|
||||||
|
cnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv
|
||||||
|
Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50hklodHRwOi8v
|
||||||
|
Y3JsLnByaW1lLWNvbnRyb2xzLmNvbS9DZXJ0RW5yb2xsL1BSSU1FJTIwQ09OVFJPTFMlMjBST09U
|
||||||
|
JTIwQ0EuY3JsMIIBNAYIKwYBBQUHAQEEggEmMIIBIjCBuwYIKwYBBQUHMAKGga5sZGFwOi8vL0NO
|
||||||
|
PVBSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EsQ049QUlBLENOPVB1YmxpYyUyMEtleSUyMFNl
|
||||||
|
cnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y0FD
|
||||||
|
ZXJ0aWZpY2F0ZT9iYXNlP29iamVjdENsYXNzPWNlcnRpZmljYXRpb25BdXRob3JpdHkwYgYIKwYB
|
||||||
|
BQUHMAKGVmh0dHA6Ly9jcmwucHJpbWUtY29udHJvbHMuY29tL0NlcnRFbnJvbGwvUFJJTUUtUk9P
|
||||||
|
VENBX1BSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQAw
|
||||||
|
2SIuBMB8JWC/YGbh3LJDt9T/z1BwEniLwEKu4SyBMfW3qJoLR0Zps8xHlCIlUjisZBSilHDNW4uW
|
||||||
|
4891yqg104OR0dx94dQV2Y6Aw9V4tvlw+GGnWpHbUNwP3/JizlndR6ZdhdnJlIt6g7xCy4LwyXrw
|
||||||
|
f22CibC1EWUVnsYJ8ez+LxSprWiUaEME8+bEaVuajXRitYyoG0asvtESeBhR3lwaxuzBqyAuGEfc
|
||||||
|
P8pO/fgjigripPScXnp6opQRzZ12VSNsN8BUmtCfsqX8DK7iRsn/QLZtOKVf6kapPkorovetKwai
|
||||||
|
wasE2mpKJweKYhGwWXdHrXZfCp6biTzG0oX4DGke7tHxdA92ArgmPR4SsNnpekUGcbUgkdpOc0sC
|
||||||
|
mt+LXzRvFKJNd205u/FvpZlBzRZl+NQ++9OGAywyzOb4Lxli49G0yFQ5Fpq0UElqDKiLzUqUR2Ye
|
||||||
|
qDT9nMJNTZEyP6hEjQQ+tSD9XFIlFWD83AyBHhRNKKZfPiu4mJxNYtW1ltVu5Z1EvBfbcRwc0lQa
|
||||||
|
VKGQS23sSIx6heq2q5FqhnYZ15zSXqaAH680Up5mOkeGIfm45rMIFl/aMAJ2Fntl+Taebosf2rv5
|
||||||
|
M/QOJs494ePbXxCJ1kzCLaqgucDXtadJ7ZV2AKhu3B9OiDrS8sv990Yn95ItlpthpVetiXxLHg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
462
server/ldap_auth.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"""LDAPS authentication against the Windows domain — D13, built in T10.1.
|
||||||
|
|
||||||
|
The suite stores no passwords. A sign-in is a **simple bind** to
|
||||||
|
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
|
||||||
|
person typed; a successful bind IS the authentication. This module owns that
|
||||||
|
conversation and nothing else — it does not touch the database, does not issue
|
||||||
|
sessions, and does not decide what anyone is allowed to do. `server/auth.py` and
|
||||||
|
the `login` route in `server/app.py` do that.
|
||||||
|
|
||||||
|
WHY `prime.local` AND NOT A DC NAME OR AN IP (this is load-bearing, do not "fix" it):
|
||||||
|
every domain controller's certificate carries `prime.local` in its SAN alongside its
|
||||||
|
own hostname, and the domain name round-robins in DNS across all six DCs published
|
||||||
|
in `_ldap._tcp.prime.local`. So connecting to the domain name both passes hostname
|
||||||
|
validation and gives failover in one move. Connecting to `192.168.3.37` instead
|
||||||
|
fails with `hostname mismatch` — the DC certificates carry no IP SAN — and the only
|
||||||
|
way to "fix" that is to disable the check, which is the one thing that must not
|
||||||
|
happen here (see below).
|
||||||
|
|
||||||
|
THE CLIENT PRESENTS NO CERTIFICATE. This module is the TLS *client*; clients verify,
|
||||||
|
they do not present. What it needs is a trust anchor: `PRIME CONTROLS ROOT CA` plus
|
||||||
|
`PRIME CONTROLS ISSUING CA 1`, shipped as a PEM bundle at `server/certs/`. Those are
|
||||||
|
public certificates — no private key, nothing secret, nothing issued to this app.
|
||||||
|
|
||||||
|
TWO WAYS THIS GOES CATASTROPHICALLY WRONG, both guarded here:
|
||||||
|
|
||||||
|
1. An EMPTY PASSWORD. In LDAP a simple bind with an empty password is an
|
||||||
|
*anonymous* bind, and it SUCCEEDS. Without an explicit guard a blank password
|
||||||
|
authenticates as whatever username was submitted. `verify()` therefore rejects
|
||||||
|
an empty or whitespace-only password BEFORE `bind()` is ever called. If you are
|
||||||
|
refactoring this file and that check looks redundant, it is not.
|
||||||
|
|
||||||
|
2. `validate=ssl.CERT_NONE`. It still encrypts, so it fails silently — what it
|
||||||
|
loses is the ability to tell the real DC from someone who terminates the TLS
|
||||||
|
session, harvests the domain password and relays the bind onward. Domain
|
||||||
|
credentials cross this channel, so that turns an app compromise into a Windows
|
||||||
|
compromise. `CERT_REQUIRED` with an explicit CA file is the only mode here, and
|
||||||
|
the system trust store is deliberately NOT used: it currently trusts five other
|
||||||
|
self-signed CAs on this estate, any of which could issue a DC-shaped cert.
|
||||||
|
|
||||||
|
FAILED BINDS COUNT AGAINST THE DOMAIN LOCKOUT POLICY. That is why nothing in here
|
||||||
|
retries a rejected credential — only genuine network failures are retried, and a
|
||||||
|
`bind()` that returns False is final. The caller throttles before it gets this far
|
||||||
|
(T10.2); this module's job is not to make the problem worse.
|
||||||
|
|
||||||
|
Unconfigured is a first-class state, as it is for `MICRON_DB_URL` in `assets_db.py`:
|
||||||
|
with no CA bundle or no `ldap3` installed, `is_configured()` is False and `verify()`
|
||||||
|
returns `UNCONFIGURED` rather than raising. Since D13 leaves no local password
|
||||||
|
fallback, the caller must surface that state loudly — an unconfigured deploy and a
|
||||||
|
mistyped password look identical at the login box otherwise.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("wpsuite.ldap")
|
||||||
|
|
||||||
|
# ldap3 is pinned in requirements.txt. The import is guarded rather than assumed so
|
||||||
|
# that this module — and the test suite that imports it — still loads on a checkout
|
||||||
|
# where the dependency has not been installed. `is_configured()` reports the truth,
|
||||||
|
# and the startup line from `describe()` says so out loud.
|
||||||
|
try:
|
||||||
|
from ldap3 import Server, Connection, Tls, SIMPLE, SUBTREE, NONE
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPException,
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSessionTerminatedByServerError,
|
||||||
|
LDAPCertificateError,
|
||||||
|
)
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
HAVE_LDAP3 = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
HAVE_LDAP3 = False
|
||||||
|
LDAPException = LDAPSocketOpenError = Exception
|
||||||
|
LDAPSessionTerminatedByServerError = LDAPCertificateError = Exception
|
||||||
|
|
||||||
|
def escape_filter_chars(x, encoding=None): # type: ignore[misc]
|
||||||
|
raise RuntimeError("ldap3 is not installed")
|
||||||
|
|
||||||
|
|
||||||
|
# ── configuration ─────────────────────────────────────────────────────────────
|
||||||
|
# Module level, matching how server/auth.py reads AUTH_SESSION_HOURS. Tests patch
|
||||||
|
# these attributes directly rather than re-importing.
|
||||||
|
DOMAIN = os.getenv("LDAP_DOMAIN", "prime.local")
|
||||||
|
# Defaults to the domain name on purpose — see the module docstring. Override only
|
||||||
|
# if you have a reason, and never with an IP address.
|
||||||
|
HOST = os.getenv("LDAP_HOST", "") or DOMAIN
|
||||||
|
PORT = int(os.getenv("LDAP_PORT", "636"))
|
||||||
|
CA_FILE = os.getenv("LDAP_CA_FILE", "") or os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "certs", "prime-ca-chain.pem"
|
||||||
|
)
|
||||||
|
TIMEOUT_SECONDS = int(os.getenv("LDAP_TIMEOUT_SECONDS", "8"))
|
||||||
|
# Retries apply to CONNECT failures only, never to a rejected credential. Two extra
|
||||||
|
# attempts covers the realistic case: DNS round-robin handed out a DC that is
|
||||||
|
# rebooting for patching.
|
||||||
|
CONNECT_RETRIES = int(os.getenv("LDAP_CONNECT_RETRIES", "2"))
|
||||||
|
# The initial value and the fallback for the admin-console setting added in T10.5.
|
||||||
|
REQUIRED_GROUP = os.getenv("LDAP_REQUIRED_GROUP", "")
|
||||||
|
|
||||||
|
# AD's "member of, transitively" extensible match. Plain `memberOf` is DIRECT
|
||||||
|
# membership only and would wrongly refuse anyone who is in a nested child group,
|
||||||
|
# which is how most estates actually organise access.
|
||||||
|
NESTED_MEMBER_RULE = "1.2.840.113556.1.4.1941"
|
||||||
|
|
||||||
|
_USER_ATTRS = ["sAMAccountName", "mail", "displayName", "userPrincipalName"]
|
||||||
|
|
||||||
|
# Reason codes. Machine-readable, for logs and for the caller's branching — never
|
||||||
|
# for a response body, because several of them would leak whether an account exists.
|
||||||
|
OK = "ok"
|
||||||
|
EMPTY_INPUT = "empty_input"
|
||||||
|
UNCONFIGURED = "unconfigured"
|
||||||
|
UNREACHABLE = "unreachable"
|
||||||
|
UNTRUSTED = "untrusted"
|
||||||
|
BAD_CREDENTIALS = "bad_credentials"
|
||||||
|
NOT_IN_GROUP = "not_in_group"
|
||||||
|
NO_DIRECTORY_ENTRY = "no_directory_entry"
|
||||||
|
GROUP_NOT_FOUND = "group_not_found"
|
||||||
|
GROUP_CHECK_FAILED = "group_check_failed"
|
||||||
|
|
||||||
|
# AD returns these as `data <code>` inside an error-49 message. Kept for the log
|
||||||
|
# only: telling an unauthenticated caller "your password expired" confirms the
|
||||||
|
# account exists, which `login()` goes out of its way not to do.
|
||||||
|
_ERR49 = {
|
||||||
|
"525": "no such user",
|
||||||
|
"52e": "bad password",
|
||||||
|
"530": "not permitted at this time",
|
||||||
|
"531": "not permitted at this workstation",
|
||||||
|
"532": "password expired",
|
||||||
|
"533": "account disabled",
|
||||||
|
"701": "account expired",
|
||||||
|
"773": "must change password",
|
||||||
|
"775": "account locked out",
|
||||||
|
}
|
||||||
|
_ERR49_RE = re.compile(r"data\s+([0-9a-fA-F]{3})")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LdapResult:
|
||||||
|
"""Outcome of a bind attempt.
|
||||||
|
|
||||||
|
`ok` is the only field the caller should branch on for allow/deny. `reason` and
|
||||||
|
`detail` are for logs. `sam`/`mail`/`full_name` are populated only on success and
|
||||||
|
are what T10.4 uses to match or provision the local account.
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
reason: str = ""
|
||||||
|
detail: str = ""
|
||||||
|
sam: str = ""
|
||||||
|
mail: str = ""
|
||||||
|
full_name: str = ""
|
||||||
|
upn: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_config_problem(self) -> bool:
|
||||||
|
"""True when the failure is ours, not the user's. With no local password
|
||||||
|
fallback (D13) these must be logged as errors and surfaced to an operator —
|
||||||
|
otherwise a broken deploy is indistinguishable from a forgotten password."""
|
||||||
|
return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND,
|
||||||
|
GROUP_CHECK_FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
def base_dn(domain: Optional[str] = None) -> str:
|
||||||
|
"""`prime.local` -> `DC=prime,DC=local`."""
|
||||||
|
d = (domain or DOMAIN or "").strip().strip(".")
|
||||||
|
return ",".join(f"DC={part}" for part in d.split(".") if part)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""Whether a bind could even be attempted. Deliberately does not touch the
|
||||||
|
network — `selftest()` does that."""
|
||||||
|
return bool(HAVE_LDAP3 and HOST and CA_FILE and os.path.isfile(CA_FILE))
|
||||||
|
|
||||||
|
|
||||||
|
def describe() -> str:
|
||||||
|
"""One line for the startup log. D13 removed the local password path, so an
|
||||||
|
operator needs to see this in `docker compose logs api` rather than discovering
|
||||||
|
it when the first person cannot sign in."""
|
||||||
|
from . import ldap_fake
|
||||||
|
if ldap_fake.is_active():
|
||||||
|
return ("*** FAKE DIRECTORY ACTIVE — passwords come from "
|
||||||
|
f"{ldap_fake.ENV_VAR}, NOT from the domain. Tests only. ***")
|
||||||
|
if not HAVE_LDAP3:
|
||||||
|
return "LDAP auth DISABLED — ldap3 is not installed. No one can sign in."
|
||||||
|
if not CA_FILE or not os.path.isfile(CA_FILE):
|
||||||
|
return (f"LDAP auth DISABLED — CA bundle not found at {CA_FILE!r}. "
|
||||||
|
f"No one can sign in. Set LDAP_CA_FILE.")
|
||||||
|
group = REQUIRED_GROUP or "(none configured — every domain account may sign in)"
|
||||||
|
return (f"LDAP auth enabled — ldaps://{HOST}:{PORT}, domain {DOMAIN}, "
|
||||||
|
f"CA {CA_FILE}, required group: {group}")
|
||||||
|
|
||||||
|
|
||||||
|
def _tls() -> "Tls":
|
||||||
|
"""The only TLS configuration in this module.
|
||||||
|
|
||||||
|
`CERT_REQUIRED` plus an explicit `ca_certs_file`. ldap3 performs the hostname
|
||||||
|
check against the certificate's SAN itself whenever validate is not CERT_NONE,
|
||||||
|
which is what makes connecting by IP fail instead of quietly succeeding.
|
||||||
|
"""
|
||||||
|
# No `version=` pin: ldap3's default negotiates the highest version both ends
|
||||||
|
# support, which against these DCs is TLS 1.3. Pinning PROTOCOL_TLSv1_2 here
|
||||||
|
# would silently DOWNGRADE every connection to 1.2.
|
||||||
|
return Tls(
|
||||||
|
ca_certs_file=CA_FILE,
|
||||||
|
validate=ssl.CERT_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _server() -> "Server":
|
||||||
|
return Server(
|
||||||
|
HOST, port=PORT, use_ssl=True, tls=_tls(),
|
||||||
|
connect_timeout=TIMEOUT_SECONDS, get_info=NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _err49(result: Optional[dict]) -> str:
|
||||||
|
"""Human-readable AD sub-code from a bind failure, for the log only."""
|
||||||
|
msg = ((result or {}).get("message") or "")
|
||||||
|
m = _ERR49_RE.search(msg)
|
||||||
|
if not m:
|
||||||
|
return ((result or {}).get("description") or "invalid credentials")
|
||||||
|
code = m.group(1).lower()
|
||||||
|
return f"{code} ({_ERR49.get(code, 'unrecognised sub-code')})"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_username(raw: str) -> str:
|
||||||
|
"""Reduce whatever was typed in the login box to a `sAMAccountName`.
|
||||||
|
|
||||||
|
People type their email address. The mail domain here (`prime-controls.com`) is
|
||||||
|
not the AD domain (`prime.local`), so an address is never a valid bind string —
|
||||||
|
the local part is used instead. This assumes the mail local part equals the
|
||||||
|
sAMAccountName, which is the norm but not guaranteed; where it differs the person
|
||||||
|
must type their short logon name, and we log it so that is diagnosable.
|
||||||
|
|
||||||
|
ONE candidate is produced, never a list to try in turn: every rejected bind
|
||||||
|
counts against the domain lockout policy, so guessing would let a handful of
|
||||||
|
login attempts lock a real account out of Windows.
|
||||||
|
"""
|
||||||
|
name = (raw or "").strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
# DOMAIN\user, as typed by anyone used to a Windows logon prompt.
|
||||||
|
if "\\" in name:
|
||||||
|
name = name.rsplit("\\", 1)[1].strip()
|
||||||
|
if "@" in name:
|
||||||
|
local, _, dom = name.partition("@")
|
||||||
|
log.info("login input %r looks like an address; binding as sAMAccountName %r "
|
||||||
|
"(mail domain %r is not the AD domain)", name, local.strip(), dom)
|
||||||
|
name = local.strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_group_dn(conn, group: str) -> Optional[str]:
|
||||||
|
"""Accept either a distinguished name or a plain group name, return a DN."""
|
||||||
|
g = (group or "").strip()
|
||||||
|
if not g:
|
||||||
|
return None
|
||||||
|
if "," in g and "=" in g:
|
||||||
|
return g # already a DN
|
||||||
|
esc = escape_filter_chars(g)
|
||||||
|
conn.search(base_dn(), f"(&(objectClass=group)(|(cn={esc})(sAMAccountName={esc})))",
|
||||||
|
search_scope=SUBTREE, attributes=["cn"], size_limit=2)
|
||||||
|
if not conn.entries:
|
||||||
|
return None
|
||||||
|
if len(conn.entries) > 1:
|
||||||
|
log.warning("group %r is ambiguous in the directory (%d matches); using %s",
|
||||||
|
g, len(conn.entries), conn.entries[0].entry_dn)
|
||||||
|
return conn.entries[0].entry_dn
|
||||||
|
|
||||||
|
|
||||||
|
def member_of(conn, sam: str, group: str) -> bool:
|
||||||
|
"""Is `sam` in `group`, counting nested membership?
|
||||||
|
|
||||||
|
THE RETURN VALUE OF conn.search() IS NOT OPTIONAL READING. The connection is
|
||||||
|
built with raise_exceptions=False, so a search that FAILS returns False and
|
||||||
|
leaves conn.entries empty — which is byte-for-byte indistinguishable from "no
|
||||||
|
match" if you only look at conn.entries. An earlier version of this function did
|
||||||
|
exactly that, and every failure of the extensible-match filter presented to the
|
||||||
|
user as "you are not in the group" while they plainly were.
|
||||||
|
|
||||||
|
Two searches, in order, and the second one exists to catch the first being wrong:
|
||||||
|
|
||||||
|
1. AD's transitive matching rule (LDAP_MATCHING_RULE_IN_CHAIN). This is the
|
||||||
|
correct query — it walks nested groups, which plain memberOf does not.
|
||||||
|
2. If that matches nothing, a plain memberOf equality check for DIRECT
|
||||||
|
membership.
|
||||||
|
|
||||||
|
If (2) matches after (1) did not, the person IS a member and is let in — refusing
|
||||||
|
a real member is the worse error — but it is logged as a WARNING, because it means
|
||||||
|
the transitive rule is returning nothing and NESTED membership is silently not
|
||||||
|
working on this connection. That needs a human; it must not pass unnoticed.
|
||||||
|
"""
|
||||||
|
dn = _resolve_group_dn(conn, group)
|
||||||
|
if not dn:
|
||||||
|
log.error("required group %r does not resolve in %s — refusing the sign-in. "
|
||||||
|
"This is a configuration fault, not a bad password.", group, base_dn())
|
||||||
|
raise LookupError(GROUP_NOT_FOUND)
|
||||||
|
|
||||||
|
esc_sam, esc_dn = escape_filter_chars(sam), escape_filter_chars(dn)
|
||||||
|
|
||||||
|
def _search(filt: str, label: str):
|
||||||
|
ok = conn.search(base_dn(), filt, search_scope=SUBTREE,
|
||||||
|
attributes=["sAMAccountName"], size_limit=1)
|
||||||
|
if not ok:
|
||||||
|
log.error("the %s membership search FAILED (not 'no match') for %r: %s | "
|
||||||
|
"filter=%s", label, sam, conn.result, filt)
|
||||||
|
raise LookupError(GROUP_CHECK_FAILED)
|
||||||
|
return bool(conn.entries)
|
||||||
|
|
||||||
|
if _search(f"(&(sAMAccountName={esc_sam})(memberOf:{NESTED_MEMBER_RULE}:={esc_dn}))",
|
||||||
|
"nested"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if _search(f"(&(sAMAccountName={esc_sam})(memberOf={esc_dn}))", "direct"):
|
||||||
|
log.warning(
|
||||||
|
"%r IS a direct member of %r, but AD's transitive matching rule "
|
||||||
|
"(%s) returned nothing for them. Allowing the sign-in — refusing a real "
|
||||||
|
"member is worse — but NESTED group membership is not working on this "
|
||||||
|
"connection and needs investigating.", sam, dn, NESTED_MEMBER_RULE)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify(username: str, password: str, required_group: Optional[str] = None) -> LdapResult:
|
||||||
|
"""Authenticate against the domain. The only entry point the app should call.
|
||||||
|
|
||||||
|
`required_group` overrides the `LDAP_REQUIRED_GROUP` default so the admin-console
|
||||||
|
setting (T10.5) wins. Pass an empty string to mean "no group gate"; pass None to
|
||||||
|
use the configured default.
|
||||||
|
"""
|
||||||
|
sam = normalize_username(username)
|
||||||
|
|
||||||
|
# ── guard 1: no bind on empty input ──────────────────────────────────────
|
||||||
|
# An empty password makes the bind below an ANONYMOUS bind, which SUCCEEDS and
|
||||||
|
# would authenticate `sam` without proving anything at all. Must stay before
|
||||||
|
# every return path that reaches bind().
|
||||||
|
if not sam or not (password or "").strip():
|
||||||
|
return LdapResult(False, EMPTY_INPUT, "empty username or password")
|
||||||
|
|
||||||
|
group = REQUIRED_GROUP if required_group is None else required_group
|
||||||
|
|
||||||
|
# Test seam (T10.7). Deliberately placed AFTER the empty-input guard above, so
|
||||||
|
# the anonymous-bind guard covers the fake path too — a fake that re-implemented
|
||||||
|
# it would let the real one rot without any test noticing. `is_active()` refuses
|
||||||
|
# whenever a non-SQLite DATABASE_URL is configured; see server/ldap_fake.py.
|
||||||
|
from . import ldap_fake
|
||||||
|
if ldap_fake.is_active():
|
||||||
|
ok, reason, attrs = ldap_fake.lookup(sam, password, group)
|
||||||
|
if not ok:
|
||||||
|
log.info("fake directory refused %r: %s", sam, reason)
|
||||||
|
return LdapResult(False, reason, "fake directory")
|
||||||
|
return LdapResult(True, OK, "fake directory", **attrs)
|
||||||
|
|
||||||
|
if not is_configured():
|
||||||
|
return LdapResult(False, UNCONFIGURED, describe())
|
||||||
|
|
||||||
|
bind_user = f"{sam}@{DOMAIN}"
|
||||||
|
last_network_error = ""
|
||||||
|
|
||||||
|
# Retries cover CONNECT failures only: DNS round-robin across six DCs will
|
||||||
|
# eventually hand out one that is rebooting. A rejected credential returns
|
||||||
|
# immediately and is never retried — each attempt counts against AD lockout.
|
||||||
|
for attempt in range(1, max(1, CONNECT_RETRIES + 1) + 1):
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = Connection(
|
||||||
|
_server(), user=bind_user, password=password,
|
||||||
|
authentication=SIMPLE, read_only=True,
|
||||||
|
receive_timeout=TIMEOUT_SECONDS, raise_exceptions=False,
|
||||||
|
)
|
||||||
|
if not conn.bind():
|
||||||
|
detail = _err49(conn.result)
|
||||||
|
log.warning("bind refused for %r: %s", sam, detail)
|
||||||
|
return LdapResult(False, BAD_CREDENTIALS, detail)
|
||||||
|
|
||||||
|
# Bound as the user. AD lets an account read its own object, so no
|
||||||
|
# service account is needed for either of the next two steps.
|
||||||
|
conn.search(base_dn(),
|
||||||
|
f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
|
||||||
|
search_scope=SUBTREE, attributes=_USER_ATTRS, size_limit=1)
|
||||||
|
if not conn.entries:
|
||||||
|
log.error("bind succeeded for %r but the account has no readable "
|
||||||
|
"directory entry under %s", sam, base_dn())
|
||||||
|
return LdapResult(False, NO_DIRECTORY_ENTRY, "no readable directory entry")
|
||||||
|
e = conn.entries[0]
|
||||||
|
|
||||||
|
def one(attr: str) -> str:
|
||||||
|
v = getattr(e, attr, None)
|
||||||
|
return str(v.value) if v is not None and v.value else ""
|
||||||
|
|
||||||
|
if group:
|
||||||
|
try:
|
||||||
|
if not member_of(conn, sam, group):
|
||||||
|
log.warning("bind succeeded for %r but the account is NOT in %r",
|
||||||
|
sam, group)
|
||||||
|
return LdapResult(False, NOT_IN_GROUP, f"not in {group}")
|
||||||
|
except LookupError as exc:
|
||||||
|
reason = str(exc) or GROUP_NOT_FOUND
|
||||||
|
return LdapResult(False, reason, f"group {group!r}: {reason}")
|
||||||
|
|
||||||
|
return LdapResult(
|
||||||
|
True, OK,
|
||||||
|
sam=one("sAMAccountName") or sam,
|
||||||
|
mail=one("mail"),
|
||||||
|
full_name=one("displayName"),
|
||||||
|
upn=one("userPrincipalName"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except LDAPCertificateError as exc:
|
||||||
|
# NOT retried and NOT downgraded. Either the CA bundle is wrong or
|
||||||
|
# something is impersonating a DC; both need a human, and retrying with
|
||||||
|
# relaxed validation is exactly the wrong instinct.
|
||||||
|
log.error("LDAPS certificate validation FAILED against %s: %s. Refusing "
|
||||||
|
"to continue — check LDAP_CA_FILE, and never set CERT_NONE.",
|
||||||
|
HOST, exc)
|
||||||
|
return LdapResult(False, UNTRUSTED, str(exc))
|
||||||
|
except (LDAPSocketOpenError, LDAPSessionTerminatedByServerError) as exc:
|
||||||
|
last_network_error = str(exc)
|
||||||
|
log.warning("LDAPS connect to %s:%s failed (attempt %d): %s",
|
||||||
|
HOST, PORT, attempt, exc)
|
||||||
|
continue
|
||||||
|
except LDAPException as exc:
|
||||||
|
log.error("LDAP error for %r: %s", sam, exc)
|
||||||
|
return LdapResult(False, UNREACHABLE, str(exc))
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.unbind()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return LdapResult(False, UNREACHABLE,
|
||||||
|
last_network_error or f"no domain controller answered on {HOST}:{PORT}")
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> LdapResult:
|
||||||
|
"""Open a TLS session to the domain and validate the certificate, WITHOUT binding.
|
||||||
|
|
||||||
|
Used by the startup check and by the admin console's diagnostics. Touches no
|
||||||
|
account, so it cannot contribute to a lockout. Proves the three things that
|
||||||
|
actually break a deploy: DNS resolves, a DC answers on 636, and the presented
|
||||||
|
certificate validates against our CA bundle.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
return LdapResult(False, UNCONFIGURED, describe())
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = Connection(_server(), receive_timeout=TIMEOUT_SECONDS, raise_exceptions=True)
|
||||||
|
conn.open()
|
||||||
|
return LdapResult(True, OK, f"ldaps://{HOST}:{PORT} certificate validates")
|
||||||
|
except LDAPCertificateError as exc:
|
||||||
|
return LdapResult(False, UNTRUSTED, str(exc))
|
||||||
|
except LDAPException as exc:
|
||||||
|
return LdapResult(False, UNREACHABLE, str(exc))
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.unbind()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
95
server/ldap_fake.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""A fake directory, for tests only — D13 / T10.7.
|
||||||
|
|
||||||
|
`server/ldap_auth.py` normally opens an LDAPS connection to a domain controller.
|
||||||
|
Tests cannot: CI has no domain, 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, which is what this module is.
|
||||||
|
|
||||||
|
Set `WP_LDAP_FAKE_DIRECTORY` to a JSON object and `ldap_auth.verify()` answers
|
||||||
|
from it instead of touching the network:
|
||||||
|
|
||||||
|
{"root": {"password": "…", "mail": "root@example.test",
|
||||||
|
"full_name": "Root", "groups": ["WP-Suite-Users"]}}
|
||||||
|
|
||||||
|
`groups` is a flat list of names the account is "in". Nested groups do not exist
|
||||||
|
here — the real `member_of` resolves a DN and uses AD's LDAP_MATCHING_RULE_IN_CHAIN,
|
||||||
|
and faking that faithfully would mean reimplementing AD. A test that cares about
|
||||||
|
nesting has to run against a real directory; this one is honest about being a
|
||||||
|
string comparison.
|
||||||
|
|
||||||
|
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
|
||||||
|
|
||||||
|
An environment variable that makes any password work is exactly the kind of thing
|
||||||
|
that escapes into production, and D13 removed every other way in — there is no
|
||||||
|
local password to fall back on and no break-glass, so a fake directory silently
|
||||||
|
active in production would be a total authentication bypass with nothing behind it.
|
||||||
|
|
||||||
|
So `is_active()` refuses whenever a real database is configured, using the same
|
||||||
|
test `auth._load_secret` uses to refuse an ephemeral signing key: a non-SQLite
|
||||||
|
`DATABASE_URL` means production, full stop. `ldap_auth.describe()` also shouts
|
||||||
|
when the fake is live, so the startup line can never be mistaken for a real one.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("wpsuite.ldap.fake")
|
||||||
|
|
||||||
|
ENV_VAR = "WP_LDAP_FAKE_DIRECTORY"
|
||||||
|
|
||||||
|
|
||||||
|
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 ldap_auth, 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 directory — this looks like production, and D13 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 like a bad password
|
||||||
|
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(username: str, password: str, required_group: Optional[str]) -> tuple:
|
||||||
|
"""Return (ok, reason, attrs). Mirrors what ldap_auth.verify() needs.
|
||||||
|
|
||||||
|
Deliberately does NOT re-check for an empty password: `verify()` guards that
|
||||||
|
before it ever gets here, and duplicating the check in the fake would let the
|
||||||
|
real guard rot without any test noticing.
|
||||||
|
"""
|
||||||
|
people = directory()
|
||||||
|
who = people.get(username) or people.get(username.lower())
|
||||||
|
if not isinstance(who, dict) or password != who.get("password"):
|
||||||
|
return (False, "bad_credentials", {})
|
||||||
|
if required_group:
|
||||||
|
groups = who.get("groups") or []
|
||||||
|
if required_group not in groups:
|
||||||
|
return (False, "not_in_group", {})
|
||||||
|
return (True, "ok", {
|
||||||
|
"sam": who.get("sam") or username,
|
||||||
|
"mail": who.get("mail", ""),
|
||||||
|
"full_name": who.get("full_name", ""),
|
||||||
|
"upn": who.get("upn", ""),
|
||||||
|
})
|
||||||
@@ -1,21 +1,42 @@
|
|||||||
"""Command-line user management for the Work Package Suite.
|
"""Command-line user management for the Work Package Suite.
|
||||||
|
|
||||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
Accounts are not created here any more. D13 provisions them on first successful
|
||||||
existing admin, so you have to bootstrap one here), and for occasional account
|
sign-in, so this tool exists to do the one thing the directory cannot decide:
|
||||||
maintenance from a shell on the server.
|
assign the app's PERMISSIONS role. The directory supplies identity; this supplies
|
||||||
|
authorization.
|
||||||
|
|
||||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
|
||||||
and .env resolve the same way the API does:
|
|
||||||
|
|
||||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
|
||||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
|
||||||
python -m server.manage_users list
|
python -m server.manage_users list
|
||||||
python -m server.manage_users reset-password alice
|
python -m server.manage_users promote alice # -> admin
|
||||||
|
python -m server.manage_users promote bob --role project_admin
|
||||||
|
python -m server.manage_users demote alice # -> project_user
|
||||||
python -m server.manage_users disable bob
|
python -m server.manage_users disable bob
|
||||||
python -m server.manage_users enable bob
|
python -m server.manage_users enable bob
|
||||||
|
|
||||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
Run from the PROJECT ROOT (same place you run uvicorn) so the package imports and
|
||||||
at least 8 characters.
|
.env resolve the way the API does.
|
||||||
|
|
||||||
|
`create-admin` and `create` are GONE (D14). They were redundant once accounts
|
||||||
|
provision themselves, and removing them closes a whole class of problem: every
|
||||||
|
row now originates from a successful bind, so a username can no longer be typed
|
||||||
|
in wrong and end up orphaned from the directory identity it was meant to match.
|
||||||
|
|
||||||
|
BOOTSTRAPPING THE FIRST ADMIN is therefore two steps, in this order:
|
||||||
|
1. Sign in to the app once. That provisions your account at project_user.
|
||||||
|
2. Run `promote <your-sAMAccountName>` here.
|
||||||
|
|
||||||
|
EVERY COMMAND THAT CHANGES ANYTHING REQUIRES A DOMAIN BIND (D14). Shell access
|
||||||
|
alone is no longer enough to mint an admin. Be clear about what that is and is
|
||||||
|
not worth: anyone with a shell on this container can still write to the `users`
|
||||||
|
table directly with psql or sqlite3, so this is defence in depth and — mostly —
|
||||||
|
ACCOUNTABILITY. Before D14 every role change made from a shell was invisible in
|
||||||
|
the audit trail while the same change through the console was recorded. Now both
|
||||||
|
are recorded, and both name a person.
|
||||||
|
|
||||||
|
The bind here deliberately does NOT apply the login group gate. If a mistyped
|
||||||
|
required group locks everyone out of the console, this tool has to still work —
|
||||||
|
otherwise the only way to fix the lockout is the only thing the lockout prevents.
|
||||||
|
|
||||||
|
`list` needs no credential, so an outage stays diagnosable.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
@@ -23,124 +44,194 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from .db import SessionLocal, Base, engine
|
from .db import SessionLocal, Base, engine
|
||||||
from . import models, auth
|
from . import models, auth, ldap_auth
|
||||||
|
|
||||||
|
|
||||||
def _gen_id() -> str:
|
def _gen_id(prefix: str = "user") -> str:
|
||||||
return f"user_{uuid.uuid4().hex[:12]}"
|
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None:
|
||||||
pw = provided
|
"""Append an audit row in the caller's transaction.
|
||||||
|
|
||||||
|
Written by hand rather than via app.py's log_event: importing that would drag
|
||||||
|
FastAPI and the entire application into a CLI startup for one INSERT.
|
||||||
|
"""
|
||||||
|
db.add(models.AuditLog(
|
||||||
|
id=_gen_id("ev"), actor=actor, action=action, entity_type="user",
|
||||||
|
entity_id=user.id, summary=user.username, detail=detail,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_operator() -> str:
|
||||||
|
"""Prompt for a domain credential, bind, and return the operator's sAMAccountName.
|
||||||
|
|
||||||
|
Exits on failure — a command that changes a role must not proceed unauthenticated.
|
||||||
|
The password is only ever read from a hidden prompt: there is no --password flag,
|
||||||
|
because that would put a live domain password into shell history and into the
|
||||||
|
output of `ps` for every other user on the box.
|
||||||
|
"""
|
||||||
|
if not ldap_auth.is_configured():
|
||||||
|
sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n"
|
||||||
|
f"This command needs a domain bind. Fix the LDAP configuration first.")
|
||||||
|
who = input("Your domain username: ").strip()
|
||||||
|
if not who:
|
||||||
|
sys.exit("Cancelled.")
|
||||||
|
pw = getpass.getpass("Your domain password: ")
|
||||||
if not pw:
|
if not pw:
|
||||||
pw = getpass.getpass("New password: ")
|
sys.exit("Cancelled.")
|
||||||
confirm = getpass.getpass("Confirm password: ")
|
# required_group="" on purpose: see the module docstring. The login group must
|
||||||
if pw != confirm:
|
# not be able to lock an operator out of the tool that fixes the login group.
|
||||||
sys.exit("Passwords do not match.")
|
result = ldap_auth.verify(who, pw, required_group="")
|
||||||
problem = auth.password_problem(pw, username)
|
if not result.ok:
|
||||||
if problem:
|
# SAY WHY. The /api/auth/login endpoint deliberately returns one generic
|
||||||
sys.exit(problem)
|
# message so an unauthenticated caller cannot enumerate accounts; that
|
||||||
return pw
|
# reasoning does NOT transfer here. This is a local tool, the operator is
|
||||||
|
# the account holder, and there is nobody to leak to — so withholding the
|
||||||
|
# AD sub-code only makes a failure undiagnosable. An earlier version of
|
||||||
|
# this function printed "Authentication failed." and nothing else.
|
||||||
|
print(f"Authentication failed: {result.reason} — {result.detail}", file=sys.stderr)
|
||||||
|
print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}",
|
||||||
|
file=sys.stderr)
|
||||||
|
print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr)
|
||||||
|
print(f" password length : {len(pw)} characters", file=sys.stderr)
|
||||||
|
if result.reason == ldap_auth.BAD_CREDENTIALS:
|
||||||
|
print(" The sub-code above is AD's own reason: 52e = wrong password, "
|
||||||
|
"775 = account locked out, 532 = password expired, "
|
||||||
|
"533 = account disabled, 525 = no such user.", file=sys.stderr)
|
||||||
|
print(" A 525 with a password you know is correct means the BIND NAME is "
|
||||||
|
"wrong, not the password. This binds as <sAMAccountName>@LDAP_DOMAIN, "
|
||||||
|
"which only works where that matches your real UPN suffix — set "
|
||||||
|
"LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Authenticated as {result.sam}.")
|
||||||
|
return result.sam
|
||||||
|
|
||||||
|
|
||||||
def cmd_create(args, role: str | None = None) -> None:
|
def _load(db, username: str) -> "models.User":
|
||||||
role = role or args.role
|
u = auth.find_user(db, username)
|
||||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
if not u:
|
||||||
# documented one-liners keep working; anything else has to be a current role.
|
sys.exit(f"No account named '{username}'. Accounts are created on first "
|
||||||
if role == "user":
|
f"sign-in — has this person signed in yet? `list` shows who exists.")
|
||||||
role = auth.ROLE_PROJECT_USER
|
return u
|
||||||
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)
|
|
||||||
db.commit()
|
|
||||||
print(f"Created {role}: {u.username} (id={u.id})")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args) -> None:
|
def cmd_list(args) -> None:
|
||||||
|
"""Read-only, and deliberately needs no credential: during an outage this is
|
||||||
|
how you find out what the app thinks the world looks like."""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rows = db.query(models.User).order_by(models.User.username).all()
|
rows = db.query(models.User).order_by(models.User.username).all()
|
||||||
if not rows:
|
if not rows:
|
||||||
print("No users yet. Create one with: create-admin <username>")
|
print("No accounts yet. They are created on first successful sign-in.")
|
||||||
return
|
return
|
||||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'LAST LOGIN':<22}{'NAME'}")
|
||||||
for u in rows:
|
for u in rows:
|
||||||
|
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
|
||||||
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
||||||
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_reset_password(args) -> None:
|
def _set_role(username: str, role: str) -> None:
|
||||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
if role not in auth.ROLES:
|
||||||
|
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
||||||
|
operator = authenticate_operator()
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
u = auth.find_user(db, args.username)
|
u = _load(db, username)
|
||||||
if not u:
|
old = auth.normalize_role(u.role)
|
||||||
sys.exit(f"No user named '{args.username}'.")
|
if old == role:
|
||||||
u.password_hash = auth.hash_password(pw)
|
print(f"{u.username} is already {role}. Nothing to do.")
|
||||||
|
return
|
||||||
|
# The last-admin guard, matching set_user_role in app.py: an app with no
|
||||||
|
# admin cannot be administered, and there is no password login left to
|
||||||
|
# recover through.
|
||||||
|
if old == auth.ROLE_ADMIN and role != auth.ROLE_ADMIN:
|
||||||
|
others = db.query(models.User).filter(
|
||||||
|
models.User.role == auth.ROLE_ADMIN,
|
||||||
|
models.User.id != u.id,
|
||||||
|
models.User.is_active.is_(True),
|
||||||
|
).count()
|
||||||
|
if not others:
|
||||||
|
sys.exit("Refusing: that is the last active admin account. Promote "
|
||||||
|
"someone else first.")
|
||||||
|
detail = {"from": old, "to": role, "via": "manage_users"}
|
||||||
|
u.role = role
|
||||||
|
# Mirrors set_user_role: an admin already reaches every project, so the
|
||||||
|
# default-member flag would sit there doing nothing and spring back to life
|
||||||
|
# on demotion.
|
||||||
|
if role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
|
||||||
|
u.auto_add_projects = False
|
||||||
|
u.auto_add_role = ""
|
||||||
|
detail["auto_add_cleared"] = True
|
||||||
|
# NOTE: unlike the API, this permits changing your OWN role. The endpoint
|
||||||
|
# forbids it to stop an admin locking themselves out of the console; here it
|
||||||
|
# is the entire bootstrap path — sign in, then promote yourself.
|
||||||
|
if u.username.lower() == operator.lower():
|
||||||
|
detail["self"] = True
|
||||||
|
_audit(db, operator, "role_changed", u, detail)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"Password reset for {u.username}.")
|
print(f"{u.username}: {old} -> {role}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_promote(args) -> None:
|
||||||
|
_set_role(args.username, args.role)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_demote(args) -> None:
|
||||||
|
_set_role(args.username, auth.ROLE_PROJECT_USER)
|
||||||
|
|
||||||
|
|
||||||
def _set_active(username: str, active: bool) -> None:
|
def _set_active(username: str, active: bool) -> None:
|
||||||
|
operator = authenticate_operator()
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
u = auth.find_user(db, username)
|
u = _load(db, username)
|
||||||
if not u:
|
if bool(u.is_active) == active:
|
||||||
sys.exit(f"No user named '{username}'.")
|
print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
|
||||||
|
return
|
||||||
u.is_active = active
|
u.is_active = active
|
||||||
|
# Disabling has to take effect on sessions already issued, and role reads go
|
||||||
|
# through the database on every request — but token_version is what get_current_user
|
||||||
|
# checks, so bump it to sign them out now rather than at session expiry.
|
||||||
|
u.token_version = (u.token_version or 0) + 1
|
||||||
|
_audit(db, operator, "user_active_changed", u,
|
||||||
|
{"is_active": active, "via": "manage_users"})
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# Ensure the users table exists even on a fresh database.
|
# Ensure tables exist on a fresh local database (SQLite dev). Production owns
|
||||||
|
# its schema through alembic.
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
p = argparse.ArgumentParser(
|
||||||
|
prog="manage_users",
|
||||||
|
description="Work Package Suite user management. Accounts are created on "
|
||||||
|
"first sign-in (D13); this assigns roles.")
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
def add_create(name, help_):
|
sub.add_parser("list", help="list all accounts (no credential needed)")
|
||||||
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")
|
pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)")
|
||||||
c = add_create("create", "create an account")
|
pr.add_argument("username", help="the person's sAMAccountName")
|
||||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
pr.add_argument("--role", default=auth.ROLE_ADMIN, choices=list(auth.ROLES),
|
||||||
help="permissions role ('user' is the legacy name for project_user)")
|
help="target role (default: admin)")
|
||||||
|
|
||||||
sub.add_parser("list", help="list all accounts")
|
dm = sub.add_parser("demote", help=f"set an account back to {auth.ROLE_PROJECT_USER}")
|
||||||
|
dm.add_argument("username", help="the person's sAMAccountName")
|
||||||
|
|
||||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||||
rp.add_argument("username")
|
|
||||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
|
||||||
|
|
||||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
|
||||||
dp.add_argument("username")
|
dp.add_argument("username")
|
||||||
ep = sub.add_parser("enable", help="re-enable an account")
|
ep = sub.add_parser("enable", help="re-enable an account")
|
||||||
ep.add_argument("username")
|
ep.add_argument("username")
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
if args.cmd == "create-admin":
|
if args.cmd == "list":
|
||||||
cmd_create(args, role="admin")
|
|
||||||
elif args.cmd == "create":
|
|
||||||
cmd_create(args)
|
|
||||||
elif args.cmd == "list":
|
|
||||||
cmd_list(args)
|
cmd_list(args)
|
||||||
elif args.cmd == "reset-password":
|
elif args.cmd == "promote":
|
||||||
cmd_reset_password(args)
|
cmd_promote(args)
|
||||||
|
elif args.cmd == "demote":
|
||||||
|
cmd_demote(args)
|
||||||
elif args.cmd == "disable":
|
elif args.cmd == "disable":
|
||||||
_set_active(args.username, False)
|
_set_active(args.username, False)
|
||||||
elif args.cmd == "enable":
|
elif args.cmd == "enable":
|
||||||
|
|||||||
@@ -142,8 +142,14 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
"""A login account. NO PASSWORD IS STORED — D13 moved authentication to an
|
||||||
hash (see server/auth.py). `username` is what people sign in with.
|
LDAPS bind against the domain (see server/ldap_auth.py), and the
|
||||||
|
`password_hash` column was dropped. `username` is the sAMAccountName people
|
||||||
|
sign in with; an account is created on first successful sign-in if it does not
|
||||||
|
already exist.
|
||||||
|
|
||||||
|
`is_active` is LOCAL and overrides the directory: clearing it revokes access to
|
||||||
|
this app without touching the domain account.
|
||||||
|
|
||||||
Two independent notions of "role", deliberately separate:
|
Two independent notions of "role", deliberately separate:
|
||||||
• role the PERMISSIONS role — what the account may do in the app.
|
• role the PERMISSIONS role — what the account may do in the app.
|
||||||
@@ -159,7 +165,6 @@ class User(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||||
email: Mapped[str] = mapped_column(String(200), default="")
|
email: Mapped[str] = mapped_column(String(200), default="")
|
||||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
|
||||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||||
# Job function on the project — free text, offered from a suggested list.
|
# Job function on the project — free text, offered from a suggested list.
|
||||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||||
@@ -182,12 +187,14 @@ class User(Base):
|
|||||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
# Bumped to invalidate all existing sessions for this user. Password changes no
|
||||||
# change). The value is embedded in the JWT and re-checked on every request.
|
# longer exist (D13), but a role change or a deactivation still has to take
|
||||||
|
# effect on live sessions. The value is embedded in the JWT and re-checked on
|
||||||
|
# every request.
|
||||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Public view of a user — NEVER includes the password hash."""
|
"""Public view of a user."""
|
||||||
return {
|
return {
|
||||||
"id": self.id, "username": self.username, "email": self.email,
|
"id": self.id, "username": self.username, "email": self.email,
|
||||||
"full_name": self.full_name, "role": self.role,
|
"full_name": self.full_name, "role": self.role,
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
background task. Notification bodies carry customer CONTEXT — the WP number, its
|
||||||
number and a deep link, not the work-package contents.
|
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 os
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
@@ -46,7 +49,8 @@ DEFAULTS = {
|
|||||||
|
|
||||||
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||||
# are safe for any authenticated user to read (feature flags + localization
|
# are safe for any authenticated user to read (feature flags + localization
|
||||||
# defaults + whether self-service password reset can work at all).
|
# defaults). Self-service password reset is gone with D13 — the login page links to
|
||||||
|
# Okta instead, so there is nothing left for the client to feature-detect.
|
||||||
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||||
|
|
||||||
|
|
||||||
@@ -80,13 +84,9 @@ def public_settings(db: Session) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def app_flags(db: Session) -> dict:
|
def app_flags(db: Session) -> dict:
|
||||||
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
|
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
|
||||||
`password_reset_enabled` tells the login page whether a self-service reset can
|
|
||||||
actually deliver mail — there's no point offering the link otherwise."""
|
|
||||||
s = get_settings(db)
|
s = get_settings(db)
|
||||||
out = {k: s.get(k) for k in PUBLIC_KEYS}
|
return {k: s.get(k) for k in PUBLIC_KEYS}
|
||||||
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def smtp_ready(s: dict) -> bool:
|
def smtp_ready(s: dict) -> bool:
|
||||||
@@ -107,7 +107,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
|||||||
port = int(s.get("smtp_port") or 587)
|
port = int(s.get("smtp_port") or 587)
|
||||||
user = s.get("smtp_username") or ""
|
user = s.get("smtp_username") or ""
|
||||||
pw = os.getenv("SMTP_PASSWORD", "")
|
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):
|
if s.get("smtp_use_tls", True):
|
||||||
srv.starttls()
|
srv.starttls()
|
||||||
if user:
|
if user:
|
||||||
@@ -115,22 +121,6 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
|||||||
srv.send_message(msg)
|
srv.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
|
|
||||||
"""Send one email immediately, outside the outbox. Used for password resets —
|
|
||||||
a reset link must never sit in a queue, and it must not be persisted in the
|
|
||||||
notifications table where an admin could read it and take over the account.
|
|
||||||
Returns True if it went out."""
|
|
||||||
s = get_settings(db)
|
|
||||||
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
send_email(s, to_addr, subject, body)
|
|
||||||
return True
|
|
||||||
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
|
|
||||||
log.warning("password-reset email to %s failed: %s", to_addr, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||||
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||||
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||||
|
|||||||
@@ -9,8 +9,18 @@ gunicorn==26.0.0
|
|||||||
sqlalchemy==2.0.51
|
sqlalchemy==2.0.51
|
||||||
alembic==1.18.5 # database migrations
|
alembic==1.18.5 # database migrations
|
||||||
psycopg[binary]==3.3.4
|
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
|
pydantic==2.13.4
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
bcrypt==5.0.0 # password hashing
|
bcrypt==5.0.0 # password hashing
|
||||||
PyJWT==2.13.0 # signed session tokens
|
PyJWT==2.13.0 # signed session tokens
|
||||||
|
ldap3==2.9.1 # D13: LDAPS simple bind against prime.local. Pure Python,
|
||||||
|
# so no system libldap/OpenLDAP headers in the image. The
|
||||||
|
# trust anchor is server/certs/prime-ca-chain.pem, NOT the
|
||||||
|
# system store — see server/ldap_auth.py.
|
||||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ def main():
|
|||||||
str(shown) == str(as_root["total"]),
|
str(shown) == str(as_root["total"]),
|
||||||
"shown=%r server=%r (poisoned cache said 2)" % (shown, 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)
|
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")
|
print("\n3. a failed aggregate request is an error, not a zero")
|
||||||
page.eval("""(() => {
|
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")
|
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
|
||||||
browser = cdp.Browser()
|
browser = cdp.Browser()
|
||||||
page = browser.page()
|
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:
|
for name, filename, user, wait_for in pages:
|
||||||
capture(page, base, tok, name, filename, user, wait_for,
|
capture(page, base, tok, name, filename, user, wait_for,
|
||||||
widths, args.out, args.label)
|
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".
|
not test this" is not the same answer as "this is broken".
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -86,7 +87,7 @@ def seed(db_path):
|
|||||||
def mk(username, role):
|
def mk(username, role):
|
||||||
db.add(models.User(id="user_" + username, username=username,
|
db.add(models.User(id="user_" + username, username=username,
|
||||||
email=f"{username}@example.test", full_name=username.title(),
|
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("root", auth.ROLE_ADMIN)
|
||||||
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
||||||
@@ -109,9 +110,34 @@ def seed(db_path):
|
|||||||
# Job A gets a complete SOP and two packages. Without a SOP the field view's
|
# 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
|
# 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.
|
# 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",
|
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
|
||||||
complete=True,
|
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()
|
db.flush()
|
||||||
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
|
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
|
||||||
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
|
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
|
||||||
@@ -125,10 +151,33 @@ def seed(db_path):
|
|||||||
for u in db.query(models.User).all()}
|
for u in db.query(models.User).all()}
|
||||||
|
|
||||||
|
|
||||||
|
# D13 / T10.7. The app authenticates by binding to a domain controller, 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/ldap_fake.py
|
||||||
|
# reads this instead, and refuses to work against a non-SQLite database.
|
||||||
|
#
|
||||||
|
# Most checks never sign in (seed() mints tokens with auth.create_token and sets
|
||||||
|
# the cookie directly), so this matters only where the login FORM is driven —
|
||||||
|
# url_state_check's deep-link-through-login case. It is set for every server here
|
||||||
|
# anyway so that a test which starts signing in later does not fail mysteriously.
|
||||||
|
FAKE_DIRECTORY = json.dumps({
|
||||||
|
u: {"password": PW, "mail": f"{u}@example.test", "full_name": u.title(),
|
||||||
|
"groups": ["WP-Suite-Users"]}
|
||||||
|
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def start_server(port, db_path):
|
def start_server(port, db_path):
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||||
|
env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
||||||
|
# SET empty, never pop: server/db.py calls load_dotenv() at import and
|
||||||
|
# python-dotenv only skips keys already present in os.environ, so a popped
|
||||||
|
# variable comes back from the developer's .env inside the subprocess. An empty
|
||||||
|
# string is "present" and therefore wins. The fake grants "WP-Suite-Users" to
|
||||||
|
# everyone; a test asserting the group gate belongs in ldap_auth_check.
|
||||||
|
env["LDAP_REQUIRED_GROUP"] = ""
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||||
"--port", str(port), "--log-level", "warning"],
|
"--port", str(port), "--log-level", "warning"],
|
||||||
|
|||||||
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())
|
||||||
124
tests/console_dialogs_check.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/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 password-reset prompt on
|
||||||
|
the users console with natives poisoned, and proves validate() answers AT the
|
||||||
|
input while the server round-trip completes end to end.
|
||||||
|
|
||||||
|
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))
|
spans = len(re.findall(r"<span[^>]*onclick", code))
|
||||||
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
||||||
% spans)
|
% 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 ──────────────────────────────────────────────────────────
|
# ── 2. the rail ──────────────────────────────────────────────────────────
|
||||||
print("\n2. the rail is built from the cards")
|
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)"))
|
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
|
||||||
if over["scroll"] > over["client"] + 2:
|
if over["scroll"] > over["client"] + 2:
|
||||||
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
|
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
|
# CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
|
||||||
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
|
# cause was actually removed: the help-tip's CSS ::after escaped its badge to
|
||||||
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
|
# the right, and rebuilding the component (S8) with a viewport-clamped bubble
|
||||||
# out again - which is the point of pinning rather than printing.
|
# ended the overflow. The check now asserts the FIX, so a regression reopens
|
||||||
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
|
# BL-001 loudly instead of quietly re-widening the page.
|
||||||
"fails when it is fixed",
|
chk("BL-001 is closed: the creator does not overflow at 390px",
|
||||||
over["scroll"] > over["client"] + 2, over)
|
over["scroll"] <= over["client"] + 2, over)
|
||||||
|
|
||||||
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
|
# 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,
|
# 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())
|
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
|
||||||
bad = []
|
bad = []
|
||||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
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)
|
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
|
||||||
and any(isinstance(t, ast.Attribute) and t.attr == "status"
|
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)]
|
for t in n.targets)]
|
||||||
if not assigns:
|
if not assigns:
|
||||||
continue
|
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)
|
rcpts = sorted(m["to"][0] for m in sink.messages)
|
||||||
chk("...exactly them, actor excluded",
|
chk("...exactly them, actor excluded",
|
||||||
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
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",
|
chk("the mail says old status, new status and who",
|
||||||
"In Transit" in body and "Delivered" in body and "Root" in body,
|
"In Transit" in body and "Delivered" in body and "Root" in body,
|
||||||
ascii_(body, 260))
|
ascii_(body, 260))
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ def seed_empty(db_path):
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
db.add(models.User(id="user_new", username="new", email="new@example.test",
|
db.add(models.User(id="user_new", username="new", email="new@example.test",
|
||||||
full_name="New Starter", password_hash=auth.hash_password(PW),
|
full_name="New Starter", role=auth.ROLE_ADMIN))
|
||||||
role=auth.ROLE_ADMIN))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
|
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
|
||||||
|
|
||||||
|
|||||||
419
tests/ldap_auth_check.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Does domain authentication hold its guarantees? — D13 / T10.7.
|
||||||
|
|
||||||
|
Covers the four things `docs/waves/decisions-2026-08-21.md` calls non-negotiable,
|
||||||
|
plus the two provisioning rules that decide whether an existing admin survives the
|
||||||
|
switch. These are the failure modes where the app still *looks* fine:
|
||||||
|
|
||||||
|
1. An empty password must not reach bind(). In LDAP a simple bind with an empty
|
||||||
|
password is an ANONYMOUS bind and it SUCCEEDS — so without the guard, a blank
|
||||||
|
password authenticates as whatever username was submitted.
|
||||||
|
2. TLS must be CERT_REQUIRED with an explicit CA file. CERT_NONE still encrypts,
|
||||||
|
so it fails silently; what it loses is the ability to tell a real DC from
|
||||||
|
someone harvesting domain passwords.
|
||||||
|
3. Group membership must be evaluated through AD's nested-group matching rule.
|
||||||
|
Plain memberOf is direct membership only and wrongly refuses real people.
|
||||||
|
4. The fake directory must be impossible to select against a real database.
|
||||||
|
5. A refused sign-in must create no account.
|
||||||
|
6. An existing admin must still be an admin afterwards.
|
||||||
|
|
||||||
|
Self-contained: throwaway SQLite + its own uvicorn. No browser, no domain — the
|
||||||
|
fake directory (server/ldap_fake.py) stands in for the DC.
|
||||||
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from cdp import free_port # noqa: E402
|
||||||
|
|
||||||
|
_PASS, _FAIL = [], []
|
||||||
|
PW = "CorrectHorseBattery9"
|
||||||
|
GROUP = "WP-Suite-Users"
|
||||||
|
SECRET = "ldap-auth-check-not-for-production"
|
||||||
|
os.environ.setdefault("AUTH_SECRET_KEY", SECRET)
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def chk(label, ok, detail=""):
|
||||||
|
(_PASS if ok else _FAIL).append(label)
|
||||||
|
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
|
||||||
|
|
||||||
|
|
||||||
|
def post(base, path, payload):
|
||||||
|
req = urllib.request.Request(base + path, method="POST",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return r.status, json.loads(r.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def get(base, path, token):
|
||||||
|
req = urllib.request.Request(base + path, headers={"Cookie": f"wp_session={token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return r.status, json.loads(r.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def post_as(base, path, token, payload):
|
||||||
|
req = urllib.request.Request(base + path, method="POST",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Cookie": f"wp_session={token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return r.status, json.loads(r.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def start(port, db_path, fake, required_group=""):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||||
|
env["AUTH_SECRET_KEY"] = SECRET
|
||||||
|
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
|
||||||
|
# SET it empty, never pop it. server/db.py calls load_dotenv() at import, and
|
||||||
|
# python-dotenv only skips a key that is already present in os.environ — so a
|
||||||
|
# popped variable is helpfully restored from the developer's .env inside the
|
||||||
|
# subprocess, and the test silently runs against the real required group.
|
||||||
|
# An empty string counts as present, so it wins.
|
||||||
|
env["LDAP_REQUIRED_GROUP"] = required_group or ""
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||||
|
"--port", str(port), "--log-level", "warning"],
|
||||||
|
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
|
||||||
|
for _ in range(160):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
|
||||||
|
return proc
|
||||||
|
except Exception:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return None
|
||||||
|
time.sleep(0.25)
|
||||||
|
proc.kill()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def users_in(db_path):
|
||||||
|
"""Read the users table straight out of the given file.
|
||||||
|
|
||||||
|
Deliberately NOT via server.db.SessionLocal: that engine is bound from
|
||||||
|
DATABASE_URL when the module is first imported, so setting the env var later
|
||||||
|
keeps reading whichever database was configured first. Two assertions in this
|
||||||
|
file passed against the wrong file before that was noticed.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
return {r[0]: r[1] for r in con.execute("select username, role from users")}
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return {} # no users table yet
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("1. the guards that do not need a server")
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite:///./_ldapcheck_unit.db"
|
||||||
|
os.environ["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(
|
||||||
|
{"root": {"password": PW, "groups": [GROUP]}})
|
||||||
|
from server import ldap_auth
|
||||||
|
|
||||||
|
# 1 — the anonymous-bind guard. Connection is nulled so ANY call to bind()
|
||||||
|
# would raise: this proves the guard returns before the transport is touched,
|
||||||
|
# rather than merely that the result is a failure.
|
||||||
|
saved, ldap_auth.Connection = ldap_auth.Connection, None
|
||||||
|
try:
|
||||||
|
for label, u, p in [("an empty password", "root", ""),
|
||||||
|
("a whitespace-only password", "root", " "),
|
||||||
|
("an empty username", "", PW)]:
|
||||||
|
r = ldap_auth.verify(u, p, required_group="")
|
||||||
|
chk(f"{label} is refused without reaching bind()",
|
||||||
|
(not r.ok) and r.reason == ldap_auth.EMPTY_INPUT, r.reason)
|
||||||
|
finally:
|
||||||
|
ldap_auth.Connection = saved
|
||||||
|
|
||||||
|
# 2 — TLS configuration.
|
||||||
|
tls = ldap_auth._tls()
|
||||||
|
chk("TLS validate is CERT_REQUIRED", tls.validate == ssl.CERT_REQUIRED, tls.validate)
|
||||||
|
chk("...with an explicit CA file, not the system store",
|
||||||
|
bool(tls.ca_certs_file) and os.path.isfile(tls.ca_certs_file), tls.ca_certs_file)
|
||||||
|
src = open(os.path.join(ROOT, "server", "ldap_auth.py"), encoding="utf-8").read()
|
||||||
|
# Parse the module rather than grep it: the docstring names validate=ssl.CERT_NONE
|
||||||
|
# in order to explain why it must never be used, and a text search cannot tell
|
||||||
|
# that apart from an actual call. Walk every keyword argument called `validate`
|
||||||
|
# and check what it is really set to.
|
||||||
|
import ast as _ast
|
||||||
|
bad = []
|
||||||
|
for node in _ast.walk(_ast.parse(src)):
|
||||||
|
if isinstance(node, _ast.Call):
|
||||||
|
for kw in node.keywords:
|
||||||
|
if kw.arg == "validate":
|
||||||
|
name = (kw.value.attr if isinstance(kw.value, _ast.Attribute)
|
||||||
|
else getattr(kw.value, "id", ""))
|
||||||
|
if name != "CERT_REQUIRED":
|
||||||
|
bad.append(f"line {node.lineno}: validate={name or '?'}")
|
||||||
|
chk("every validate= in the module is CERT_REQUIRED (AST, not grep)",
|
||||||
|
not bad, "; ".join(bad))
|
||||||
|
|
||||||
|
# 3 — nested groups. The fake cannot model AD nesting (it is a string list), so
|
||||||
|
# what is asserted is that the REAL path builds AD's transitive matching rule
|
||||||
|
# into its filter. A test that truly exercises nesting needs a real directory.
|
||||||
|
chk("membership uses AD's nested matching rule, not plain memberOf",
|
||||||
|
ldap_auth.NESTED_MEMBER_RULE == "1.2.840.113556.1.4.1941"
|
||||||
|
and "memberOf:{NESTED_MEMBER_RULE}:=" in src,
|
||||||
|
"the transitive matching rule is not in the search filter")
|
||||||
|
|
||||||
|
# 4 — the production guard on the fake, in a subprocess because DATABASE_URL is
|
||||||
|
# read at import time.
|
||||||
|
out = subprocess.run(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
"from server import ldap_fake; print(ldap_fake.is_active())"],
|
||||||
|
cwd=ROOT, capture_output=True, text=True,
|
||||||
|
env={**os.environ,
|
||||||
|
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
|
||||||
|
"AUTH_SECRET_KEY": "x",
|
||||||
|
"WP_LDAP_FAKE_DIRECTORY": json.dumps({"root": {"password": PW}})})
|
||||||
|
chk("the fake directory REFUSES to work against a non-SQLite database",
|
||||||
|
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
|
||||||
|
|
||||||
|
print("\n2. sign-in, against a server")
|
||||||
|
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
|
||||||
|
port = free_port()
|
||||||
|
fake = {"root": {"password": PW, "mail": "root@example.test",
|
||||||
|
"full_name": "Root Person", "groups": [GROUP]},
|
||||||
|
"outsider": {"password": PW, "mail": "outsider@example.test",
|
||||||
|
"full_name": "Out Sider", "groups": ["SomeOtherGroup"]}}
|
||||||
|
server = start(port, db_path, fake, required_group=GROUP)
|
||||||
|
if server is None:
|
||||||
|
print("the test server would not start.")
|
||||||
|
return 2
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
try:
|
||||||
|
st, _ = post(base, "/api/auth/login", {"username": "root", "password": PW})
|
||||||
|
chk("a correct password signs in", st == 200, st)
|
||||||
|
chk("...and provisioned the account at project_user",
|
||||||
|
users_in(db_path).get("root") == "project_user", users_in(db_path))
|
||||||
|
|
||||||
|
st, body = post(base, "/api/auth/login", {"username": "root", "password": "wrong-" + PW})
|
||||||
|
chk("a wrong password is refused", st == 401, st)
|
||||||
|
chk("...with a message that does not say why",
|
||||||
|
"password" in (body.get("detail") or "").lower()
|
||||||
|
and "expired" not in (body.get("detail") or "").lower(), body)
|
||||||
|
|
||||||
|
st, _ = post(base, "/api/auth/login", {"username": "root", "password": ""})
|
||||||
|
chk("a blank password is refused at the endpoint too", st == 401, st)
|
||||||
|
|
||||||
|
before = set(users_in(db_path))
|
||||||
|
st, _ = post(base, "/api/auth/login", {"username": "outsider", "password": PW})
|
||||||
|
chk("a correct password OUTSIDE the required group is refused", st == 401, st)
|
||||||
|
chk("...and no account was created for them",
|
||||||
|
set(users_in(db_path)) == before, set(users_in(db_path)) - before)
|
||||||
|
|
||||||
|
st, _ = post(base, "/api/auth/login", {"username": "nobody", "password": PW})
|
||||||
|
chk("an unknown account is refused and creates nothing",
|
||||||
|
st == 401 and "nobody" not in users_in(db_path), st)
|
||||||
|
finally:
|
||||||
|
server.kill()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("4. local state overrides the directory, and the throttle protects it")
|
||||||
|
db_fd3, db3 = tempfile.mkstemp(suffix=".db"); os.close(db_fd3)
|
||||||
|
import sqlalchemy as _sa0
|
||||||
|
from server.db import Base as _B0
|
||||||
|
from server import models as _m0 # noqa: F401
|
||||||
|
eng0 = _sa0.create_engine("sqlite:///" + db3.replace("\\", "/"))
|
||||||
|
_B0.metadata.create_all(bind=eng0)
|
||||||
|
with eng0.begin() as con:
|
||||||
|
con.execute(_sa0.text(
|
||||||
|
"insert into users (id,username,email,full_name,role,is_active,"
|
||||||
|
"failed_attempts,token_version,project_role,locale,timezone,"
|
||||||
|
"auto_add_projects,auto_add_role,created_at,updated_at) values "
|
||||||
|
"('user_root','root','','Root','project_user',0,0,0,'','','',0,'',"
|
||||||
|
"datetime('now'),datetime('now'))")) # is_active = 0
|
||||||
|
eng0.dispose()
|
||||||
|
port3 = free_port()
|
||||||
|
server3 = start(port3, db3, fake, required_group=GROUP)
|
||||||
|
if server3 is None:
|
||||||
|
print("the third test server would not start.")
|
||||||
|
return 2
|
||||||
|
b3 = f"http://127.0.0.1:{port3}"
|
||||||
|
try:
|
||||||
|
st, _ = post(b3, "/api/auth/login", {"username": "root", "password": PW})
|
||||||
|
chk("a disabled local account is refused even though the bind succeeds",
|
||||||
|
st == 403, st)
|
||||||
|
|
||||||
|
# The throttle. AUTH_MAX_ATTEMPTS is 2, and its whole purpose is that failures
|
||||||
|
# are real domain binds counting against the AD lockout policy — so it has to
|
||||||
|
# stop CALLING the directory, not merely refuse. Proving that: burn the budget
|
||||||
|
# with wrong passwords, then present the CORRECT one. A 429 for a credential
|
||||||
|
# that would otherwise succeed is only possible if the throttle runs before the
|
||||||
|
# directory is consulted.
|
||||||
|
codes = [post(b3, "/api/auth/login",
|
||||||
|
{"username": "outsider", "password": "wrong"})[0] for _ in range(3)]
|
||||||
|
chk("the attempt budget is spent and the next try is throttled",
|
||||||
|
codes[-1] == 429, codes)
|
||||||
|
st, _ = post(b3, "/api/auth/login", {"username": "outsider", "password": PW})
|
||||||
|
chk("...and a CORRECT password is still refused while throttled, proving the "
|
||||||
|
"directory is never reached", st == 429, st)
|
||||||
|
chk("...while another account is unaffected (the budget is per-username)",
|
||||||
|
post(b3, "/api/auth/login", {"username": "root", "password": PW})[0] == 403, "")
|
||||||
|
finally:
|
||||||
|
server3.kill()
|
||||||
|
try:
|
||||||
|
server3.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\n3. an existing admin survives the switch")
|
||||||
|
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
|
||||||
|
# Build the schema with a NEW engine bound to this file — see users_in().
|
||||||
|
import sqlalchemy as _sa
|
||||||
|
from server.db import Base
|
||||||
|
from server import models # noqa: F401
|
||||||
|
eng2 = _sa.create_engine("sqlite:///" + db2.replace("\\", "/"))
|
||||||
|
Base.metadata.create_all(bind=eng2)
|
||||||
|
with eng2.begin() as con:
|
||||||
|
con.execute(_sa.text(
|
||||||
|
"insert into users (id,username,email,full_name,role,is_active,"
|
||||||
|
"failed_attempts,token_version,project_role,locale,timezone,"
|
||||||
|
"auto_add_projects,auto_add_role,created_at,updated_at) values "
|
||||||
|
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
|
||||||
|
"datetime('now'),datetime('now'))"))
|
||||||
|
eng2.dispose()
|
||||||
|
port2 = free_port()
|
||||||
|
server2 = start(port2, db2, fake, required_group=GROUP)
|
||||||
|
if server2 is None:
|
||||||
|
print("the second test server would not start.")
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
st, body = post(f"http://127.0.0.1:{port2}", "/api/auth/login",
|
||||||
|
{"username": "root", "password": PW})
|
||||||
|
chk("the pre-existing admin signs in", st == 200, st)
|
||||||
|
chk("...and is STILL an admin (D13 criterion 4)",
|
||||||
|
users_in(db2).get("root") == "admin", users_in(db2))
|
||||||
|
chk("...and their locally-set name was not overwritten by the directory",
|
||||||
|
(body.get("user") or {}).get("full_name") == "Set By Hand", body.get("user"))
|
||||||
|
chk("...and no duplicate account appeared",
|
||||||
|
len(users_in(db2)) == 1, users_in(db2))
|
||||||
|
finally:
|
||||||
|
server2.kill()
|
||||||
|
try:
|
||||||
|
server2.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("5. the console's own routes, and what token_version actually does")
|
||||||
|
db_fd4, db4 = tempfile.mkstemp(suffix=".db"); os.close(db_fd4)
|
||||||
|
import sqlalchemy as _sa1
|
||||||
|
from server.db import Base as _B1
|
||||||
|
from server import models as _m1, auth as _auth # noqa: F401
|
||||||
|
url4 = "sqlite:///" + db4.replace("\\", "/")
|
||||||
|
eng1 = _sa1.create_engine(url4)
|
||||||
|
_B1.metadata.create_all(bind=eng1)
|
||||||
|
with eng1.begin() as con:
|
||||||
|
for uid, un, role in [("user_boss", "root", "admin"),
|
||||||
|
("user_pat", "pat", "project_user")]:
|
||||||
|
con.execute(_sa1.text(
|
||||||
|
"insert into users (id,username,email,full_name,role,is_active,"
|
||||||
|
"failed_attempts,token_version,project_role,locale,timezone,"
|
||||||
|
"auto_add_projects,auto_add_role,created_at,updated_at) values "
|
||||||
|
f"('{uid}','{un}','','{un}','{role}',1,0,0,'','','',0,'',"
|
||||||
|
"datetime('now'),datetime('now'))"))
|
||||||
|
|
||||||
|
class _U:
|
||||||
|
pass
|
||||||
|
def _tok(uid, un, role):
|
||||||
|
u = _U(); u.id = uid; u.username = un; u.role = role; u.token_version = 0
|
||||||
|
return _auth.create_token(u)
|
||||||
|
boss_tok = _tok("user_boss", "root", "admin")
|
||||||
|
pat_tok = _tok("user_pat", "pat", "project_user")
|
||||||
|
|
||||||
|
port4 = free_port()
|
||||||
|
server4 = start(port4, db4, fake, required_group="")
|
||||||
|
if server4 is None:
|
||||||
|
print("the fourth test server would not start.")
|
||||||
|
return 2
|
||||||
|
b4 = f"http://127.0.0.1:{port4}"
|
||||||
|
try:
|
||||||
|
st, body = get(b4, "/api/auth/users", boss_tok)
|
||||||
|
chk("an admin can list accounts", st == 200, st)
|
||||||
|
|
||||||
|
post(b4, "/api/auth/login", {"username": "outsider", "password": PW})
|
||||||
|
st, body = get(b4, "/api/auth/users", boss_tok)
|
||||||
|
rows = body if isinstance(body, list) else (body.get("users") or body.get("items") or [])
|
||||||
|
names = [u.get("username") for u in rows if isinstance(u, dict)]
|
||||||
|
chk("a just-provisioned account appears in the Admin console list",
|
||||||
|
"outsider" in names, names)
|
||||||
|
|
||||||
|
# Exactly the request users.html sends — D13 criterion 4.
|
||||||
|
st, _ = post_as(b4, "/api/auth/users/user_pat/role", boss_tok, {"role": "admin"})
|
||||||
|
chk("granting admin through the console route succeeds", st == 200, st)
|
||||||
|
chk("...and the role really changed", users_in(db4).get("pat") == "admin", users_in(db4))
|
||||||
|
|
||||||
|
# token_version. NOTHING in app.py bumps it any more: is_active and role are
|
||||||
|
# re-read from the database every request, so both take effect at once without
|
||||||
|
# it. What it still does is invalidate an ALREADY-ISSUED cookie, which is what
|
||||||
|
# manage_users does on disable. Bump it directly and prove the effect.
|
||||||
|
eng2 = _sa1.create_engine(url4)
|
||||||
|
with eng2.begin() as con:
|
||||||
|
con.execute(_sa1.text("update users set token_version = 1 where id='user_pat'"))
|
||||||
|
eng2.dispose()
|
||||||
|
st, _ = get(b4, "/api/auth/me", pat_tok)
|
||||||
|
chk("bumping token_version invalidates a cookie already issued", st == 401, st)
|
||||||
|
st, _ = get(b4, "/api/auth/me", boss_tok)
|
||||||
|
chk("...and leaves every other session alone", st == 200, st)
|
||||||
|
|
||||||
|
st, body = post(b4, "/api/auth/login", {"username": "root", "password": "wrong"})
|
||||||
|
blob = json.dumps(body).lower()
|
||||||
|
chk("no AD sub-code leaks into a response body",
|
||||||
|
not any(c in blob for c in ("52e", "525", "532", "533", "775", "data ")), body)
|
||||||
|
from server import ldap_auth as _la
|
||||||
|
chk("...though _err49 does parse one when AD sends it",
|
||||||
|
"52e" in _la._err49({"message": "80090308: LdapErr: DSID-0C0903A9, comment: "
|
||||||
|
"AcceptSecurityContext error, data 52e, v4563"}))
|
||||||
|
finally:
|
||||||
|
eng1.dispose()
|
||||||
|
server4.kill()
|
||||||
|
try:
|
||||||
|
server4.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\n" + "-" * 54)
|
||||||
|
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
return 1 if _FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"could not run: {type(exc).__name__}: {exc}")
|
||||||
|
sys.exit(2)
|
||||||
180
tests/mobile_check.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Does the whole suite hold together at 390px? — C2, T9.6.
|
||||||
|
|
||||||
|
Nothing in the original proposal touched mobile, and it is where the worst
|
||||||
|
rendering was found. This drives all seven pages at 390px (mobile emulation,
|
||||||
|
so the media queries under test actually fire) and asserts:
|
||||||
|
|
||||||
|
- no page scrolls sideways
|
||||||
|
- no visible control is clipped past the viewport or collapsed to nothing
|
||||||
|
- every control meets the 24px WCAG floor; on the gloved-hands surfaces
|
||||||
|
(Field View, and the creator's rail / status / save controls) the bar is
|
||||||
|
44px, which is what the shared coarse-pointer sizing in wp-chrome.css
|
||||||
|
delivers
|
||||||
|
|
||||||
|
The after-screenshots live in docs/reference/baseline/after-wave9 (captured by
|
||||||
|
baseline_shots.py) beside the wave 0 set.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
PAGES = [
|
||||||
|
("login", "/login.html"),
|
||||||
|
("launcher", "/index.html"),
|
||||||
|
("wizard", "/work-package-suite.html?tab=sop&project=projA"),
|
||||||
|
("creator", "/wp-creation-index.html?project=projA"),
|
||||||
|
("field", "/field.html?project=projA"),
|
||||||
|
("admin", "/admin.html"),
|
||||||
|
("users", "/users.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEASURE = """(function(){
|
||||||
|
var out = {sw: document.documentElement.scrollWidth, total:0, under24:[],
|
||||||
|
clipped:[]};
|
||||||
|
function skip(el){
|
||||||
|
// An off-canvas drawer is PARKED outside the viewport by design, and a row
|
||||||
|
// inside an overflow-x container is scrollable, not clipped - the same two
|
||||||
|
// lessons frame_check's widest-box scan learned the hard way.
|
||||||
|
for (var n = el; n; n = n.parentElement){
|
||||||
|
var cs = getComputedStyle(n);
|
||||||
|
if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') return true;
|
||||||
|
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
|
||||||
|
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var els = document.querySelectorAll('button, a[href], input, select, textarea, [role=button]');
|
||||||
|
for (var i=0;i<els.length;i++){
|
||||||
|
var el=els[i]; var r=el.getBoundingClientRect();
|
||||||
|
if (r.width===0 || r.height===0 || el.disabled || el.type==='hidden') continue;
|
||||||
|
if (skip(el)) continue;
|
||||||
|
out.total++;
|
||||||
|
var inlineText = el.tagName==='A' && getComputedStyle(el).display==='inline';
|
||||||
|
var m=Math.min(r.width,r.height);
|
||||||
|
var id=el.tagName+'.'+String(el.className).slice(0,24)+' '+Math.round(r.width)+'x'+Math.round(r.height);
|
||||||
|
if (m < 24 && !inlineText && out.under24.length < 6) out.under24.push(id);
|
||||||
|
if ((r.left < -2 || r.right > 392) && out.clipped.length < 6) out.clipped.push(id);
|
||||||
|
}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
FIELD44 = """(function(){
|
||||||
|
var out = {total:0, under:[]};
|
||||||
|
function skip(el){
|
||||||
|
for (var n = el; n; n = n.parentElement){
|
||||||
|
var cs = getComputedStyle(n);
|
||||||
|
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
|
||||||
|
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var els = document.querySelectorAll(
|
||||||
|
'button, a[href], input:not([type=checkbox]):not([type=radio]), select, textarea');
|
||||||
|
for (var i=0;i<els.length;i++){
|
||||||
|
var el=els[i]; var r=el.getBoundingClientRect();
|
||||||
|
if (r.width===0 || r.height===0 || el.disabled) continue;
|
||||||
|
if (skip(el)) continue;
|
||||||
|
out.total++;
|
||||||
|
if (Math.min(r.width, r.height) < 44 && out.under.length < 6)
|
||||||
|
out.under.push(el.tagName+'.'+String(el.className).slice(0,24)+' '
|
||||||
|
+Math.round(r.width)+'x'+Math.round(r.height));
|
||||||
|
}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_(v, n=280):
|
||||||
|
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
exe = cdp.find_browser()
|
||||||
|
if not exe:
|
||||||
|
print("no headless-capable browser found; set WP_BROWSER.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
shots = os.path.join(ROOT, "docs", "reference", "baseline", "after-wave9")
|
||||||
|
chk("the after-screenshots are committed beside the wave 0 baseline",
|
||||||
|
os.path.isdir(shots) and len([f for f in os.listdir(shots)
|
||||||
|
if f.endswith("-390.png")]) >= 7,
|
||||||
|
shots)
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mobile-")
|
||||||
|
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)
|
||||||
|
|
||||||
|
for name, path in PAGES:
|
||||||
|
print("\n%s" % name)
|
||||||
|
page.goto(base + path)
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
time.sleep(2.4)
|
||||||
|
m = json.loads(page.eval(MEASURE))
|
||||||
|
chk("%s: no sideways scrolling" % name, m["sw"] <= 392, m["sw"])
|
||||||
|
chk("%s: no control clipped past the viewport" % name,
|
||||||
|
not m["clipped"], ascii_(m["clipped"]))
|
||||||
|
chk("%s: every control meets the 24px floor" % name,
|
||||||
|
not m["under24"], ascii_(m["under24"]))
|
||||||
|
|
||||||
|
# the gloved-hands bar: Field View, everything 44px
|
||||||
|
print("\nfield view, the 44px bar")
|
||||||
|
page.goto(base + "/field.html?project=projA")
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
time.sleep(2.4)
|
||||||
|
f = json.loads(page.eval(FIELD44))
|
||||||
|
chk("field view: every control is a 44px touch target",
|
||||||
|
f["total"] > 0 and not f["under"], ascii_(f))
|
||||||
|
|
||||||
|
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||||
|
chk("no JavaScript errors across the whole sweep", 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())
|
||||||
@@ -121,13 +121,14 @@ def main():
|
|||||||
len((wp.get("data") or {}).get("materialRequests") or []) == 1)
|
len((wp.get("data") or {}).get("materialRequests") or []) == 1)
|
||||||
chk("...and the warehouse owner is notified through the T7.6 gate",
|
chk("...and the warehouse owner is notified through the T7.6 gate",
|
||||||
wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12))
|
wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12))
|
||||||
body = next((m for m in sink.messages if "Material request" in m["data"]), {"data": "", "to": [""]})
|
body = next((m for m in sink.messages if "Material request" in m["data"]),
|
||||||
|
{"data": "", "text": "", "to": [""]})
|
||||||
chk("...the mail goes to the owner, says the size, the date, the delivery "
|
chk("...the mail goes to the owner, says the size, the date, the delivery "
|
||||||
"and carries the deep link",
|
"and carries the deep link",
|
||||||
body["to"] == ["sue@example.test"] and "2 lines" in body["data"]
|
body["to"] == ["sue@example.test"] and "2 lines" in body["text"]
|
||||||
and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"]
|
and "2026-09-01" in body["text"] and "Shark cage 7" in body["text"]
|
||||||
and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"],
|
and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["text"],
|
||||||
ascii_(body["data"], 300))
|
ascii_(body["text"], 300))
|
||||||
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root)
|
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root)
|
||||||
chk("...and the audit history has it", bool(ev))
|
chk("...and the audit history has it", bool(ev))
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40
|
|||||||
from stepper_check import dismiss_dialogs # noqa: E402
|
from stepper_check import dismiss_dialogs # noqa: E402
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body
|
CANARY = "FAB-9 SECRET SECTOR" # location: customer CONTEXT - IN bodies since 2026-08-20
|
||||||
|
DESC_CANARY = "PULL-SCHED-CANARY-7X" # document CONTENT - must never reach a body
|
||||||
|
|
||||||
|
|
||||||
def ascii_(v, n=300):
|
def ascii_(v, n=300):
|
||||||
@@ -87,7 +88,7 @@ class SmtpSink(threading.Thread):
|
|||||||
self.sock.bind(("127.0.0.1", 0))
|
self.sock.bind(("127.0.0.1", 0))
|
||||||
self.sock.listen(8)
|
self.sock.listen(8)
|
||||||
self.port = self.sock.getsockname()[1]
|
self.port = self.sock.getsockname()[1]
|
||||||
self.messages = [] # {"to": [...], "data": str}
|
self.messages = [] # {"to": [...], "data": wire str, "text": decoded body}
|
||||||
self._stop = False
|
self._stop = False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@@ -127,8 +128,22 @@ class SmtpSink(threading.Thread):
|
|||||||
return
|
return
|
||||||
if in_data:
|
if in_data:
|
||||||
if line.rstrip(b"\r\n") == b".":
|
if line.rstrip(b"\r\n") == b".":
|
||||||
|
raw = b"".join(buf)
|
||||||
|
# "data" is the wire payload (headers + body, transfer-encoded).
|
||||||
|
# "text" is the DECODED body: any non-ASCII character (the
|
||||||
|
# bodies' em-dash) switches smtplib to quoted-printable, whose
|
||||||
|
# soft line breaks split words at column 76 - a substring pin
|
||||||
|
# against "data" then fails on luck of line position. Content
|
||||||
|
# pins read "text"; header pins (Subject:) still read "data".
|
||||||
|
import email as _email
|
||||||
|
try:
|
||||||
|
_msg = _email.message_from_bytes(raw)
|
||||||
|
_text = _msg.get_payload(decode=True).decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
_text = raw.decode("utf-8", "replace")
|
||||||
self.messages.append({"to": list(rcpt),
|
self.messages.append({"to": list(rcpt),
|
||||||
"data": b"".join(buf).decode("utf-8", "replace")})
|
"data": raw.decode("utf-8", "replace"),
|
||||||
|
"text": _text})
|
||||||
rcpt, in_data, buf = [], False, []
|
rcpt, in_data, buf = [], False, []
|
||||||
conn.sendall(b"250 OK\r\n")
|
conn.sendall(b"250 OK\r\n")
|
||||||
else:
|
else:
|
||||||
@@ -177,7 +192,7 @@ def set_qa_group(ids):
|
|||||||
|
|
||||||
def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
|
def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
|
||||||
data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}],
|
data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}],
|
||||||
"location": CANARY, "desc": "600ft of 3/4 EMT through " + CANARY}
|
"location": CANARY, "desc": "600ft of 3/4 EMT through " + DESC_CANARY}
|
||||||
data.update(extra_data or {})
|
data.update(extra_data or {})
|
||||||
return api(base, "/api/wps", tok, "POST", {
|
return api(base, "/api/wps", tok, "POST", {
|
||||||
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
|
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
|
||||||
@@ -263,12 +278,16 @@ def main():
|
|||||||
rcpts = sorted(m["to"][0] for m in qa_msgs())
|
rcpts = sorted(m["to"][0] for m in qa_msgs())
|
||||||
chk("...addressed to the group members and NOBODY else",
|
chk("...addressed to the group members and NOBODY else",
|
||||||
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
||||||
body = qa_msgs()[0]["data"] if qa_msgs() else ""
|
body = qa_msgs()[0]["text"] if qa_msgs() else ""
|
||||||
chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200))
|
chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200))
|
||||||
chk("...and a link that opens THAT work package, not the app root",
|
chk("...and a link that opens THAT work package, not the app root",
|
||||||
"/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
|
"/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
|
||||||
chk("...and no customer IP: the location canary does not appear",
|
# Decided 2026-08-20: context IN, content OUT. This pin asserted the
|
||||||
CANARY not in body and all(CANARY not in m["data"] for m in sink.messages))
|
# location's ABSENCE until that decision; it flipped with the rule.
|
||||||
|
chk("...and the location and title ride in the body (context, allowed)",
|
||||||
|
CANARY in body and "conduit" in body, ascii_(body, 300))
|
||||||
|
chk("...but document content never does: the desc canary appears nowhere",
|
||||||
|
all(DESC_CANARY not in m["text"] + m["data"] for m in sink.messages))
|
||||||
chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
|
chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
|
||||||
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
|
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
|
||||||
|
|
||||||
@@ -290,7 +309,7 @@ def main():
|
|||||||
chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
|
chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
|
||||||
"sue@example.test"], ascii_(rcpts))
|
"sue@example.test"], ascii_(rcpts))
|
||||||
chk("...and the comment itself stays on the package, out of the mail",
|
chk("...and the comment itself stays on the package, out of the mail",
|
||||||
all("Torque strap" not in m["data"] for m in sink.messages))
|
all("Torque strap" not in m["text"] + m["data"] for m in sink.messages))
|
||||||
|
|
||||||
# The upsert path enforces the same comment rule (it is how the browser saves).
|
# The upsert path enforces the same comment rule (it is how the browser saves).
|
||||||
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
|
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
|
||||||
|
|||||||
@@ -329,8 +329,15 @@ def run(page, base, tok, db_path):
|
|||||||
return JSON.stringify({assets: out.assets, materials: out.materials,
|
return JSON.stringify({assets: out.assets, materials: out.materials,
|
||||||
kitStatus: out.kitStatus, subject: out.subject});
|
kitStatus: out.kitStatus, subject: out.subject});
|
||||||
})()""" % json.dumps(FULL_PKG)))
|
})()""" % json.dumps(FULL_PKG)))
|
||||||
|
# Content, not byte-equality: since D11 (Aug 20) every asset row loaded into
|
||||||
|
# the form is normalised - a row the Micron catalog does not vouch for gains
|
||||||
|
# source:'manual' on its way through. That stamp is the feature working, not
|
||||||
|
# the section toggle leaking; what CR-016 requires to survive is the DATA.
|
||||||
chk("a package edited while Assets is off keeps its assets on save",
|
chk("a package edited while Assets is off keeps its assets on save",
|
||||||
collected["assets"] == FULL_PKG["assets"], collected["assets"])
|
[{"tag": a.get("tag"), "desc": a.get("desc")} for a in collected["assets"]]
|
||||||
|
== [{"tag": a["tag"], "desc": a["desc"]} for a in FULL_PKG["assets"]]
|
||||||
|
and all(a.get("source") in ("manual", "catalog") for a in collected["assets"]),
|
||||||
|
collected["assets"])
|
||||||
# Compared on the content, not the whole row: the creator upper-cases a
|
# Compared on the content, not the whole row: the creator upper-cases a
|
||||||
# material unit on its way through the form ("ea" -> "EA"), which is its own
|
# material unit on its way through the form ("ea" -> "EA"), which is its own
|
||||||
# long-standing behaviour and nothing to do with section toggles. Asserting
|
# long-standing behaviour and nothing to do with section toggles. Asserting
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ def source_counts():
|
|||||||
|
|
||||||
|
|
||||||
def run(page, base, tok):
|
def run(page, base, tok):
|
||||||
def open_wizard(query="?project=projA"):
|
# BL-018's fixture fix (T9.9) gave projA a PRODUCTION-shape completed SOP,
|
||||||
|
# so the wizard on projA now legitimately restores a finished configuration.
|
||||||
|
# This file's premise is a wizard someone is STARTING - projB has no SOP,
|
||||||
|
# which is that premise, honestly.
|
||||||
|
def open_wizard(query="?project=projB"):
|
||||||
# Leave the outgoing page clean first: the unsaved-work guard from T4.3 is
|
# Leave the outgoing page clean first: the unsaved-work guard from T4.3 is
|
||||||
# doing its job, and a "Leave site?" prompt would stall the navigation.
|
# doing its job, and a "Leave site?" prompt would stall the navigation.
|
||||||
try:
|
try:
|
||||||
@@ -232,9 +236,13 @@ def run(page, base, tok):
|
|||||||
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
||||||
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
||||||
divs, spans = source_counts()
|
divs, spans = source_counts()
|
||||||
chk("app-wide div-with-onclick is %d, down 10 from %d"
|
# Re-pointed at T9.5: the C1 audit drove the app-wide count to ZERO (the
|
||||||
% (divs, BASELINE_DIV_ONCLICK), divs == BASELINE_DIV_ONCLICK - 10,
|
# last two - the wizard's constraint-library entries and the dashboard
|
||||||
"counted %d" % divs)
|
# chips - became buttons). "Exactly baseline-10" was right while wave 9 was
|
||||||
|
# future; asserting <= that now would let regressions hide under the slack,
|
||||||
|
# so the pin is the final number.
|
||||||
|
chk("app-wide div-with-onclick is %d - the C1 target, reached at T9.5"
|
||||||
|
% divs, divs == 0, "counted %d" % divs)
|
||||||
chk("...and none of the survivors is in the wizard's rail",
|
chk("...and none of the survivors is in the wizard's rail",
|
||||||
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
||||||
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
||||||
@@ -248,7 +256,7 @@ def run(page, base, tok):
|
|||||||
chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur)
|
chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur)
|
||||||
chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur)
|
chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur)
|
||||||
|
|
||||||
# projA's fixture project has no division or site, so step 1 is incomplete on
|
# projB has no SOP at all, so step 1 is incomplete on
|
||||||
# a fresh load and everything ahead of it is genuinely out of reach.
|
# a fresh load and everything ahead of it is genuinely out of reach.
|
||||||
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
||||||
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
||||||
@@ -406,14 +414,14 @@ def run(page, base, tok):
|
|||||||
settle(page, 1.1)
|
settle(page, 1.1)
|
||||||
chk("...and Back returns to the previous step", page.eval("currentStep") == 2,
|
chk("...and Back returns to the previous step", page.eval("currentStep") == 2,
|
||||||
page.eval("currentStep"))
|
page.eval("currentStep"))
|
||||||
# Back to a URL with NO step at all does not return to step 1 — the popstate
|
# BL-016, FIXED at T9.9: a step-less wizard URL is step 1. This check pinned
|
||||||
# handler parses `step` and ignores a NaN. That is T4.2's restore rather than
|
# the WRONG behaviour until the fix landed, and flipped with it - which was
|
||||||
# the rail's, it predates this task, and it is logged as BL-016.
|
# the plan recorded on the entry.
|
||||||
chk("...and the known step-1 gap is still exactly that, and no wider",
|
chk("...and Back continues to work",
|
||||||
page.eval("(() => { history.back(); return true; })()") is True)
|
page.eval("(() => { history.back(); return true; })()") is True)
|
||||||
settle(page, 1.1)
|
settle(page, 1.1)
|
||||||
chk("...(BL-016) Back to a step-less URL leaves the step where it was",
|
chk("...(BL-016) Back to a step-less URL returns to step 1",
|
||||||
page.eval("currentStep") == 2 and "step=" not in page.eval("location.search"),
|
page.eval("currentStep") == 1 and "step=" not in page.eval("location.search"),
|
||||||
[page.eval("currentStep"), page.eval("location.search")])
|
[page.eval("currentStep"), page.eval("location.search")])
|
||||||
|
|
||||||
# ── narrow width: the gloved-hands surface ────────────────────────────────
|
# ── narrow width: the gloved-hands surface ────────────────────────────────
|
||||||
@@ -480,7 +488,10 @@ def run(page, base, tok):
|
|||||||
chk("the page does not scroll sideways at 1440px",
|
chk("the page does not scroll sideways at 1440px",
|
||||||
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
||||||
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
||||||
chk("the wizard still boots without a JavaScript error", not page.js_errors(),
|
# projB has no SOP, so /api/sops/latest answering 404 is the CORRECT answer
|
||||||
|
# being logged by the browser, not an error in the page.
|
||||||
|
chk("the wizard still boots without a JavaScript error",
|
||||||
|
not [e for e in page.js_errors() if "sops/latest" not in e],
|
||||||
page.js_errors())
|
page.js_errors())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,13 @@ def main():
|
|||||||
break
|
break
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
settle(page, 1.2)
|
settle(page, 1.2)
|
||||||
|
# NOT `"wp-creation-index.html" in location.href` — that string is in the
|
||||||
|
# ?next= parameter too, so the check passed while still sitting on
|
||||||
|
# login.html with the sign-in rejected. Assert we actually LEFT the
|
||||||
|
# login page (D13/T10.7: it caught nothing when the bind started failing).
|
||||||
|
href = page.eval("location.href")
|
||||||
chk("signing in continues to the requested page, not the home page",
|
chk("signing in continues to the requested page, not the home page",
|
||||||
"wp-creation-index.html" in page.eval("location.href"),
|
"login.html" not in href and "wp-creation-index.html" in href, href)
|
||||||
page.eval("location.href"))
|
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
if page.eval("!!window.wpCreatorReady"):
|
if page.eval("!!window.wpCreatorReady"):
|
||||||
break
|
break
|
||||||
|
|||||||