Compare commits

..

1 Commits

Author SHA1 Message Date
7ef1fcdd96 Add Micron asset picker to work package creator
Adds an optional read-only Micron asset catalog lookup for the WP creator, with searchable asset IDs, CSV import, and graceful fallback to manual asset entry when the catalog is absent or unreachable. This includes the backend /api/assets endpoint, SQL Server connector configuration, Docker network changes for outbound access, and UI updates/documentation to make the catalog read-only and clearly distinguish Micron-vetted assets from manual entries.
2026-08-18 14:56:55 -05:00
31 changed files with 1332 additions and 2814 deletions

View File

@@ -195,18 +195,12 @@ good deploy look broken.
Sanity checks — all four should take under a minute:
1. Log in. The home page offers to select or create a project.
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
home page). The table should read as **one line per user** — if rows are three lines
tall and the table spills outside its white card, you are still on the old cached
2. Open **Admin Console** (the link is on the home page; you need an admin account).
The user table should read as **one line per user** — if rows are three lines tall
and the table spills outside its white card, you are still on the old cached
files: hard-reload again.
> Changed since this runbook was written: user accounts moved out of the Admin
> Console into `users.html` when the **Project Super User** role was added, so that
> a project admin can create accounts on their own job. If you are deploying a build
> from before that change, read this step as "Admin Console → the user table".
3. Open **Admin Console** (admin account required). Two new cards are present and
load: **Projects**, and **Default members on new projects**. Both should list rows,
not an error.
3. Two new cards are present and load: **Projects**, and **Default members on new
projects**. Both should list rows, not an error.
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived**
it should reappear marked `archived`. Click **Unarchive** to put it back. That

View File

@@ -146,64 +146,20 @@ docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from proje
### Automated smoke test
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in
project → SOP → Work Package → the AWP issue gate → status → metrics → comments →
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
It **signs in first**, because every `/api/` route except `/api/health` requires a
session. Credentials come from the environment so a password stays out of shell
history, and the account must be an **admin**: the run creates a project and deletes
it again, and archiving or deleting one takes Project Admin on it. The script checks
the signed-in role up front and warns if it is too low rather than letting you find
out in the cleanup step.
`server/smoketest.py` exercises the whole stack end-to-end (health → project
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
cleanup). Stdlib only — no pip/jq.
```bash
export WP_SMOKE_USER=<admin-account>
export WP_SMOKE_PASSWORD='…'
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000
# Or from inside the api container (hits FastAPI directly):
docker compose exec api python /app/server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI.
# --user / --password override the environment if you'd rather be explicit.
```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
could not start (host unreachable, or credentials missing or rejected). The last is
kept separate on purpose — "I could not test this" is a different answer from "this is
broken", and automation should not treat them alike.
### Front-end browser check
`tests/browser_check.py` is the other half: the smoke test proves the API works, this
proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools
Protocol and asserts what only a browser can settle — that each page boots without a
JavaScript error, that the role-dependent renderings are right, and that the layout
rules the console pages depend on are actually in effect.
Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects,
an admin, a Project Super User, a plain member, and accounts positioned to exercise
in-scope / out-of-scope / invisible), starts its own server on a free port, and tears
all of it down. **Your real database is never touched.** Stdlib only.
```bash
python tests/browser_check.py # everything, ~71 checks
python tests/browser_check.py --keep-server # leave it up to poke at by hand
WP_BROWSER=/path/to/chrome python tests/browser_check.py
```
Same exit codes as the smoke test, including **2** for "no browser found" — a missing
browser is not a failing app.
Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css`
or `html/admin.js`. It is the check that would have caught a rule lost while
`console.css` was being extracted out of `admin.html`, which is a silent, whole-page
regression that no server-side test can see.
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
working. It cleans up after itself (the test project and its SOP/WPs are
deleted via cascade); a single tagged test comment remains (there's no comment
@@ -376,45 +332,18 @@ console's **Reset password** button).
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set on the **User Directory** page (`users.html`) — not the Admin Console,
which no longer manages accounts.
Both are set in the Admin console's user table.
| Role | May do |
|---|---|
| `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
| `admin` | User administration, app settings, and every project |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before roles existed
only hides controls to avoid dead-end clicks. Accounts created before this change
carried the role `user`, which the migration rewrites to `project_user`.
### Project Super User — what bounds it
The role exists so a project admin can staff their own job without an app admin.
Its limits are what make it safe to hand out, and all of them are server-side
(`managed_project_ids`, `manage_user_problem`, `grantable_roles` in `server/app.py`):
* **Scope comes from projects, not the job title.** A super user administers the users
of the projects they hold the role on — via their account role, or via
`ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
changing permissions or deleting are global acts, so they are refused when the
target is also on a project the caller does not administer. The directory shows
those rows read-only with the reason. An app admin has to make the change.
* **No admin or super-user targets, and none granted.** A super user may hand out
`project_admin` / `project_user` only, and may not touch an admin's or another
super user's account — so the role cannot become a route to app-wide control.
* **Saving project access never reaches outside scope.** `PUT
/api/auth/users/{id}/projects` rebuilds only the caller's own slice; memberships on
projects they don't administer are left untouched.
* **App settings, feature flags and the default-member rule stay admin-only.**
No migration is needed for the new role — `users.role` is already `String(20)` and
`project_super_user` fits. Grant it from the User Directory (Permissions column), or
per project from **Project access → Project Super User here**.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator

View File

@@ -62,14 +62,6 @@ the token cannot be read, but the injected code does not need it: it runs in the
victim's page and can call any API the victim can, including
`POST /api/auth/users/{id}/role`.
**The `project_super_user` role (added 2026-08-05) widens the set of victims whose
session is worth stealing, without raising the ceiling.** Previously only an app
admin's session could create accounts or change permissions; now a super user's can
too, within the projects they administer. The ceiling is unchanged — it was already
`admin` — but the odds of landing on a session that can mint an account go up, and a
super user is likelier than an admin to be reading a WP creator on a live job. It is
one more reason the accidental-breakage case is not the only one that matters.
Two controls that look like they would contain this do not:
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
@@ -91,12 +83,6 @@ malicious one.
suite is exposed outside the corporate network, accounts are issued to
subcontractors or clients, or self-registration is added.
Note that the second of those got easier to reach without anyone deciding to: a
Project Super User can now issue accounts on their own job without an app admin
involved, so "accounts are issued to subcontractors" can become true by ordinary
delegated use rather than by a policy change. Worth checking the directory
occasionally against who is actually on staff.
### What closing it takes
Small — roughly half an hour. The helper already exists; it was added to the SOP
@@ -116,9 +102,8 @@ function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
clicks and the name must display intact.
The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved
out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) —
same ordering, same reasoning, worth reading before starting.
The equivalent fix on the admin side is `jsq()` in `html/admin.js` — same ordering,
same reasoning, worth reading before starting.
---

View File

@@ -35,12 +35,22 @@ services:
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
# Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and
# the picker cleanly falls back to manual entry (see server/assets_db.py).
# Use a db_datareader login: the app only ever SELECTs.
MICRON_DB_URL: ${MICRON_DB_URL:-}
restart: unless-stopped
depends_on:
db:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
# Reaching the Micron database means leaving this compose project, and
# `internal` is deliberately egress-free. `outbound` is attached to the api
# container ONLY — the database and backup containers stay sealed. Detach it
# again if you are not using the Micron asset picker.
- outbound
db:
image: postgres:16-alpine
@@ -98,4 +108,12 @@ networks:
name: proxy
external: true
internal:
internal: true # no outbound internet access from api/db
internal: true # no route off the host for anything on this network alone
outbound:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
# above removes the gateway entirely, which blocks not just the internet but
# the LAN and the VPN too — so the api container needs this second network to
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
# remain on `internal` only and still have no way off the host.
# Detach it from api if you are not using the Micron asset picker.
driver: bridge

View File

@@ -13,45 +13,198 @@
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="console.css">
<link rel="stylesheet" href="wp-sidenav.css">
<style>
/* Page-specific only — the tokens, cards, controls, tables, banners and modal
live in console.css, shared with the User Directory. What stays here is what
only this page has: the per-card scroll boxes admin.js paints tables into, the
column exceptions for those tables, and the admins-only notice.
/* ══ TOKENS ══════════════════════════════════════════════════════════════
The console is the only page in the suite that is mostly dense tables, so
it carries its own sheet. The palette, the square corners and the type are
Carbon's — the same ones theme-light.css sets — so it still reads as one
product with the rest of the suite. Two scales do all the spacing and all
the control sizing; nothing in here should invent its own. */
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
--green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
--ctl:32px; /* every button / input / select that sits in a form row */
--ctl-sm:26px; } /* every control that sits inside a table cell */
*{ box-sizing:border-box; }
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
These are addressed by ID because admin.js emits the tables without per-cell
classes. */
/* ══ PAGE ════════════════════════════════════════════════════════════════
1240px, not 860: the user table is nine columns wide and at 860 it spilled
straight out of its own white card. Wide enough for that table, still a
readable measure for the prose, which is capped separately. */
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
a.home:hover{ text-decoration:underline; }
/* These start life as empty divs that admin.js fills on demand, so they only earn
their gap once they are actually saying something. */
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
/* ══ CARDS ═══════════════════════════════════════════════════════════════ */
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
/* One card header everywhere: small uppercase accent label on a hairline.
admin.js also emits h2 for sub-sections inside a card (Localization
defaults, Step views, Actions) with an inline margin-top — the same
treatment reads correctly as a divider there, so both get it. */
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
/* Every container admin.js paints a table into is a scrollport of its own, so a
sticky header always has something to stick to rather than sliding up behind
the app bar. Same rule as console.css's .tscroll. */
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
/* ══ CONTROLS ════════════════════════════════════════════════════════════
Every button, input and select in a form row is exactly --ctl tall, so a
toolbar is one clean band instead of a ragged one. */
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* The filter / search / button strip at the top of a card. */
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
.toolbar + .banner{ margin-top:0; }
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* Checkboxes are excluded: they are drawn by the platform and want none of a
text field's height, padding or border. */
.toolbar input:not([type=checkbox]), .toolbar select,
.urow input:not([type=checkbox]), .urow select{
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
.toolbar input:focus-visible, .toolbar select:focus-visible,
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
.toolbar > input{ flex:1 1 240px; min-width:150px; }
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
/* ══ FEEDBACK: banners, notes, console output, key/value ═════════════════ */
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
background:var(--surface); color:var(--text); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
/* These three start life as empty divs that admin.js fills on demand, so they
only earn their gap once they are actually saying something. */
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
and #features-box / #settings-box are themselves .note — their primary toggle
labels inherit this colour. */
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
.note strong, .note em{ color:var(--text); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
table.users is the name admin.js already emits; table.grid is the same
object under the shared name. One rule set serves both, so existing markup
picks up the dense styling without being rewritten. border-collapse is
separate rather than collapse because a collapsed border does not travel
with a sticky header. */
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
font-size:13px; color:var(--text); background:var(--surface); }
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
box-shadow:inset 0 -1px 0 var(--border); }
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
the table stops reading as rows. Anything genuinely long truncates (.ell)
or is exempted by name further down. */
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
vertical-align:middle; white-space:nowrap; }
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
projects" pill sitting on its own colour disappears the moment you hover it. */
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
never appears, which is the usual reason this trick looks like it works in the
stylesheet and doesn't on the page. admin.js emits <td class="ell"><span>. */
.ell{ max-width:240px; }
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
/* Every action cell admin.js renders is a .cellactions, and it must not wrap:
unwrapped, the three buttons stack and the row grows fourfold — which is what
the console looked like before this pass. */
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
/* Controls that live in a cell are one step smaller, which is what keeps a
row at ~34px instead of ~100px. .chk is form-row sized by default, so it needs
saying again here or the default-members rows stand 6px taller than the rest. */
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
background:#fff; color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
line-height:1.55; white-space:nowrap; vertical-align:middle; }
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
.tag.user{ background:#e8e8e8; color:var(--muted); }
.tag.on{ background:var(--green-bg); color:var(--green); }
.tag.off{ background:var(--red-bg); color:var(--red); }
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
/* A wide table scrolls inside its own box so the page never scrolls sideways,
and the capped height is what gives the sticky header something to do. Every
container admin.js paints a table into is one, so a sticky header always has
a scrollport of its own rather than sliding up behind the app bar. */
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
one table never ends up with two scrollbars. */
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
so one table never ends up with two scrollbars. */
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
#defmem-table:has(.tscroll){
overflow:visible; max-height:none; }
/* Comment text and audit detail are the two columns you are actually here to
read, so they wrap inside a sane width instead of truncating. */
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
/* Column exceptions, addressed by card because admin.js emits these tables
without per-cell classes. Email is the one user cell long enough to stretch
a row, so it truncates; comment text and audit detail are the two columns
you are actually here to read, so they wrap inside a sane width instead. */
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
/* ══ GATES & WARNINGS ════════════════════════════════════════════════════ */
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
#admin-denied .card{ max-width:560px; }
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px;
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
/* ══ NARROW SCREENS ══════════════════════════════════════════════════════
The page itself must never scroll sideways; the wide tables scroll inside
their own box instead, and there they get the full page height to do it. */
@media (max-width:900px){
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
max-height:none; }
.wrap{ padding:var(--s4) var(--s3) 60px; }
.card{ padding:var(--s3) var(--s4) var(--s4); }
.toolbar > input{ flex:1 1 100%; }
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{ max-height:none; }
}
@media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; }
}
</style>
</head>
@@ -88,14 +241,29 @@
<div class="banner" id="health-banner"></div>
</div>
<!-- USER ADMINISTRATION — moved out to its own page -->
<!-- USER ADMINISTRATION -->
<div class="card">
<h2>User accounts</h2>
<div class="sub">Login accounts, permissions and project access now live on the
<strong>User Directory</strong> page. They moved because user administration is no longer
admin-only: a <strong>Project Super User</strong> creates and manages the accounts on the
projects they administer, and they must never be sent through this console to do it.</div>
<div class="toolbar"><a class="home" href="users.html"><button class="primary">Open the User Directory →</button></a></div>
<h2>User administration</h2>
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
<div id="users-banner"></div>
<div id="users-table"></div>
<h2 style="margin-top:var(--s6)">Add a user</h2>
<div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do">
<option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</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">
<button class="primary" onclick="createUser()">Create user</button>
</div>
<div id="users-create-msg" class="note"></div>
</div>
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
@@ -118,10 +286,8 @@
<h2>Default members on new projects</h2>
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
with the role chosen here. It does not touch projects that already exist — for those, use
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
Administrators are listed with nothing to set: they already reach every project. This card stays
in the console because it is a rule about <em>every</em> future project, including the ones a
Project Super User has no part in — so only an admin sets it.</div>
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
to set: they already reach every project.</div>
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
<div id="defmem-banner"></div>
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
@@ -211,9 +377,7 @@
</div>
</div>
<script src="console-util.js"></script>
<script src="admin.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -4,20 +4,14 @@
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
already requires a login (redirecting to login.html otherwise) and publishes
window.WP_USER; here we show the console only when that user is an admin, and
show an "Admins only" notice otherwise. Every API this page calls is also
enforced as admin-only server-side, so this is a real gate, not obfuscation.
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
User role arrived: administering users is no longer an admin-only act, so the
page that does it can't be behind an admins-only gate. What stays here is what
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
project archiving, and the default-member rule for future projects.
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
show an "Admins only" notice otherwise. Every user-management API is also
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
function reveal(){
document.getElementById('admin-main').style.display='';
fillProjectRoleOptions();
checkHealth();
loadUsers();
loadProjects();
loadDefaultMembers();
loadSettings();
@@ -30,6 +24,18 @@ function showDenied(){
document.getElementById('admin-denied').style.display='';
}
// ── api helper ──────────────────────────────────────────────────────────────
async function api(method, path, body){
const opt = { method, headers:{ 'Accept':'application/json' } };
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
try {
const r = await fetch(path, opt);
const t = await r.text();
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
return { status:r.status, json };
} catch(e){ return { status:0, json:String(e) }; }
}
// ── connectivity ──────────────────────────────────────────────────────────────
async function checkHealth(){
const b = document.getElementById('health-banner');
@@ -159,6 +165,317 @@ async function cleanDemo(){
snapshot();
}
// ── user administration ────────────────────────────────────────────────────────
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
// for the JS string literal it lands in, and again for the HTML attribute carrying
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
// survives, and the browser decodes the entities before the JS parser runs.
//
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
// value containing a backslash: the \ we add is itself escaped by the stored one,
// the quote closes the literal, and everything after it runs as code. Project names
// and full names are free text that any signed-in user can write, so that is a real
// path from a project_user to whatever an admin's session can do. Use jsq() for
// EVERY value that lands inside an inline handler.
function jsq(v){
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
}
async function currentUserId(){
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
const { status, json } = await api('GET','/api/auth/me');
return (status===200 && json && json.user) ? json.user.id : null;
}
async function loadUsers(){
const banner=document.getElementById('users-banner');
const wrap=document.getElementById('users-table');
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/auth/users');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant manage users. Ask an admin, or use the CLI: python -m server.manage_users';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
const meId = await currentUserId();
renderUsers(json, meId);
// Fill in the project-access counts, then repaint that column.
await loadProjectCounts(json);
renderUsers(json, meId);
}
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
const PERM_ROLES = ['admin','project_admin','project_user'];
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function fillProjectRoleOptions(){
const sel=document.getElementById('nu-project-role'); if(!sel) return;
sel.innerHTML='<option value="">Project role…</option>'+
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
}
// Per-user project access gets its own column: it was buried among the action
// buttons, which is exactly where you'd fail to find "which projects can this
// person see, and what may they do there".
let _userProjectCounts = {}; // user id -> number of assigned projects
function projAccessCell(u){
const uname = jsq(u.username);
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
const n = _userProjectCounts[u.id];
const label = (n === undefined) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
return '<button class="mini' + (n === 0 ? ' danger' : '') +
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
' title="Choose which projects this user can access, and their role on each">' +
label + '</button>';
}
// A project's role dropdown only matters while that project is ticked.
function projRowToggled(cb){
const row = cb.closest('div');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
// Counts for that column. One call per user, but only for non-admins and only on a
// refresh — the admin console is not a hot path.
async function loadProjectCounts(list){
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
await Promise.all(targets.map(async u => {
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
}));
}
function renderUsers(list, meId){
const wrap=document.getElementById('users-table');
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
let rows = list.map(u=>{
const me = u.id===meId;
const active = u.is_active;
const disableBtn = me
? '<button class="mini" disabled title="You cant disable yourself">—</button>'
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
const delBtn = me
? ''
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">Delete</button>';
// Role can be changed at any time via an inline dropdown. Your own row is
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
const escUname = jsq(u.username);
const escUid = jsq(u.id);
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
PERM_ROLES.map(function(r){
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
'</select>';
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
}).join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
return '<tr>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+
// The address is truncated with the full value on the title: a long one used
// to wrap mid-word and push the whole row onto three lines.
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
'</tr>';
}).join('');
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
// rather than forcing every cell to wrap. The explanatory note stays outside it.
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th>'+
'<th title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
'change a completed SOP, and delete the project. '+
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
'but cannot delete WPs or change the SOP once it\'s complete. '+
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
'team pickers and notification routing, and grants nothing on its own.</div>';
}
async function createUser(){
const msg=document.getElementById('users-create-msg');
const username=document.getElementById('nu-username').value.trim();
const full_name=document.getElementById('nu-fullname').value.trim();
const email=document.getElementById('nu-email').value.trim();
const role=document.getElementById('nu-role').value;
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
const password=document.getElementById('nu-password').value;
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
msg.style.color='var(--muted)'; msg.textContent='Creating…';
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
if(status===200){
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
loadUsers();
} else {
msg.style.color='var(--red)';
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
}
}
async function resetPw(id, username){
const pw=prompt('New password for "'+username+'" (min 8 characters):');
if(pw===null) return;
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status===200) alert('Password reset for '+username+'.');
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status===200) loadUsers();
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
// Change a user's role (user ↔ admin) at any time. The server enforces the same
// admin-only rule as every other user-management call, and refuses to remove the
// last admin. On any failure we reload so the dropdown snaps back to the truth.
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); }
else {
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status===200){ loadUsers(); }
else {
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status===200) loadUsers();
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
// ── project access assignment ───────────────────────────────────────────────────
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
}
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
function openProjectModal(userId, username, projects, assigned, userObj, roles){
closeProjectModal();
const isAdmin = userObj && normRole(userObj.role)==='admin';
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
roles = roles || {};
// Each project row: access tick + the role ON THAT project. "Same as account"
// inherits the account's Permissions, so the common case needs no thought.
// Live jobs first — an archived one is still listed (an existing assignment has to
// stay removable) but it is finished work, so it doesn't belong at the top of a
// list you're using to staff someone.
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
'</select>';
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+'</span>'+
'</label>'+ sel +
'</div>';
}).join('') : '<div class="note">No projects exist yet.</div>';
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
modal.innerHTML =
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access &amp; permissions — '+uesc(username)+'</div>'+
'<div style="padding:14px 18px;overflow:auto;">'+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
'<div id="proj-list">'+items+'</div>'+
'</div>'+
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
'<button onclick="closeProjectModal()">Cancel</button>'+
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
'</div>'+
'</div>';
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
document.body.appendChild(modal);
const saveBtn = document.getElementById('proj-save');
if(saveBtn) saveBtn.onclick = async () => {
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
const roleMap = {};
ids.forEach(pid => {
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
if(sel && sel.value) roleMap[pid] = sel.value;
});
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
if(status===200){ closeProjectModal(); loadUsers(); }
else alert('Save failed (HTTP '+status+').');
};
}
// ── projects: archive / unarchive ───────────────────────────────────────────────
// Archiving is the answer to "this job is over but I can't throw the data away".
// An archived project disappears from every picker, switcher and search in the
@@ -298,8 +615,8 @@ async function loadDefaultMembers(){
renderDefaultMembers();
}
// The role only matters while the tick is on. The select sits in a sibling <td>, so
// the lookup is scoped to the row.
// Same idea as projRowToggled() — the role only matters while the tick is on — but
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
function defMemToggled(cb){
const row = cb.closest('tr');
const sel = row && row.querySelector('select');
@@ -317,7 +634,8 @@ function renderDefaultMembers(){
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
// Admins reach every project already, so there is nothing to add them to.
// Admins reach every project already, so there is nothing to add them to
// the same thing projAccessCell() says in the user table.
if(role === 'admin'){
return '<tr>'+who+
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
@@ -327,15 +645,8 @@ function renderDefaultMembers(){
}
const on = !!u.auto_add_projects;
const cur = u.auto_add_role || '';
// Every project-scoped role is offered, super user included: this card is
// admin-only, and "the QA lead runs the users on every new job" is exactly the
// sort of standing rule it exists to express.
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
uesc(PERM_LABELS[role]||role)+')</option>']
.concat(PROJECT_SCOPED_ROLES.map(r =>
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
return '<tr>'+who+
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
'<td><label class="chk">'+
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
' title="Add this user to every project created from now on"'+
@@ -343,7 +654,11 @@ function renderDefaultMembers(){
'</label></td>'+
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
' title="The role this user gets on those projects"'+
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
'</select></td>'+
'</tr>';
}).join('');
wrap.innerHTML =
@@ -354,8 +669,8 @@ function renderDefaultMembers(){
'<th title="Their role on those projects">Role on those projects</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
'Directory</a> to add someone to a project that already exists.</div>';
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
'that already exists.</div>';
}
// Saves on every tick and every dropdown change — there is no Save button, so a

View File

@@ -124,24 +124,14 @@
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
// A Project Super User is a Project Admin with user administration on top, so it
// counts here too (server: auth.is_project_admin).
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
return r === 'admin' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// Whether this account can administer USER accounts. The account role is only half
// the answer — the role can also be held on a single project — so anything that
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
// cheap hint used to decide whether to bother offering a control.
window.wpMayManageUsers = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_super_user';
};
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
@@ -195,11 +185,6 @@
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
// The directory is readable by everyone — it's how you find who is on your job —
// so it is offered to everyone, not just the people who can edit accounts.
if (!/(^|\/)users\.html$/.test(location.pathname)) {
wrap.appendChild(sep()); wrap.appendChild(link('Users', null, 'users.html'));
}
// Always offered; wp-format.js may still be parsing when the menu is built, so
// the check happens at click time rather than once, up front.
wrap.appendChild(sep());

View File

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

View File

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

View File

@@ -13,7 +13,6 @@
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="wp-sidenav.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
@@ -88,6 +87,5 @@
<script src="help.js"></script>
<script src="field.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -141,7 +141,7 @@
<h4>Key fields</h4>
<ul>
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
<li><strong>Assets</strong> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</li>
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li>
<li><strong>Scope &amp; Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
<li><strong>Labor Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
@@ -281,7 +281,7 @@
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
<tr><td><strong>Bagged &amp; tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
<tr><td><strong>Asset</strong></td><td>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</td></tr>
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
</table>` },

View File

@@ -373,13 +373,6 @@
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
<!-- USER DIRECTORY -->
<a href="users.html" class="card" id="card-users">
<h3>User Directory</h3>
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
<button class="card-button" id="card-users-btn">Open Directory</button>
</a>
</div>
<!-- COMMENTS SECTION -->

View File

@@ -14,16 +14,15 @@
'use strict';
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v6';
const CACHE = 'wp-suite-shell-v5';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html', '/users.html',
'/field.html', '/login.html', '/admin.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
'/wp-chrome.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
'/console-util.js', '/admin.js', '/users.js',
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];

View File

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

View File

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

View File

@@ -105,7 +105,7 @@ function applySOP(){
applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner();
}
@@ -134,7 +134,7 @@ function applyKindVisibility(){
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj);
show('bim-card', ewp); // model area / clash + IFF # / scan
show('asset-card', !ewp); // controls.dev assets
show('asset-card', !ewp); // Micron DB assets
show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
@@ -807,20 +807,344 @@ function splitByDiscipline(){
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
}
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
// Interim: assets are linked manually back to controls.dev. A future direct
// integration will let the user pick them from a list instead of pasting links.
// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
// Assets are picked from the Micron asset catalog, a SQL Server database this app
// reads through /api/assets. The lookup is strictly read-only — picking an asset
// never writes to the catalog, and there is no endpoint that could.
//
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
// neither may block someone from writing a work package: in both cases the picker
// says so and manual entry carries on. Manually entered assets are marked
// source:'manual' so it stays visible which rows the catalog vouches for.
// The whole catalog is fetched once when the page loads and searched in memory —
// it is slow-moving reference data, so a request per keystroke would buy nothing
// and cost latency on every one.
let assetCatalog = []; // the full catalog, loaded once
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
let assetCatalogState = 'loading'; // loading | ready | absent | error
let assetResults = []; // current matches; the result list indexes into this
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
function assetAlreadyAdded(tag){
const k = String(tag||'').trim().toLowerCase();
return pkgAssets.some(a => assetKey(a) === k && k);
}
function buildAssets(){
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'&quot;')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
<td><input type="text" value="${(a.desc||'').replace(/"/g,'&quot;')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
<td><input type="url" value="${(a.link||'').replace(/"/g,'&quot;')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
tb.appendChild(tr); });
if(!pkgAssets.length){
tb.innerHTML = `<tr><td colspan="3" class="asset-empty">No assets yet — search the Micron DB above to add the assets this package covers.</td></tr>`;
return;
}
pkgAssets.forEach((a,i)=>{
const tr=document.createElement('tr');
// The asset ID on a catalog row is shown as text, not an input: the catalog
// is the source of truth for it and a locally edited copy would silently
// disagree. The note is always the user's own, so it stays editable either
// way. Every interpolation below is esc()'d text content or a numeric index
// — never a raw string inside an inline handler, which is the bug pattern
// recorded in KNOWN-ISSUES.md §1.
const idCell = a.source === 'catalog'
? `<td><span class="asset-tag">${esc(a.tag)}</span> <span class="asset-badge" title="From the Micron DB">Micron DB</span></td>`
: `<td><input type="text" value="${esc(a.tag)}" placeholder="asset ID" oninput="pkgAssets[${i}].tag=this.value"></td>`;
tr.innerHTML = idCell +
`<td><input type="text" value="${esc(a.desc)}" placeholder="what it is / why it's in scope" oninput="pkgAssets[${i}].desc=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAsset(${i})" title="Remove">✕</button></td>`;
tb.appendChild(tr);
});
}
// Kept for saved packages written before the picker existed: their rows have no
// `source`, so they would render as read-only catalog rows with no way to fix a
// typo. Anything that didn't come from the catalog is treated as manual.
function normaliseAsset(a){
const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
if(o.source !== 'catalog') o.source = 'manual';
return o;
}
function addManualAsset(){
pkgAssets.push(normaliseAsset({}));
buildAssets();
track('asset_added',{source:'manual'});
}
// ── CSV IMPORT ───────────────────────────────────────────────────────────────
// Bulk-add a list of asset ids. Each imported id is checked against the loaded
// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
// for rather than silently dropped. Nothing is ever written back to Micron.
const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
function importAssets(ev){
const f = ev.target.files && ev.target.files[0];
if(!f){ return; }
const clear = () => { ev.target.value = ''; };
if(/\.xlsx?$/i.test(f.name)){
alert('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){ alert('Could not parse that CSV.'); clear(); return; }
if(!rows.length){ alert('That file has no rows.'); clear(); return; }
applyImportedAssets(rows);
clear();
};
r.onerror = () => { alert('Could not read that file.'); 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){ alert('No asset ids found in that file.'); 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.';
alert(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=[];
for(const tag of assetCatalog){
const t = tag.toLowerCase();
if(t === q) exact.push(tag);
else if(t.startsWith(q)) prefix.push(tag);
else if(t.includes(q)) other.push(tag);
if(exact.length + prefix.length + other.length >= ASSET_RESULT_MAX) break;
}
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 isnt 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();
// Mark just this row instead of re-rendering the list: the results stay open
// for the next pick, the scroll position holds, and the clicked element is
// never detached mid-click (see the composedPath note in initAssetPicker).
markAssetResultAdded(ix);
const box = document.getElementById('asset-search');
if(box) box.focus(); // keep typing straight into the next search
track('asset_added',{source:'catalog'});
}
function markAssetResultAdded(ix){
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
if(!row) return;
row.classList.add('is-added');
row.disabled = true;
const label = row.querySelector('.asset-result-add');
if(label) label.textContent = 'added';
}
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){
@@ -1189,8 +1513,8 @@ function renderPackage(pkg){
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('<br>')}</td></tr>`:''}
</tbody></table>`;
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets</h2><table><thead><tr><th style="width:240px">Asset ID</th><th>Note</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`); h+=`</tbody></table>`; }
let scopeHtml;
if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections
scopeHtml = Object.keys(pkg.scope).map(d=>{
@@ -1688,7 +2012,7 @@ function loadPackageIntoForm(p){
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
// collections
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
@@ -1745,7 +2069,7 @@ function newPackage(){
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
pkgAssets=[]; buildAssets();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
@@ -2111,6 +2435,7 @@ function bootData(){
bootSOP();
setRadio('status','Draft');
loadMembers();
initAssetPicker();
initWpNavDrawer();
renderSavedList();
positionSectionNav();

View File

@@ -181,12 +181,23 @@
</div>
</div>
<!-- ASSETS (controls.dev) -->
<!-- ASSETS (Micron asset catalog) -->
<div class="card" id="asset-card">
<div class="sub-heading">Assets</div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
<button class="add-btn" onclick="addAsset()">+ Add Asset</button>
<div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
<div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
<div class="asset-pick" id="asset-pick">
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
placeholder="Search asset IDs, or paste a column from Excel…"
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
<div class="asset-results" id="asset-results" hidden></div>
</div>
<div class="field-hint" id="asset-source-note"></div>
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
<div class="material-actions">
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
</div>
</div>
<!-- DISCIPLINES -->

View File

@@ -645,6 +645,38 @@
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
A search box over a read-only catalog. Results drop below the input and are
added to the table as rows; the catalog itself is never written to. */
.asset-pick { position:relative; margin-bottom:10px; }
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
box-sizing:border-box; }
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
max-height:320px; overflow-y:auto; background:var(--surface);
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
box-shadow:0 8px 24px rgba(20,30,50,.18); }
.asset-results[hidden] { display:none; }
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
width:100%; text-align:left; background:none; border:0;
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
.asset-result:hover:not(:disabled) { background:var(--surface2); }
.asset-result:disabled { cursor:default; opacity:.55; }
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
/* Marks rows the catalog vouches for, so a manually typed asset is never
mistaken for a looked-up one. */
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
.asset-tag { font-weight:600; }
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);

View File

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

View File

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

View File

@@ -30,3 +30,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password
# ── Micron asset catalog (optional) ───────────────────────────────────────────
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
# app only ever runs the single SELECT in server/assets_db.py, so give it a
# db_datareader login and nothing more.
#
# Leave this unset and the suite works normally — the picker reports that no
# catalog is configured and people type asset tags in by hand.
#
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
#
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
# and install the Microsoft ODBC driver in the image:
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
#
# Two things to check when the picker says the catalog is unreachable:
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
# Micron schema — that one constant is the whole schema contract.
# 2. The api container is on the `outbound` network in docker-compose.yml. The
# `internal` network has no default gateway, which blocks the VPN as well as
# the internet.

View File

@@ -25,7 +25,7 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
from . import models, auth, notify
from . import models, auth, notify, assets_db
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -163,9 +163,7 @@ def require_project_admin(db: Session, user: "models.User", project_id: Optional
project, and editing a SOP that has already been completed. Requires project
access AND Project Admin *on that project*."""
require_project_access(db, user, project_id)
if effective_role(db, user, project_id) not in (
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN,
):
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
raise HTTPException(
status_code=403,
detail=f"{what} requires the Project Admin role on this project",
@@ -187,19 +185,7 @@ def require_project_writable(db: Session, user_or_none, project_id: Optional[str
if not project_id:
return
proj = db.get(models.Project, project_id)
if proj is None:
# The project this write targets is gone — usually an outbox op queued before
# someone deleted the job. Refusing here is what keeps it a clean 409 instead
# of a foreign-key violation surfacing as a 500: the row could never be
# inserted anyway now that both engines enforce their FKs (see db.py). 409
# also matters because project-data.js retires a 4xx op and would retry a 5xx
# forever, so this is the difference between one quiet failure and a loop.
raise HTTPException(
status_code=409,
detail=f"{what} — this project no longer exists. It was deleted, so there is "
f"nothing to save it against.",
)
if proj.archived_at is not None:
if proj is not None and proj.archived_at is not None:
raise HTTPException(
status_code=409,
detail=(f"{what} — this project is archived (read-only). An administrator "
@@ -261,163 +247,6 @@ def add_default_members(db: Session, project_id: str, actor) -> list[str]:
return added
# ── User-administration scope ──────────────────────────────────────────────────
# User administration used to be one thing: an app admin did all of it. It is now
# two, because a project admin has to be able to staff their own job without an app
# admin on the phone. An app admin still manages every account; a PROJECT SUPER USER
# manages the accounts on the projects they hold that role on.
#
# Three questions, deliberately separate, because they have different answers:
# managed_project_ids which projects do I administer the users of?
# visible_user_ids whose entry may I SEE in the directory?
# manage_user_problem may I change this account? (much narrower than seeing it)
def managed_project_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
"""Projects where `caller` may administer user accounts. None means every project
(an app admin). Read per-membership so the per-project override decides: a super
user demoted to plain member on one job does not administer its users, and an
ordinary account made super user on one job does administer that one."""
if auth.is_admin(caller):
return None
rows = db.scalars(
select(models.ProjectMember).where(models.ProjectMember.user_id == caller.id)
).all()
account_role = auth.normalize_role(caller.role)
out = set()
for r in rows:
role = auth.normalize_role(r.role) if (r.role or "").strip() else account_role
if role == auth.ROLE_PROJECT_SUPER:
out.add(r.project_id)
return out
def is_user_manager(db: Session, user: "models.User") -> bool:
"""May this account administer users at all? THE one definition — derived from the
managed set, never from the account role alone, because the super-user role can be
held on a single project (ProjectMember.role) by an otherwise ordinary account.
Falls out of it that a super user with no project memberships manages nobody,
which is right: the authority comes from the jobs, not the job title."""
managed = managed_project_ids(db, user)
return managed is None or bool(managed)
def require_user_manager(user: "models.User" = Depends(auth.get_current_user),
db: Session = Depends(get_db)) -> "models.User":
"""First gate on the user-administration routes: does this caller administer the
users of ANY project? Which accounts they may then touch is a second, narrower
check per target — require_manage_user."""
if not is_user_manager(db, user):
raise HTTPException(
status_code=403,
detail="Managing user accounts requires the Administrator role, or Project "
"Super User on a project",
)
return user
def member_project_ids(db: Session, user_id: str) -> set[str]:
"""Every project this user has a membership row for (no admin shortcut — this is
the raw set, which is exactly what the scope checks need to reason about)."""
return set(db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)
).all())
def visible_user_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
"""Whose directory entry `caller` may read. None means everyone (an app admin).
Anyone signed in may look up the people they actually work with — their own
projects' members — plus the app admins, who are on every project implicitly and
are who you go to when something needs unblocking. Nobody else: the directory
must not become a company-wide address book for a single-project subcontractor."""
if auth.is_admin(caller):
return None
ids = {caller.id}
mine = accessible_project_ids(db, caller) or set()
if mine:
ids |= set(db.scalars(
select(models.ProjectMember.user_id).where(models.ProjectMember.project_id.in_(mine))
).all())
ids |= set(db.scalars(
select(models.User.id).where(models.User.role == auth.ROLE_ADMIN)
).all())
return ids
def manage_user_problem(db: Session, caller: "models.User", target: "models.User",
cache: Optional[dict] = None) -> Optional[str]:
"""None if `caller` may make ACCOUNT-level changes to `target` (password, name,
permissions role, enable/disable, delete); otherwise the reason they may not, in
words the console can show verbatim.
An app admin may always. A super user may only when the account sits ENTIRELY
inside the projects they administer, and is not itself an admin or super user.
Both limits matter:
• Exclusive scope, because these changes are global. Resetting a password or
disabling an account reaches every project that person is on, so a super user
must not be able to reach into a job they don't run by way of a shared member.
• No admin/super targets, because otherwise the role could be used to take over
a peer's account and inherit their scope.
Project-scoped changes (adding someone to MY project, their role THERE) are not
account-level and are checked against `managed_project_ids` instead.
`cache` lets a caller judging a whole page of users hand in the two lookups this
needs ('managed', and 'members' as {user_id: {project_id}}) so the verdict for
thirty rows costs two queries instead of sixty. The rule itself lives only here."""
if auth.is_admin(caller):
return None
cache = cache if cache is not None else {}
managed = cache.get("managed")
if managed is None:
managed = cache["managed"] = managed_project_ids(db, caller) or set()
if not managed:
return ("You don't administer the users of any project — that needs the Project "
"Super User role on the project")
if auth.normalize_role(target.role) in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER):
return "Only an application administrator can change an Administrator or Project Super User account"
members = cache.get("members")
theirs = members.get(target.id, set()) if members is not None else member_project_ids(db, target.id)
if not theirs:
return ("This account isn't on any project, so only an application administrator "
"can change it")
outside = theirs - managed
if outside:
return (f"{target.username} is also on {len(outside)} project(s) you don't administer — "
"account changes there have to come from an application administrator. "
"You can still change their access and role on your own projects.")
return None
def require_manage_user(db: Session, caller: "models.User", target: "models.User") -> None:
problem = manage_user_problem(db, caller, target)
if problem:
raise HTTPException(status_code=403, detail=problem)
def require_see_user(db: Session, caller: "models.User", target: "models.User") -> None:
"""404, not 403: whether an account exists outside your projects is itself not
yours to learn, and a 403 would confirm the username."""
visible = visible_user_ids(db, caller)
if visible is not None and target.id not in visible:
raise HTTPException(status_code=404, detail="User not found")
def grantable_roles(caller: "models.User") -> tuple:
"""Permissions roles `caller` may hand out. A super user may staff their job with
project admins and project users — never another admin or super user, which is the
line that keeps the role from being a route to app-wide control."""
if auth.is_admin(caller):
return auth.ROLES
return (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
def load_target_user(db: Session, user_id: str) -> "models.User":
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
# ── Audit trail ────────────────────────────────────────────────────────────────
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
@@ -563,10 +392,6 @@ class NewUserIn(BaseModel):
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
project_role: str = "" # job function on the project (no permissions)
# Projects to put the new account on straight away. Optional for an app admin
# (who can assign later); REQUIRED for a super user, whose authority over an
# account comes from the projects it is on — see create_user.
project_ids: list[str] = Field(default_factory=list)
class ProjectRoleIn(BaseModel):
@@ -778,11 +603,8 @@ def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
@app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load.
`role` is normalized here so no page has to know that a pre-roles account stores
'user' where it now means 'project_user'."""
return {"user": {**user.to_dict(), "role": auth.normalize_role(user.role)}}
"""Who is logged in. The frontend guard calls this on every page load."""
return {"user": user.to_dict()}
# ── Display preferences (self-service) ─────────────────────────────────────────
@@ -845,122 +667,22 @@ def change_password(body: PasswordChangeIn, request: Request, response: Response
return {"ok": True}
# ── User administration ─────────────────────────────────────────────────────────
# Two kinds of caller reach these routes: an app admin, who manages every account,
# and a Project Super User, who manages the accounts on the projects they administer.
# Every route therefore asks TWO questions — does this role carry user administration
# (require_user_manager), and may it touch THIS account (require_manage_user) —
# and the read route asks a third, wider one (visible_user_ids) because looking a
# colleague up is not the same as being able to change them.
def directory_entry(db: Session, u: "models.User", caller: "models.User",
counts: Optional[dict] = None, cache: Optional[dict] = None) -> dict:
"""One row of the user directory, cut to what `caller` is entitled to see.
A manager gets the administrative record (last login, the auto-add flags, and a
`manageable` verdict with the reason when it's no). Everyone else gets the contact
card only — a project user has no business reading their colleagues' login history
out of a page whose job is "who is on this project and how do I reach them"."""
if not is_user_manager(db, caller):
return {
"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email,
"role": auth.normalize_role(u.role), "project_role": u.project_role or "",
"is_active": u.is_active, "manageable": False,
}
problem = manage_user_problem(db, caller, u, cache)
n = None if counts is None else counts.get(u.id, 0)
return {
**u.to_dict(),
"role": auth.normalize_role(u.role),
"manageable": problem is None,
"manage_blocked_reason": problem or "",
"project_count": n,
}
# ── User administration (admin only) ────────────────────────────────────────────
@app.get("/api/auth/users")
def list_users(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""The user directory, scoped to the caller. An app admin sees every account; a
project member sees the people on their own projects (plus the app admins)."""
visible = visible_user_ids(db, user)
stmt = select(models.User).order_by(models.User.username)
if visible is not None:
stmt = stmt.where(models.User.id.in_(visible))
rows = db.scalars(stmt).all()
# One pass over the membership table serves both the project-access count and the
# per-row "may I manage this account" verdict. The old console fetched the counts
# with one HTTP request per user.
counts, cache = None, None
if is_user_manager(db, user):
members: dict[str, set] = {}
for uid, pid in db.execute(
select(models.ProjectMember.user_id, models.ProjectMember.project_id)
).all():
members.setdefault(uid, set()).add(pid)
counts = {uid: len(pids) for uid, pids in members.items()}
cache = {"members": members}
return [directory_entry(db, u, user, counts, cache) for u in rows]
@app.get("/api/auth/user-scope")
def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""What the signed-in account may do on the user directory page, so the page can
render the right controls instead of guessing at the rules and drawing buttons
that 403. Advisory only — every route re-checks server-side."""
managed = managed_project_ids(db, user)
if managed is None:
rows = db.scalars(select(models.Project).order_by(models.Project.name)).all()
else:
rows = db.scalars(
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
).all() if managed else []
manager = managed is None or bool(managed)
return {
"can_manage_users": manager,
"scope": "all" if managed is None else "projects",
"role": auth.normalize_role(user.role),
"grantable_roles": list(grantable_roles(user)) if manager else [],
"grantable_project_roles": list(
auth.PROJECT_SCOPED_ROLES if auth.is_admin(user)
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
) if manager else [],
"role_labels": auth.ROLE_LABELS,
"project_roles": list(auth.PROJECT_ROLES),
"managed_projects": [{"id": p.id, "name": p.name, "number": p.number,
"archived": p.archived_at is not None} for p in rows],
}
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
return [u.to_dict() for u in rows]
@app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), 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)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
if auth.find_user(db, body.username):
raise HTTPException(status_code=409, detail="A user with that username already exists")
managed = managed_project_ids(db, actor)
requested = [p for p in dict.fromkeys(body.project_ids) if p]
for pid in requested:
check_id(pid)
if managed is not None:
# A super user's authority over an account is derived from the projects that
# account is on. Creating one with no project — or on a job they don't run —
# would either produce an account they instantly cannot manage, or reach into
# someone else's job. Both are refused rather than silently narrowed.
if not requested:
raise HTTPException(
status_code=400,
detail="Choose at least one project for the new account — you administer users per project",
)
outside = [p for p in requested if p not in managed]
if outside:
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
missing = [p for p in requested if p not in valid]
if missing:
raise HTTPException(status_code=400, detail="One of those projects no longer exists")
u = models.User(
id=gen_id("user"),
username=body.username.strip(),
@@ -971,75 +693,54 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
project_role=body.project_role.strip()[:120],
)
db.add(u)
# Flush the account before adding memberships that point at it. The ORM decides
# flush order from relationship() declarations, and models.py deliberately has
# none (plain columns + ForeignKey), so it will happily emit the project_members
# INSERT before the users one — which the database then rejects. Without this the
# whole call fails with a foreign-key violation on any engine that actually
# enforces them, which is every engine we run: Postgres always, and SQLite since
# db.py started setting `PRAGMA foreign_keys=ON`.
db.flush()
log_event(db, actor, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role,
"projects": len(valid)})
for pid in requested:
if pid in valid:
grant_project_access(db, u.id, pid)
if valid:
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
detail={"projects": len(valid), "reason": "created_with_access"})
log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role})
db.commit()
db.refresh(u)
return directory_entry(db, u, actor)
return u.to_dict()
@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)
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
if u.id == actor.id and not body.is_active:
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id and not body.is_active:
raise HTTPException(status_code=400, detail="You cannot disable your own account")
u.is_active = body.is_active
log_event(db, actor, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
summary=u.username, detail={"is_active": bool(body.is_active)})
db.commit()
return directory_entry(db, u, actor)
return u.to_dict()
@app.post("/api/auth/users/{user_id}/role")
def set_user_role(user_id: str, body: RoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Change a user's PERMISSIONS role. Their job function on the project is separate
— see set_user_project_role.
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Change a user's PERMISSIONS role (admin / project_admin / project_user).
Their job function on the project is separate — see set_user_project_role.
Guards: you can't change your own role (avoids self-lockout), the last remaining
admin can't be demoted (keeps the app manageable), and a super user may only hand
out the roles in `grantable_roles` — never admin or another super user."""
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
if u.id == actor.id:
Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot change your own role")
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
other_admins = db.scalars(
@@ -1062,27 +763,27 @@ def set_user_role(user_id: str, body: RoleIn, actor: models.User = Depends(requi
u.auto_add_projects = False
u.auto_add_role = ""
detail["auto_add_cleared"] = True
log_event(db, actor, "role_changed", "user", u.id, summary=u.username, detail=detail)
log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail)
db.commit()
db.refresh(u)
return directory_entry(db, u, actor)
return u.to_dict()
@app.post("/api/auth/users/{user_id}/project-role")
def set_user_project_role(user_id: str, body: ProjectRoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Set a user's job function on the project (Project Manager, Superintendent,
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
and notification routing read, so it's worth keeping accurate."""
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
old = u.project_role or ""
u.project_role = (body.project_role or "").strip()[:120]
log_event(db, actor, "project_role_changed", "user", u.id, summary=u.username,
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
detail={"from": old, "to": u.project_role})
db.commit()
db.refresh(u)
return directory_entry(db, u, actor)
return u.to_dict()
@app.post("/api/auth/users/{user_id}/auto-add")
@@ -1090,17 +791,13 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend
"""Flag a user as a default member of every project created from here on, with
an optional role on those projects. It only touches NEW projects — existing
assignments stay under the admin's hand (see set_user_projects), because
back-filling everyone onto historical jobs is never what this flag means.
App-admin only, unlike the rest of user administration: this is a standing rule
about every project that will ever exist, including the ones a super user has no
part in."""
allowed = ("",) + auth.PROJECT_SCOPED_ROLES
back-filling everyone onto historical jobs is never what this flag means."""
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
role = (body.role or "").strip()
if role not in allowed:
raise HTTPException(
status_code=400,
detail=f"role must be '' (inherit) or one of {', '.join(auth.PROJECT_SCOPED_ROLES)}",
detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}",
)
u = db.get(models.User, user_id)
if not u:
@@ -1117,97 +814,57 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend
@app.delete("/api/auth/users/{user_id}")
def delete_user(user_id: str, 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)
if u.id == actor.id:
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
log_event(db, actor, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
db.delete(u)
db.commit()
return {"deleted": user_id}
@app.get("/api/auth/users/{user_id}/projects")
def get_user_projects(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the project list to choose from.
(Admins implicitly access every project regardless of what's ticked here.)
A super user is shown ONLY the projects they administer, and `other_projects` says
how many more the person is on — enough for the dialog to be honest that it is
editing a slice of this account's access, without naming jobs that aren't theirs."""
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the full project list for the
assignment UI. (Admins implicitly access every project regardless.)"""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
managed = managed_project_ids(db, actor)
if managed is None:
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
in_scope = rows
else:
projects = db.scalars(
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
).all() if managed else []
in_scope = [r for r in rows if r.project_id in managed]
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
return {
"user": directory_entry(db, u, actor),
"assigned": [r.project_id for r in in_scope],
"user": u.to_dict(),
"assigned": [r.project_id for r in rows],
# Per-project role overrides, keyed by project id ('' = inherit the account's).
"roles": {r.project_id: (r.role or "") for r in in_scope},
"roles": {r.project_id: (r.role or "") for r in rows},
# Archived projects stay on this list on purpose — an existing assignment has
# to remain visible and removable — but they're flagged so the dialog can say
# so, rather than offering a finished job as though it were live work.
"projects": [{"id": p.id, "name": p.name, "number": p.number,
"archived": p.archived_at is not None} for p in projects],
"other_projects": len(rows) - len(in_scope),
"grantable_project_roles": list(
auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor)
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
),
}
@app.put("/api/auth/users/{user_id}/projects")
def set_user_projects(user_id: str, body: ProjectAssignIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set.
For an app admin the given set IS the whole answer. For a super user it replaces
only their own slice: memberships on projects they don't administer are left
exactly as they were, because a payload that simply omits them would otherwise cut
someone off from a job the caller can't even see."""
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
requested = [p for p in dict.fromkeys(body.project_ids) if p]
for pid in requested:
check_id(pid)
managed = managed_project_ids(db, actor)
if managed is not None:
outside = [p for p in requested if p not in managed]
if outside:
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
# A super user may hand out the project-scoped roles below their own; only an app
# admin can make someone a super user on a project. Anything unrecognised falls
# back to inheriting the account's own role.
allowed = auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor) else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
# Only the two project-scoped roles make sense here: app admin is global, and
# anything unrecognised falls back to inheriting the account's own role.
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
# Rebuild only the rows this caller owns. Scoping the DELETE is the whole of the
# "leave other jobs alone" guarantee — get it wrong and a super user's save
# silently revokes access everywhere else.
doomed = delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id)
if managed is not None:
# in_() on an empty set is a valid always-false predicate, so a caller who
# administers nothing deletes nothing (require_manage_user already refused them).
doomed = doomed.where(models.ProjectMember.project_id.in_(managed))
db.execute(doomed)
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
for pid in valid:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
role=roles.get(pid, "")))
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
detail={"projects": len(valid),
"scope": "all" if managed is None else "managed",
"overrides": {p: r for p, r in roles.items() if p in valid}})
db.commit()
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
@@ -2153,6 +1810,29 @@ def list_comments(
return [c.to_dict() for c in rows]
# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
# Backs the asset picker in the work package creator. This is a *lookup*, not a
# resource this app owns: there is no POST, and nothing here ever writes to the
# Micron database. It is deliberately not project-scoped by the app's own access
# rules — the catalog is reference data, and any signed-in user who can build a
# work package needs to be able to name the assets it covers. Authentication is
# still required (the auth_gate middleware covers every /api/ path).
@app.get("/api/assets")
def list_assets(_user: models.User = Depends(auth.get_current_user)):
"""The whole catalog, fetched once when the creator loads. Searching happens
in the browser — there is no per-keystroke endpoint by design."""
if not assets_db.configured():
# Not an error — the suite is designed to run without Micron wired up.
# The picker reads this and switches to manual entry.
return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
try:
return {"configured": True, "assets": assets_db.load()}
except assets_db.AssetSourceError as exc:
# 503, not 500: the suite is healthy, its upstream lookup is not. The
# picker degrades to manual entry rather than blocking the package.
raise HTTPException(status_code=503, detail=str(exc))
# ── Local dev convenience: serve the static site from this app ──────────────────
# In production NGINX serves html/ and only proxies /api/ here, so this app never
# receives "/" requests, and the api Docker image doesn't even include html/ — so

199
server/assets_db.py Normal file
View File

@@ -0,0 +1,199 @@
"""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
"""
# How long a fetched catalog is reused before the next page load re-queries.
CACHE_SECONDS = int(os.getenv("MICRON_ASSETS_CACHE_SECONDS", "300")) # 5 min
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
_cache_lock = threading.Lock()
def load(force: bool = False) -> list[dict]:
"""Return the whole catalog as [{'tag': …}, …]. Never writes."""
global _cache, _cached_at
with _cache_lock:
if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
return _cache
engine = _get_engine()
try:
with engine.connect() as conn:
result = conn.execute(text(ASSET_QUERY)).mappings().all()
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)
raise AssetSourceError(
"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."
) 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()
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."}

View File

@@ -20,21 +20,11 @@ Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
everything a project_admin may do, plus USER ADMINISTRATION
scoped to the projects they hold the role on: they create and
manage the accounts on their own jobs without an app admin
having to do it for them. They cannot reach app settings, and
they cannot create or alter an admin / super-user account.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
@@ -66,21 +56,14 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_SUPER = "project_super_user"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
# Ordered most- to least-privileged; the console renders dropdowns in this order.
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_SUPER: "Project Super User",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
# run the users on one job and be an ordinary member of the next. '' means "inherit
# the account's own role" and is always allowed alongside these.
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
@@ -107,19 +90,9 @@ def is_admin(user: "models.User") -> bool:
def is_project_admin(user: "models.User") -> bool:
"""True for the roles allowed to delete work packages and change a completed
SOP. A super user is a project admin with user administration on top, so it is
included here — never enumerate the two roles by hand."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
# super-user role can be held per project (ProjectMember.role), so the question needs
# membership rows to answer and lives in app.py — `is_user_manager` /
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
@@ -324,8 +297,6 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us
return user
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
def find_user(db: Session, username: str) -> Optional["models.User"]:
"""Look up by username, case-insensitively (also matches on email)."""

View File

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

View File

@@ -45,12 +45,8 @@ def _prompt_password(provided: str | None, username: str = "") -> str:
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
if role not in ("admin", "user"):
sys.exit("role must be 'admin' or 'user'")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
@@ -74,10 +70,9 @@ def cmd_list(args) -> None:
if not rows:
print("No users yet. Create one with: create-admin <username>")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
for u in rows:
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def cmd_reset_password(args) -> None:
@@ -118,8 +113,7 @@ def main() -> None:
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
c.add_argument("--role", choices=["admin", "user"], default="user")
sub.add_parser("list", help="list all accounts")

View File

@@ -9,21 +9,6 @@ The full client document for a SOP or WP is kept verbatim in a JSON `data`
column, with the most-queried fields promoted to real columns for listing and
filtering. IDs are short strings (client- or server-generated) so the browser
can upsert without round-tripping a sequence.
NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about.
Every link here is a plain column plus a ForeignKey; nothing is navigable as
`project.work_packages`. Queries are explicit selects, which suits an API that
mostly reads one scoped list at a time and never wants a lazy load firing inside
a response.
The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships,
not from ForeignKey metadata. With none declared it has no dependency edge to
follow, so if you add a parent and its child in the SAME flush it may emit the
child's INSERT first and the database will reject it. Both engines enforce foreign
keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this
is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent —
see `create_user` in app.py, which creates an account and its ProjectMember rows
together.
"""
from datetime import datetime, timezone
from typing import Optional
@@ -147,8 +132,7 @@ class User(Base):
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_super_user' | 'project_admin' |
'project_user' (see auth.ROLES).
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
@@ -205,11 +189,8 @@ class ProjectMember(Base):
entirely). One row per (user, project) pair.
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
one job and a normal Project User on another, or a Project Super User (who
administers that job's user accounts) on one job only. Empty means "inherit the
account's own role" (User.role), which is how every existing row behaves.
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
one job and a normal Project User on another. Empty means "inherit the account's
own role" (User.role), which is how every existing row behaves."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)

View File

@@ -9,6 +9,12 @@ gunicorn==26.0.0
sqlalchemy==2.0.51
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
# Chosen over pyodbc because it ships self-contained wheels —
# pyodbc would also need msodbcsql18 + unixODBC installed in
# the image. To use pyodbc instead, add it here, install the
# Microsoft ODBC driver in the Dockerfile, and switch
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing

View File

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

View File

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

View File

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