Compare commits
5 Commits
Micron-Ass
...
users/dire
| Author | SHA1 | Date | |
|---|---|---|---|
| 9459e76a6c | |||
| 7f831bf1ca | |||
| 64a5fd5612 | |||
| c99ef08cf1 | |||
| 4ace2afb1c |
@@ -195,12 +195,18 @@ good deploy look broken.
|
|||||||
Sanity checks — all four should take under a minute:
|
Sanity checks — all four should take under a minute:
|
||||||
|
|
||||||
1. Log in. The home page offers to select or create a project.
|
1. Log in. The home page offers to select or create a project.
|
||||||
2. Open **Admin Console** (the link is on the home page; you need an admin account).
|
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
|
||||||
The user table should read as **one line per user** — if rows are three lines tall
|
home page). The table should read as **one line per user** — if rows are three lines
|
||||||
and the table spills outside its white card, you are still on the old cached
|
tall and the table spills outside its white card, you are still on the old cached
|
||||||
files: hard-reload again.
|
files: hard-reload again.
|
||||||
3. Two new cards are present and load: **Projects**, and **Default members on new
|
|
||||||
projects**. Both should list rows, not an error.
|
> Changed since this runbook was written: user accounts moved out of the Admin
|
||||||
|
> Console into `users.html` when the **Project Super User** role was added, so that
|
||||||
|
> a project admin can create accounts on their own job. If you are deploying a build
|
||||||
|
> from before that change, read this step as "Admin Console → the user table".
|
||||||
|
3. Open **Admin Console** (admin account required). Two new cards are present and
|
||||||
|
load: **Projects**, and **Default members on new projects**. Both should list rows,
|
||||||
|
not an error.
|
||||||
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
|
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** —
|
(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
|
it should reappear marked `archived`. Click **Unarchive** to put it back. That
|
||||||
|
|||||||
@@ -146,20 +146,64 @@ docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from proje
|
|||||||
|
|
||||||
### Automated smoke test
|
### Automated smoke test
|
||||||
|
|
||||||
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in →
|
||||||
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
project → SOP → Work Package → the AWP issue gate → status → metrics → comments →
|
||||||
cleanup). Stdlib only — no pip/jq.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export WP_SMOKE_USER=<admin-account>
|
||||||
|
export WP_SMOKE_PASSWORD='…'
|
||||||
|
|
||||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||||
|
|
||||||
# Or from inside the api container (hits FastAPI directly):
|
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
|
||||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD 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.
|
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||||
|
# --user / --password override the environment if you'd rather be explicit.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
|
||||||
|
could not start (host unreachable, or credentials missing or rejected). The last is
|
||||||
|
kept separate on purpose — "I could not test this" is a different answer from "this is
|
||||||
|
broken", and automation should not treat them alike.
|
||||||
|
|
||||||
|
### Front-end browser check
|
||||||
|
|
||||||
|
`tests/browser_check.py` is the other half: the smoke test proves the API works, this
|
||||||
|
proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools
|
||||||
|
Protocol and asserts what only a browser can settle — that each page boots without a
|
||||||
|
JavaScript error, that the role-dependent renderings are right, and that the layout
|
||||||
|
rules the console pages depend on are actually in effect.
|
||||||
|
|
||||||
|
Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects,
|
||||||
|
an admin, a Project Super User, a plain member, and accounts positioned to exercise
|
||||||
|
in-scope / out-of-scope / invisible), starts its own server on a free port, and tears
|
||||||
|
all of it down. **Your real database is never touched.** Stdlib only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/browser_check.py # everything, ~71 checks
|
||||||
|
python tests/browser_check.py --keep-server # leave it up to poke at by hand
|
||||||
|
WP_BROWSER=/path/to/chrome python tests/browser_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Same exit codes as the smoke test, including **2** for "no browser found" — a missing
|
||||||
|
browser is not a failing app.
|
||||||
|
|
||||||
|
Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css`
|
||||||
|
or `html/admin.js`. It is the check that would have caught a rule lost while
|
||||||
|
`console.css` was being extracted out of `admin.html`, which is a silent, whole-page
|
||||||
|
regression that no server-side test can see.
|
||||||
|
|
||||||
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
||||||
working. It cleans up after itself (the test project and its SOP/WPs are
|
working. It cleans up after itself (the test project and its SOP/WPs are
|
||||||
deleted via cascade); a single tagged test comment remains (there's no comment
|
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||||
@@ -332,18 +376,45 @@ console's **Reset password** button).
|
|||||||
|
|
||||||
`User.role` is the **permissions** role; `User.project_role` is the person's **job
|
`User.role` is the **permissions** role; `User.project_role` is the person's **job
|
||||||
function** on the project (Project Manager, Superintendent, …) and grants nothing.
|
function** on the project (Project Manager, Superintendent, …) and grants nothing.
|
||||||
Both are set in the Admin console's user table.
|
Both are set on the **User Directory** page (`users.html`) — not the Admin Console,
|
||||||
|
which no longer manages accounts.
|
||||||
|
|
||||||
| Role | May do |
|
| Role | May do |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `admin` | User administration, app settings, and every project |
|
| `admin` | User administration everywhere, app settings, and every project |
|
||||||
|
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
|
||||||
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
|
| `project_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 |
|
| `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
|
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 this change
|
only hides controls to avoid dead-end clicks. Accounts created before roles existed
|
||||||
carried the role `user`, which the migration rewrites to `project_user`.
|
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
|
## Feature flags
|
||||||
|
|
||||||
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
|
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ 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
|
victim's page and can call any API the victim can, including
|
||||||
`POST /api/auth/users/{id}/role`.
|
`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:
|
Two controls that look like they would contain this do not:
|
||||||
|
|
||||||
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
|
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
|
||||||
@@ -83,6 +91,12 @@ malicious one.
|
|||||||
suite is exposed outside the corporate network, accounts are issued to
|
suite is exposed outside the corporate network, accounts are issued to
|
||||||
subcontractors or clients, or self-registration is added.
|
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
|
### What closing it takes
|
||||||
|
|
||||||
Small — roughly half an hour. The helper already exists; it was added to the SOP
|
Small — roughly half an hour. The helper already exists; it was added to the SOP
|
||||||
@@ -102,8 +116,9 @@ 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
|
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
|
||||||
clicks and the name must display intact.
|
clicks and the name must display intact.
|
||||||
|
|
||||||
The equivalent fix on the admin side is `jsq()` in `html/admin.js` — same ordering,
|
The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved
|
||||||
same reasoning, worth reading before starting.
|
out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) —
|
||||||
|
same ordering, same reasoning, worth reading before starting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,22 +35,12 @@ services:
|
|||||||
# default and enabled from the Admin console; this is the only email
|
# default and enabled from the Admin console; this is the only email
|
||||||
# secret and it is never stored in the DB. Leave unset until configured.
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
|
||||||
# which backs the asset picker in the work package creator. Leave unset and
|
|
||||||
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
|
||||||
# Use a db_datareader login: the app only ever SELECTs.
|
|
||||||
MICRON_DB_URL: ${MICRON_DB_URL:-}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy # waits for postgres to accept connections
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
# Reaching the Micron database 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:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -108,12 +98,4 @@ networks:
|
|||||||
name: proxy
|
name: proxy
|
||||||
external: true
|
external: true
|
||||||
internal:
|
internal:
|
||||||
internal: true # no route off the host for anything on this network alone
|
internal: true # no outbound internet access from api/db
|
||||||
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
|
|
||||||
244
html/admin.html
244
html/admin.html
@@ -13,198 +13,45 @@
|
|||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
<link rel="stylesheet" href="wp-chrome.css">
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
|
<link rel="stylesheet" href="console.css">
|
||||||
|
<link rel="stylesheet" href="wp-sidenav.css">
|
||||||
<style>
|
<style>
|
||||||
/* ══ TOKENS ══════════════════════════════════════════════════════════════
|
/* Page-specific only — the tokens, cards, controls, tables, banners and modal
|
||||||
The console is the only page in the suite that is mostly dense tables, so
|
live in console.css, shared with the User Directory. What stays here is what
|
||||||
it carries its own sheet. The palette, the square corners and the type are
|
only this page has: the per-card scroll boxes admin.js paints tables into, the
|
||||||
Carbon's — the same ones theme-light.css sets — so it still reads as one
|
column exceptions for those tables, and the admins-only notice.
|
||||||
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); }
|
|
||||||
|
|
||||||
/* ══ PAGE ════════════════════════════════════════════════════════════════
|
These are addressed by ID because admin.js emits the tables without per-cell
|
||||||
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
classes. */
|
||||||
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 ═══════════════════════════════════════════════════════════════ */
|
/* These start life as empty divs that admin.js fills on demand, so they only earn
|
||||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
|
their gap once they are actually saying something. */
|
||||||
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
|
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||||
/* 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; }
|
|
||||||
|
|
||||||
/* ══ CONTROLS ════════════════════════════════════════════════════════════
|
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||||
Every button, input and select in a form row is exactly --ctl tall, so a
|
sticky header always has something to stick to rather than sliding up behind
|
||||||
toolbar is one clean band instead of a ragged one. */
|
the app bar. Same rule as console.css's .tscroll. */
|
||||||
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
|
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||||
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; }
|
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
|
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||||
so one table never ends up with two scrollbars. */
|
one table never ends up with two scrollbars. */
|
||||||
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
|
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||||
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
|
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||||
#defmem-table:has(.tscroll){
|
|
||||||
overflow:visible; max-height:none; }
|
overflow:visible; max-height:none; }
|
||||||
|
|
||||||
/* Column exceptions, addressed by card because admin.js emits these tables
|
/* Comment text and audit detail are the two columns you are actually here to
|
||||||
without per-cell classes. Email is the one user cell long enough to stretch
|
read, so they wrap inside a sane width instead of truncating. */
|
||||||
a row, so it truncates; comment text and audit detail are the two columns
|
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||||
you are actually here to read, so they wrap inside a sane width instead. */
|
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||||
#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. */
|
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
|
||||||
#admin-denied .card{ max-width:560px; }
|
#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){
|
@media (max-width:900px){
|
||||||
.wrap{ padding:var(--s4) var(--s3) 60px; }
|
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||||
.card{ padding:var(--s3) var(--s4) var(--s4); }
|
max-height:none; }
|
||||||
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -241,29 +88,14 @@
|
|||||||
<div class="banner" id="health-banner">—</div>
|
<div class="banner" id="health-banner">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- USER ADMINISTRATION -->
|
<!-- USER ADMINISTRATION — moved out to its own page -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>User administration</h2>
|
<h2>User accounts</h2>
|
||||||
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
<div class="sub">Login accounts, permissions and project access now live on the
|
||||||
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
|
<strong>User Directory</strong> page. They moved because user administration is no longer
|
||||||
<div id="users-banner"></div>
|
admin-only: a <strong>Project Super User</strong> creates and manages the accounts on the
|
||||||
<div id="users-table"></div>
|
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 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>
|
</div>
|
||||||
|
|
||||||
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
|
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
|
||||||
@@ -286,8 +118,10 @@
|
|||||||
<h2>Default members on new projects</h2>
|
<h2>Default members on new projects</h2>
|
||||||
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
|
<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
|
with the role chosen here. It does not touch projects that already exist — for those, use
|
||||||
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
|
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
|
||||||
to set: they already reach every project.</div>
|
Administrators are listed with nothing to set: they already reach every project. This card stays
|
||||||
|
in the console because it is a rule about <em>every</em> future project, including the ones a
|
||||||
|
Project Super User has no part in — so only an admin sets it.</div>
|
||||||
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
|
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
|
||||||
<div id="defmem-banner"></div>
|
<div id="defmem-banner"></div>
|
||||||
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
|
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
|
||||||
@@ -377,7 +211,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="console-util.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
|
<script src="wp-sidenav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
363
html/admin.js
363
html/admin.js
@@ -4,14 +4,20 @@
|
|||||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||||
already requires a login (redirecting to login.html otherwise) and publishes
|
already requires a login (redirecting to login.html otherwise) and publishes
|
||||||
window.WP_USER; here we show the console only when that user is an admin, and
|
window.WP_USER; here we show the console only when that user is an admin, and
|
||||||
show an "Admins only" notice otherwise. Every user-management API is also
|
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. */
|
enforced as admin-only server-side, so this is a real gate, not obfuscation.
|
||||||
|
|
||||||
|
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
|
||||||
|
User role arrived: administering users is no longer an admin-only act, so the
|
||||||
|
page that does it can't be behind an admins-only gate. What stays here is what
|
||||||
|
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
|
||||||
|
project archiving, and the default-member rule for future projects.
|
||||||
|
|
||||||
|
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||||
|
|
||||||
function reveal(){
|
function reveal(){
|
||||||
document.getElementById('admin-main').style.display='';
|
document.getElementById('admin-main').style.display='';
|
||||||
fillProjectRoleOptions();
|
|
||||||
checkHealth();
|
checkHealth();
|
||||||
loadUsers();
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
loadDefaultMembers();
|
loadDefaultMembers();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -24,18 +30,6 @@ function showDenied(){
|
|||||||
document.getElementById('admin-denied').style.display='';
|
document.getElementById('admin-denied').style.display='';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── api helper ──────────────────────────────────────────────────────────────
|
|
||||||
async function api(method, path, body){
|
|
||||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
|
||||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
|
||||||
try {
|
|
||||||
const r = await fetch(path, opt);
|
|
||||||
const t = await r.text();
|
|
||||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
|
||||||
return { status:r.status, json };
|
|
||||||
} catch(e){ return { status:0, json:String(e) }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||||
async function checkHealth(){
|
async function checkHealth(){
|
||||||
const b = document.getElementById('health-banner');
|
const b = document.getElementById('health-banner');
|
||||||
@@ -165,317 +159,6 @@ async function cleanDemo(){
|
|||||||
snapshot();
|
snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── user administration ────────────────────────────────────────────────────────
|
|
||||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
||||||
|
|
||||||
// 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 can’t 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 can’t 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 & 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 ───────────────────────────────────────────────
|
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||||
// Archiving is the answer to "this job is over but I can't throw the data away".
|
// 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
|
// An archived project disappears from every picker, switcher and search in the
|
||||||
@@ -615,8 +298,8 @@ async function loadDefaultMembers(){
|
|||||||
renderDefaultMembers();
|
renderDefaultMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same idea as projRowToggled() — the role only matters while the tick is on — but
|
// The role only matters while the tick is on. The select sits in a sibling <td>, so
|
||||||
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
|
// the lookup is scoped to the row.
|
||||||
function defMemToggled(cb){
|
function defMemToggled(cb){
|
||||||
const row = cb.closest('tr');
|
const row = cb.closest('tr');
|
||||||
const sel = row && row.querySelector('select');
|
const sel = row && row.querySelector('select');
|
||||||
@@ -634,8 +317,7 @@ function renderDefaultMembers(){
|
|||||||
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
||||||
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
|
(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>';
|
'<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'){
|
if(role === 'admin'){
|
||||||
return '<tr>'+who+
|
return '<tr>'+who+
|
||||||
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||||
@@ -645,8 +327,15 @@ function renderDefaultMembers(){
|
|||||||
}
|
}
|
||||||
const on = !!u.auto_add_projects;
|
const on = !!u.auto_add_projects;
|
||||||
const cur = u.auto_add_role || '';
|
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+
|
return '<tr>'+who+
|
||||||
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||||
'<td><label class="chk">'+
|
'<td><label class="chk">'+
|
||||||
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
||||||
' title="Add this user to every project created from now on"'+
|
' title="Add this user to every project created from now on"'+
|
||||||
@@ -654,11 +343,7 @@ function renderDefaultMembers(){
|
|||||||
'</label></td>'+
|
'</label></td>'+
|
||||||
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
||||||
' title="The role this user gets on those projects"'+
|
' title="The role this user gets on those projects"'+
|
||||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
|
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
|
||||||
'<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>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
@@ -669,8 +354,8 @@ function renderDefaultMembers(){
|
|||||||
'<th title="Their role on those projects">Role on those projects</th>'+
|
'<th title="Their role on those projects">Role on those projects</th>'+
|
||||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||||
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
||||||
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
|
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
|
||||||
'that already exists.</div>';
|
'Directory</a> to add someone to a project that already exists.</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves on every tick and every dropdown change — there is no Save button, so a
|
// Saves on every tick and every dropdown change — there is no Save button, so a
|
||||||
|
|||||||
@@ -124,14 +124,24 @@
|
|||||||
return r === 'user' ? 'project_user' : r;
|
return r === 'user' ? 'project_user' : r;
|
||||||
};
|
};
|
||||||
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
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 () {
|
window.wpIsProjectAdmin = function () {
|
||||||
var r = window.wpRole();
|
var r = window.wpRole();
|
||||||
return r === 'admin' || r === 'project_admin';
|
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
|
||||||
};
|
};
|
||||||
// Deleting a work package, deleting a project, and editing a completed SOP are
|
// Deleting a work package, deleting a project, and editing a completed SOP are
|
||||||
// all Project Admin actions (see server require_project_admin).
|
// all Project Admin actions (see server require_project_admin).
|
||||||
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
||||||
window.wpCanEditCompletedSOP = 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 ──────────────────────────────────────────────────────
|
// ── app feature flags ──────────────────────────────────────────────────────
|
||||||
// Cached per page load. Pages that must know before rendering should await
|
// Cached per page load. Pages that must know before rendering should await
|
||||||
@@ -185,6 +195,11 @@
|
|||||||
wrap.appendChild(who);
|
wrap.appendChild(who);
|
||||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||||
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
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
|
// 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.
|
// the check happens at click time rather than once, up front.
|
||||||
wrap.appendChild(sep());
|
wrap.appendChild(sep());
|
||||||
|
|||||||
89
html/console-util.js
Normal file
89
html/console-util.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/* 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
191
html/console.css
Normal file
191
html/console.css
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/* 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%; }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
<link rel="stylesheet" href="wp-chrome.css">
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
|
<link rel="stylesheet" href="wp-sidenav.css">
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { -webkit-text-size-adjust: 100%; }
|
body { -webkit-text-size-adjust: 100%; }
|
||||||
@@ -87,5 +88,6 @@
|
|||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
<script src="field.js"></script>
|
<script src="field.js"></script>
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
|
<script src="wp-sidenav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
<h4>Key fields</h4>
|
<h4>Key fields</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||||
<li><strong>Assets</strong> — 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>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||||
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||||
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||||
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||||
@@ -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>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||||
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||||
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||||
<tr><td><strong>Asset</strong></td><td>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>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||||
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||||
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||||
</table>` },
|
</table>` },
|
||||||
|
|||||||
@@ -373,6 +373,13 @@
|
|||||||
<button class="card-button" id="card-field-btn">Open Field View</button>
|
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- COMMENTS SECTION -->
|
<!-- COMMENTS SECTION -->
|
||||||
|
|||||||
@@ -14,15 +14,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||||
// instead of serving a half-old shell from the previous cache.
|
// instead of serving a half-old shell from the previous cache.
|
||||||
const CACHE = 'wp-suite-shell-v5';
|
const CACHE = 'wp-suite-shell-v6';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||||
'/field.html', '/login.html', '/admin.html',
|
'/field.html', '/login.html', '/admin.html', '/users.html',
|
||||||
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||||
'/wp-chrome.css',
|
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
|
||||||
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||||
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||||
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
|
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
|
||||||
|
'/console-util.js', '/admin.js', '/users.js',
|
||||||
'/prime-controls-logo.jpg', '/favicon.ico',
|
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||||
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||||
];
|
];
|
||||||
|
|||||||
106
html/users.html
Normal file
106
html/users.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!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>
|
||||||
473
html/users.js
Normal file
473
html/users.js
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
/* 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 don’t 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 person’s 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 person’s 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 don’t 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 project’s 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 don’t 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 & 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();
|
||||||
@@ -105,7 +105,7 @@ function applySOP(){
|
|||||||
applyKind();
|
applyKind();
|
||||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||||
if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
|
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||||||
updateNumber(); updateReleaseBanner();
|
updateNumber(); updateReleaseBanner();
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ function applyKindVisibility(){
|
|||||||
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||||||
show('kind-row', bimProj);
|
show('kind-row', bimProj);
|
||||||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
show('bim-card', ewp); // model area / clash + IFF # / scan
|
||||||
show('asset-card', !ewp); // Micron DB assets
|
show('asset-card', !ewp); // controls.dev assets
|
||||||
show('material-card', !ewp); // bill of materials
|
show('material-card', !ewp); // bill of materials
|
||||||
show('mimo-card', !ewp); // kitting / MIMO
|
show('mimo-card', !ewp); // kitting / MIMO
|
||||||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
||||||
@@ -807,344 +807,20 @@ 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);
|
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 (Micron asset catalog) ────────────────────────────────────────────
|
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
||||||
// Assets are picked from the Micron asset catalog, a SQL Server database this app
|
// Interim: assets are linked manually back to controls.dev. A future direct
|
||||||
// reads through /api/assets. The lookup is strictly read-only — picking an asset
|
// integration will let the user pick them from a list instead of pasting links.
|
||||||
// never writes to the catalog, and there is no endpoint that could.
|
|
||||||
//
|
|
||||||
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
|
|
||||||
// neither may block someone from writing a work package: in both cases the picker
|
|
||||||
// says so and manual entry carries on. Manually entered assets are marked
|
|
||||||
// source:'manual' so it stays visible which rows the catalog vouches for.
|
|
||||||
// The whole catalog is fetched once when the page loads and searched in memory —
|
|
||||||
// it is slow-moving reference data, so a request per keystroke would buy nothing
|
|
||||||
// and cost latency on every one.
|
|
||||||
let assetCatalog = []; // the full catalog, loaded once
|
|
||||||
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
|
|
||||||
let assetCatalogState = 'loading'; // loading | ready | absent | error
|
|
||||||
let assetResults = []; // current matches; the result list indexes into this
|
|
||||||
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
|
|
||||||
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
|
|
||||||
|
|
||||||
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
|
|
||||||
function assetAlreadyAdded(tag){
|
|
||||||
const k = String(tag||'').trim().toLowerCase();
|
|
||||||
return pkgAssets.some(a => assetKey(a) === k && k);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAssets(){
|
function buildAssets(){
|
||||||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||||||
if(!pkgAssets.length){
|
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||||||
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>`;
|
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||||||
return;
|
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||||||
}
|
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||||||
pkgAssets.forEach((a,i)=>{
|
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||||||
const tr=document.createElement('tr');
|
tb.appendChild(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 isn’t in the Micron DB yet.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
box.innerHTML = assetResults.map((tag,ix) => {
|
|
||||||
const on = assetAlreadyAdded(tag);
|
|
||||||
return `<button type="button" class="asset-result${on?' is-added':''}" ${on?'disabled':''} data-asset-ix="${ix}">
|
|
||||||
<span class="asset-result-tag">${esc(tag)}</span>
|
|
||||||
<span class="asset-result-add">${on ? 'added' : '+ add'}</span>
|
|
||||||
</button>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAssetResults(open){
|
|
||||||
const box = document.getElementById('asset-results');
|
|
||||||
const inp = document.getElementById('asset-search');
|
|
||||||
if(box) box.hidden = !open;
|
|
||||||
if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCatalogAsset(ix){
|
|
||||||
const tag = assetResults[ix]; if(!tag) return;
|
|
||||||
if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
|
|
||||||
pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
|
|
||||||
buildAssets();
|
|
||||||
// Mark just this row instead of re-rendering the list: the results stay open
|
|
||||||
// for the next pick, the scroll position holds, and the clicked element is
|
|
||||||
// never detached mid-click (see the composedPath note in initAssetPicker).
|
|
||||||
markAssetResultAdded(ix);
|
|
||||||
const box = document.getElementById('asset-search');
|
|
||||||
if(box) box.focus(); // keep typing straight into the next search
|
|
||||||
track('asset_added',{source:'catalog'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAssetResultAdded(ix){
|
|
||||||
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
|
|
||||||
if(!row) return;
|
|
||||||
row.classList.add('is-added');
|
|
||||||
row.disabled = true;
|
|
||||||
const label = row.querySelector('.asset-result-add');
|
|
||||||
if(label) label.textContent = 'added';
|
|
||||||
}
|
}
|
||||||
|
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
|
||||||
|
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||||
|
|
||||||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||||||
function buildAttach(){
|
function buildAttach(){
|
||||||
@@ -1513,8 +1189,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.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>`:''}
|
${(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>`;
|
</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>`;
|
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></tr>`); h+=`</tbody></table>`; }
|
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>`; }
|
||||||
let scopeHtml;
|
let scopeHtml;
|
||||||
if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections
|
if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections
|
||||||
scopeHtml = Object.keys(pkg.scope).map(d=>{
|
scopeHtml = Object.keys(pkg.scope).map(d=>{
|
||||||
@@ -2012,7 +1688,7 @@ function loadPackageIntoForm(p){
|
|||||||
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
||||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||||
// collections
|
// collections
|
||||||
pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
|
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
|
||||||
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||||
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
||||||
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
||||||
@@ -2069,7 +1745,7 @@ function newPackage(){
|
|||||||
setRadio('status','Draft');
|
setRadio('status','Draft');
|
||||||
numberDims={}; buildNumberDims();
|
numberDims={}; buildNumberDims();
|
||||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||||
pkgAssets=[]; buildAssets();
|
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
|
||||||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||||
pkgWorkSteps=['']; buildWorkSteps();
|
pkgWorkSteps=['']; buildWorkSteps();
|
||||||
@@ -2435,7 +2111,6 @@ function bootData(){
|
|||||||
bootSOP();
|
bootSOP();
|
||||||
setRadio('status','Draft');
|
setRadio('status','Draft');
|
||||||
loadMembers();
|
loadMembers();
|
||||||
initAssetPicker();
|
|
||||||
initWpNavDrawer();
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
|
|||||||
@@ -181,23 +181,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ASSETS (Micron asset catalog) -->
|
<!-- ASSETS (controls.dev) -->
|
||||||
<div class="card" id="asset-card">
|
<div class="card" id="asset-card">
|
||||||
<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="sub-heading">Assets</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="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="asset-pick" id="asset-pick">
|
<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>
|
||||||
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
|
<button class="add-btn" onclick="addAsset()">+ Add Asset</button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- DISCIPLINES -->
|
<!-- DISCIPLINES -->
|
||||||
|
|||||||
@@ -645,38 +645,6 @@
|
|||||||
border:1px solid var(--border); border-radius:3px; }
|
border:1px solid var(--border); border-radius:3px; }
|
||||||
.pp-free .field-hint { margin-top:4px; }
|
.pp-free .field-hint { margin-top:4px; }
|
||||||
|
|
||||||
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
|
|
||||||
A search box over a read-only catalog. Results drop below the input and are
|
|
||||||
added to the table as rows; the catalog itself is never written to. */
|
|
||||||
.asset-pick { position:relative; margin-bottom:10px; }
|
|
||||||
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
|
|
||||||
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
|
|
||||||
box-sizing:border-box; }
|
|
||||||
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
|
|
||||||
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
|
|
||||||
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
|
|
||||||
max-height:320px; overflow-y:auto; background:var(--surface);
|
|
||||||
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
|
|
||||||
box-shadow:0 8px 24px rgba(20,30,50,.18); }
|
|
||||||
.asset-results[hidden] { display:none; }
|
|
||||||
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
|
|
||||||
width:100%; text-align:left; background:none; border:0;
|
|
||||||
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
|
|
||||||
.asset-result:hover:not(:disabled) { background:var(--surface2); }
|
|
||||||
.asset-result:disabled { cursor:default; opacity:.55; }
|
|
||||||
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
|
|
||||||
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
|
|
||||||
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
|
|
||||||
/* Marks rows the catalog vouches for, so a manually typed asset is never
|
|
||||||
mistaken for a looked-up one. */
|
|
||||||
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
|
|
||||||
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
|
|
||||||
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
|
|
||||||
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
|
|
||||||
.asset-tag { font-weight:600; }
|
|
||||||
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
|
|
||||||
|
|
||||||
/* Critical constraint marker (from the SOP) */
|
/* Critical constraint marker (from the SOP) */
|
||||||
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
|
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
|
||||||
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||||
|
|||||||
90
html/wp-sidenav.css
Normal file
90
html/wp-sidenav.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/* 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; }
|
||||||
221
html/wp-sidenav.js
Normal file
221
html/wp-sidenav.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -30,25 +30,3 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
|||||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||||
# on and SMTP is configured.
|
# on and SMTP is configured.
|
||||||
# SMTP_PASSWORD=your-smtp-app-password
|
# SMTP_PASSWORD=your-smtp-app-password
|
||||||
|
|
||||||
# ── Micron asset catalog (optional) ───────────────────────────────────────────
|
|
||||||
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
|
|
||||||
# app only ever runs the single SELECT in server/assets_db.py, so give it a
|
|
||||||
# db_datareader login and nothing more.
|
|
||||||
#
|
|
||||||
# Leave this unset and the suite works normally — the picker reports that no
|
|
||||||
# catalog is configured and people type asset tags in by hand.
|
|
||||||
#
|
|
||||||
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
|
|
||||||
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
|
|
||||||
#
|
|
||||||
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
|
|
||||||
# and install the Microsoft ODBC driver in the image:
|
|
||||||
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
|
||||||
#
|
|
||||||
# Two things to check when the picker says the catalog is unreachable:
|
|
||||||
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
|
|
||||||
# Micron schema — that one constant is the whole schema contract.
|
|
||||||
# 2. The api container is on the `outbound` network in docker-compose.yml. The
|
|
||||||
# `internal` network has no default gateway, which blocks the VPN as well as
|
|
||||||
# the internet.
|
|
||||||
|
|||||||
514
server/app.py
514
server/app.py
@@ -25,7 +25,7 @@ from sqlalchemy import select, delete, func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .db import Base, engine, get_db
|
from .db import Base, engine, get_db
|
||||||
from . import models, auth, notify, assets_db
|
from . import models, auth, notify
|
||||||
|
|
||||||
# Schema management:
|
# Schema management:
|
||||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||||
@@ -163,7 +163,9 @@ def require_project_admin(db: Session, user: "models.User", project_id: Optional
|
|||||||
project, and editing a SOP that has already been completed. Requires project
|
project, and editing a SOP that has already been completed. Requires project
|
||||||
access AND Project Admin *on that project*."""
|
access AND Project Admin *on that project*."""
|
||||||
require_project_access(db, user, project_id)
|
require_project_access(db, user, project_id)
|
||||||
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
|
if effective_role(db, user, project_id) not in (
|
||||||
|
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN,
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=f"{what} requires the Project Admin role on this project",
|
detail=f"{what} requires the Project Admin role on this project",
|
||||||
@@ -185,7 +187,19 @@ def require_project_writable(db: Session, user_or_none, project_id: Optional[str
|
|||||||
if not project_id:
|
if not project_id:
|
||||||
return
|
return
|
||||||
proj = db.get(models.Project, project_id)
|
proj = db.get(models.Project, project_id)
|
||||||
if proj is not None and proj.archived_at is not None:
|
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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=(f"{what} — this project is archived (read-only). An administrator "
|
detail=(f"{what} — this project is archived (read-only). An administrator "
|
||||||
@@ -247,6 +261,163 @@ def add_default_members(db: Session, project_id: str, actor) -> list[str]:
|
|||||||
return added
|
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 ────────────────────────────────────────────────────────────────
|
# ── Audit trail ────────────────────────────────────────────────────────────────
|
||||||
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
|
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:
|
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
|
||||||
@@ -392,6 +563,10 @@ class NewUserIn(BaseModel):
|
|||||||
email: str = ""
|
email: str = ""
|
||||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||||
project_role: str = "" # job function on the project (no permissions)
|
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):
|
class ProjectRoleIn(BaseModel):
|
||||||
@@ -603,8 +778,11 @@ def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@app.get("/api/auth/me")
|
@app.get("/api/auth/me")
|
||||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||||
"""Who is logged in. The frontend guard calls this on every page load."""
|
"""Who is logged in. The frontend guard calls this on every page load.
|
||||||
return {"user": user.to_dict()}
|
|
||||||
|
`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)}}
|
||||||
|
|
||||||
|
|
||||||
# ── Display preferences (self-service) ─────────────────────────────────────────
|
# ── Display preferences (self-service) ─────────────────────────────────────────
|
||||||
@@ -667,22 +845,122 @@ def change_password(body: PasswordChangeIn, request: Request, response: Response
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ── User administration (admin only) ────────────────────────────────────────────
|
# ── 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/users")
|
@app.get("/api/auth/users")
|
||||||
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def list_users(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
"""The user directory, scoped to the caller. An app admin sees every account; a
|
||||||
return [u.to_dict() for u in rows]
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users")
|
@app.post("/api/auth/users")
|
||||||
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
problem = auth.password_problem(body.password, body.username, body.email)
|
problem = auth.password_problem(body.password, body.username, body.email)
|
||||||
if problem:
|
if problem:
|
||||||
raise HTTPException(status_code=400, detail=problem)
|
raise HTTPException(status_code=400, detail=problem)
|
||||||
if body.role not in auth.ROLES:
|
allowed = grantable_roles(actor)
|
||||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
if body.role not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||||
if auth.find_user(db, body.username):
|
if auth.find_user(db, body.username):
|
||||||
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
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(
|
u = models.User(
|
||||||
id=gen_id("user"),
|
id=gen_id("user"),
|
||||||
username=body.username.strip(),
|
username=body.username.strip(),
|
||||||
@@ -693,54 +971,75 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
|||||||
project_role=body.project_role.strip()[:120],
|
project_role=body.project_role.strip()[:120],
|
||||||
)
|
)
|
||||||
db.add(u)
|
db.add(u)
|
||||||
log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
|
# Flush the account before adding memberships that point at it. The ORM decides
|
||||||
detail={"role": u.role, "project_role": u.project_role})
|
# 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"})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(u)
|
db.refresh(u)
|
||||||
return u.to_dict()
|
return directory_entry(db, u, actor)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users/{user_id}/password")
|
@app.post("/api/auth/users/{user_id}/password")
|
||||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
u = db.get(models.User, user_id)
|
u = load_target_user(db, user_id)
|
||||||
if not u:
|
require_see_user(db, actor, u)
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
require_manage_user(db, actor, u)
|
||||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||||
if problem:
|
if problem:
|
||||||
raise HTTPException(status_code=400, detail=problem)
|
raise HTTPException(status_code=400, detail=problem)
|
||||||
u.password_hash = auth.hash_password(body.new_password)
|
u.password_hash = auth.hash_password(body.new_password)
|
||||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
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()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users/{user_id}/active")
|
@app.post("/api/auth/users/{user_id}/active")
|
||||||
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
u = db.get(models.User, user_id)
|
u = load_target_user(db, user_id)
|
||||||
if not u:
|
require_see_user(db, actor, u)
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
require_manage_user(db, actor, u)
|
||||||
if u.id == admin.id and not body.is_active:
|
if u.id == actor.id and not body.is_active:
|
||||||
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||||
u.is_active = body.is_active
|
u.is_active = body.is_active
|
||||||
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
log_event(db, actor, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
||||||
summary=u.username, detail={"is_active": bool(body.is_active)})
|
summary=u.username, detail={"is_active": bool(body.is_active)})
|
||||||
db.commit()
|
db.commit()
|
||||||
return u.to_dict()
|
return directory_entry(db, u, actor)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users/{user_id}/role")
|
@app.post("/api/auth/users/{user_id}/role")
|
||||||
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
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 (admin / project_admin / project_user).
|
"""Change a user's PERMISSIONS role. Their job function on the project is separate
|
||||||
Their job function on the project is separate — see set_user_project_role.
|
— see set_user_project_role.
|
||||||
|
|
||||||
Guards: you can't change your own role (avoids self-lockout), and the last
|
Guards: you can't change your own role (avoids self-lockout), the last remaining
|
||||||
remaining admin can't be demoted (keeps the app manageable)."""
|
admin can't be demoted (keeps the app manageable), and a super user may only hand
|
||||||
if body.role not in auth.ROLES:
|
out the roles in `grantable_roles` — never admin or another super user."""
|
||||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
allowed = grantable_roles(actor)
|
||||||
u = db.get(models.User, user_id)
|
if body.role not in allowed:
|
||||||
if not u:
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
u = load_target_user(db, user_id)
|
||||||
if u.id == admin.id:
|
require_see_user(db, actor, u)
|
||||||
|
require_manage_user(db, actor, u)
|
||||||
|
if u.id == actor.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot change your own role")
|
raise HTTPException(status_code=400, detail="You cannot change your own role")
|
||||||
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
|
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
|
||||||
other_admins = db.scalars(
|
other_admins = db.scalars(
|
||||||
@@ -763,27 +1062,27 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
|
|||||||
u.auto_add_projects = False
|
u.auto_add_projects = False
|
||||||
u.auto_add_role = ""
|
u.auto_add_role = ""
|
||||||
detail["auto_add_cleared"] = True
|
detail["auto_add_cleared"] = True
|
||||||
log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail)
|
log_event(db, actor, "role_changed", "user", u.id, summary=u.username, detail=detail)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(u)
|
db.refresh(u)
|
||||||
return u.to_dict()
|
return directory_entry(db, u, actor)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users/{user_id}/project-role")
|
@app.post("/api/auth/users/{user_id}/project-role")
|
||||||
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def set_user_project_role(user_id: str, body: ProjectRoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
"""Set a user's job function on the project (Project Manager, Superintendent,
|
"""Set a user's job function on the project (Project Manager, Superintendent,
|
||||||
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
|
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
|
||||||
and notification routing read, so it's worth keeping accurate."""
|
and notification routing read, so it's worth keeping accurate."""
|
||||||
u = db.get(models.User, user_id)
|
u = load_target_user(db, user_id)
|
||||||
if not u:
|
require_see_user(db, actor, u)
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
require_manage_user(db, actor, u)
|
||||||
old = u.project_role or ""
|
old = u.project_role or ""
|
||||||
u.project_role = (body.project_role or "").strip()[:120]
|
u.project_role = (body.project_role or "").strip()[:120]
|
||||||
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
|
log_event(db, actor, "project_role_changed", "user", u.id, summary=u.username,
|
||||||
detail={"from": old, "to": u.project_role})
|
detail={"from": old, "to": u.project_role})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(u)
|
db.refresh(u)
|
||||||
return u.to_dict()
|
return directory_entry(db, u, actor)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/users/{user_id}/auto-add")
|
@app.post("/api/auth/users/{user_id}/auto-add")
|
||||||
@@ -791,13 +1090,17 @@ 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
|
"""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
|
an optional role on those projects. It only touches NEW projects — existing
|
||||||
assignments stay under the admin's hand (see set_user_projects), because
|
assignments stay under the admin's hand (see set_user_projects), because
|
||||||
back-filling everyone onto historical jobs is never what this flag means."""
|
back-filling everyone onto historical jobs is never what this flag means.
|
||||||
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
|
||||||
|
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
|
||||||
role = (body.role or "").strip()
|
role = (body.role or "").strip()
|
||||||
if role not in allowed:
|
if role not in allowed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}",
|
detail=f"role must be '' (inherit) or one of {', '.join(auth.PROJECT_SCOPED_ROLES)}",
|
||||||
)
|
)
|
||||||
u = db.get(models.User, user_id)
|
u = db.get(models.User, user_id)
|
||||||
if not u:
|
if not u:
|
||||||
@@ -814,57 +1117,97 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/api/auth/users/{user_id}")
|
@app.delete("/api/auth/users/{user_id}")
|
||||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def delete_user(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
u = db.get(models.User, user_id)
|
u = load_target_user(db, user_id)
|
||||||
if not u:
|
require_see_user(db, actor, u)
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
require_manage_user(db, actor, u)
|
||||||
if u.id == admin.id:
|
if u.id == actor.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
log_event(db, actor, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||||
db.delete(u)
|
db.delete(u)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": user_id}
|
return {"deleted": user_id}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/users/{user_id}/projects")
|
@app.get("/api/auth/users/{user_id}/projects")
|
||||||
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
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 full project list for the
|
"""Which projects a user is assigned to, plus the project list to choose from.
|
||||||
assignment UI. (Admins implicitly access every project regardless.)"""
|
(Admins implicitly access every project regardless of what's ticked here.)
|
||||||
u = db.get(models.User, user_id)
|
|
||||||
if not u:
|
A super user is shown ONLY the projects they administer, and `other_projects` says
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)
|
||||||
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
|
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()
|
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]
|
||||||
return {
|
return {
|
||||||
"user": u.to_dict(),
|
"user": directory_entry(db, u, actor),
|
||||||
"assigned": [r.project_id for r in rows],
|
"assigned": [r.project_id for r in in_scope],
|
||||||
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
||||||
"roles": {r.project_id: (r.role or "") for r in rows},
|
"roles": {r.project_id: (r.role or "") for r in in_scope},
|
||||||
# Archived projects stay on this list on purpose — an existing assignment has
|
# 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
|
# 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.
|
# so, rather than offering a finished job as though it were live work.
|
||||||
"projects": [{"id": p.id, "name": p.name, "number": p.number,
|
"projects": [{"id": p.id, "name": p.name, "number": p.number,
|
||||||
"archived": p.archived_at is not None} for p in projects],
|
"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")
|
@app.put("/api/auth/users/{user_id}/projects")
|
||||||
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
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."""
|
"""Replace a user's project assignments with the given set.
|
||||||
u = db.get(models.User, user_id)
|
|
||||||
if not u:
|
For an app admin the given set IS the whole answer. For a super user it replaces
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
only their own slice: memberships on projects they don't administer are left
|
||||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
exactly as they were, because a payload that simply omits them would otherwise cut
|
||||||
# Only the two project-scoped roles make sense here: app admin is global, and
|
someone off from a job the caller can't even see."""
|
||||||
# anything unrecognised falls back to inheriting the account's own role.
|
u = load_target_user(db, user_id)
|
||||||
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
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)
|
||||||
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
||||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
# 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)
|
||||||
for pid in valid:
|
for pid in valid:
|
||||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
||||||
role=roles.get(pid, "")))
|
role=roles.get(pid, "")))
|
||||||
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
|
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
|
||||||
detail={"projects": len(valid),
|
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}})
|
"overrides": {p: r for p, r in roles.items() if p in valid}})
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
||||||
@@ -1810,29 +2153,6 @@ def list_comments(
|
|||||||
return [c.to_dict() for c in rows]
|
return [c.to_dict() for c in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
|
|
||||||
# Backs the asset picker in the work package creator. This is a *lookup*, not a
|
|
||||||
# resource this app owns: there is no POST, and nothing here ever writes to the
|
|
||||||
# Micron database. It is deliberately not project-scoped by the app's own access
|
|
||||||
# rules — the catalog is reference data, and any signed-in user who can build a
|
|
||||||
# work package needs to be able to name the assets it covers. Authentication is
|
|
||||||
# still required (the auth_gate middleware covers every /api/ path).
|
|
||||||
@app.get("/api/assets")
|
|
||||||
def list_assets(_user: models.User = Depends(auth.get_current_user)):
|
|
||||||
"""The whole catalog, fetched once when the creator loads. Searching happens
|
|
||||||
in the browser — there is no per-keystroke endpoint by design."""
|
|
||||||
if not assets_db.configured():
|
|
||||||
# Not an error — the suite is designed to run without Micron wired up.
|
|
||||||
# The picker reads this and switches to manual entry.
|
|
||||||
return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
|
|
||||||
try:
|
|
||||||
return {"configured": True, "assets": assets_db.load()}
|
|
||||||
except assets_db.AssetSourceError as exc:
|
|
||||||
# 503, not 500: the suite is healthy, its upstream lookup is not. The
|
|
||||||
# picker degrades to manual entry rather than blocking the package.
|
|
||||||
raise HTTPException(status_code=503, detail=str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Local dev convenience: serve the static site from this app ──────────────────
|
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||||
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
||||||
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
"""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."}
|
|
||||||
@@ -20,11 +20,21 @@ Permissions roles (`User.role`) — distinct from a person's job function on the
|
|||||||
project, which lives in `User.project_role` and grants nothing:
|
project, which lives in `User.project_role` and grants nothing:
|
||||||
• admin application administrator: user administration, app settings,
|
• admin application administrator: user administration, app settings,
|
||||||
and implicit access to every project.
|
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,
|
• project_admin within their assigned projects: may delete work packages,
|
||||||
modify a SOP after it has been completed, and delete projects.
|
modify a SOP after it has been completed, and delete projects.
|
||||||
• project_user normal member: creates and edits work packages, authors a SOP
|
• project_user normal member: creates and edits work packages, authors a SOP
|
||||||
up to completion. May NOT delete WPs or change a completed 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
|
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
|
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
|
`token_version`, which is bumped when the password changes, so a used or
|
||||||
@@ -56,14 +66,21 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
|||||||
|
|
||||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
|
ROLE_PROJECT_SUPER = "project_super_user"
|
||||||
ROLE_PROJECT_ADMIN = "project_admin"
|
ROLE_PROJECT_ADMIN = "project_admin"
|
||||||
ROLE_PROJECT_USER = "project_user"
|
ROLE_PROJECT_USER = "project_user"
|
||||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
# Ordered most- to least-privileged; the console renders dropdowns in this order.
|
||||||
|
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
ROLE_ADMIN: "Administrator",
|
ROLE_ADMIN: "Administrator",
|
||||||
|
ROLE_PROJECT_SUPER: "Project Super User",
|
||||||
ROLE_PROJECT_ADMIN: "Project Admin",
|
ROLE_PROJECT_ADMIN: "Project Admin",
|
||||||
ROLE_PROJECT_USER: "Project User",
|
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
|
# Job functions offered in the admin console. Free text underneath, so a project
|
||||||
# can use a title that isn't on this list.
|
# can use a title that isn't on this list.
|
||||||
PROJECT_ROLES = (
|
PROJECT_ROLES = (
|
||||||
@@ -90,9 +107,19 @@ def is_admin(user: "models.User") -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_project_admin(user: "models.User") -> bool:
|
def is_project_admin(user: "models.User") -> bool:
|
||||||
"""True for app admins and project admins — the two roles allowed to delete
|
"""True for the roles allowed to delete work packages and change a completed
|
||||||
work packages and change a completed SOP."""
|
SOP. A super user is a project admin with user administration on top, so it is
|
||||||
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
|
included here — never enumerate the two roles by hand."""
|
||||||
|
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
|
||||||
|
# super-user role can be held per project (ProjectMember.role), so the question needs
|
||||||
|
# membership rows to answer and lives in app.py — `is_user_manager` /
|
||||||
|
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
|
||||||
|
# same question used to exist here and silently disagreed with the scoped one, which
|
||||||
|
# locked per-project super users out of the routes they were entitled to.
|
||||||
|
|
||||||
# Password policy (shared by the API and the CLI).
|
# Password policy (shared by the API and the CLI).
|
||||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||||
@@ -297,6 +324,8 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||||
"""Look up by username, case-insensitively (also matches on email)."""
|
"""Look up by username, case-insensitively (also matches on email)."""
|
||||||
|
|||||||
21
server/db.py
21
server/db.py
@@ -12,7 +12,7 @@ Connection precedence:
|
|||||||
The schema is identical either way (SQLAlchemy handles dialect differences).
|
The schema is identical either way (SQLAlchemy handles dialect differences).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine, URL
|
from sqlalchemy import create_engine, event, URL
|
||||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
|
||||||
# Load a local .env if present (dev convenience).
|
# Load a local .env if present (dev convenience).
|
||||||
@@ -46,6 +46,25 @@ _is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
|
|||||||
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||||
|
|
||||||
|
if _is_sqlite:
|
||||||
|
# SQLite ships with foreign keys DISABLED and the pragma is per-connection, so
|
||||||
|
# without this every `ondelete="CASCADE"` in models.py is silently a no-op on a
|
||||||
|
# dev database while working correctly on Postgres. That divergence is worse than
|
||||||
|
# it sounds: deleting a project left its SOPs, work packages and membership rows
|
||||||
|
# behind as orphans pointing at an id that no longer exists, and deleting a user
|
||||||
|
# left their project_members rows — and the smoke test's cascade assertion failed
|
||||||
|
# on dev while passing in production, which is the exact failure that makes a
|
||||||
|
# smoke test worth ignoring.
|
||||||
|
#
|
||||||
|
# Registered on the engine, not a session, because the pragma has to be set on
|
||||||
|
# each new DBAPI connection as the pool creates it.
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _sqlite_enforce_foreign_keys(dbapi_connection, _record):
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,12 @@ def _prompt_password(provided: str | None, username: str = "") -> str:
|
|||||||
|
|
||||||
def cmd_create(args, role: str | None = None) -> None:
|
def cmd_create(args, role: str | None = None) -> None:
|
||||||
role = role or args.role
|
role = role or args.role
|
||||||
if role not in ("admin", "user"):
|
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||||
sys.exit("role must be 'admin' or 'user'")
|
# 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)}")
|
||||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if auth.find_user(db, args.username):
|
if auth.find_user(db, args.username):
|
||||||
@@ -70,9 +74,10 @@ def cmd_list(args) -> None:
|
|||||||
if not rows:
|
if not rows:
|
||||||
print("No users yet. Create one with: create-admin <username>")
|
print("No users yet. Create one with: create-admin <username>")
|
||||||
return
|
return
|
||||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||||
for u in rows:
|
for u in rows:
|
||||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
||||||
|
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_reset_password(args) -> None:
|
def cmd_reset_password(args) -> None:
|
||||||
@@ -113,7 +118,8 @@ def main() -> None:
|
|||||||
|
|
||||||
add_create("create-admin", "create an admin account")
|
add_create("create-admin", "create an admin account")
|
||||||
c = add_create("create", "create an account")
|
c = add_create("create", "create an account")
|
||||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
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)")
|
||||||
|
|
||||||
sub.add_parser("list", help="list all accounts")
|
sub.add_parser("list", help="list all accounts")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ The full client document for a SOP or WP is kept verbatim in a JSON `data`
|
|||||||
column, with the most-queried fields promoted to real columns for listing and
|
column, with the most-queried fields promoted to real columns for listing and
|
||||||
filtering. IDs are short strings (client- or server-generated) so the browser
|
filtering. IDs are short strings (client- or server-generated) so the browser
|
||||||
can upsert without round-tripping a sequence.
|
can upsert without round-tripping a sequence.
|
||||||
|
|
||||||
|
NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about.
|
||||||
|
Every link here is a plain column plus a ForeignKey; nothing is navigable as
|
||||||
|
`project.work_packages`. Queries are explicit selects, which suits an API that
|
||||||
|
mostly reads one scoped list at a time and never wants a lazy load firing inside
|
||||||
|
a response.
|
||||||
|
|
||||||
|
The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships,
|
||||||
|
not from ForeignKey metadata. With none declared it has no dependency edge to
|
||||||
|
follow, so if you add a parent and its child in the SAME flush it may emit the
|
||||||
|
child's INSERT first and the database will reject it. Both engines enforce foreign
|
||||||
|
keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this
|
||||||
|
is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent —
|
||||||
|
see `create_user` in app.py, which creates an account and its ProjectMember rows
|
||||||
|
together.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -132,7 +147,8 @@ class User(Base):
|
|||||||
|
|
||||||
Two independent notions of "role", deliberately separate:
|
Two independent notions of "role", deliberately separate:
|
||||||
• role the PERMISSIONS role — what the account may do in the app.
|
• role the PERMISSIONS role — what the account may do in the app.
|
||||||
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
|
'admin' | 'project_super_user' | 'project_admin' |
|
||||||
|
'project_user' (see auth.ROLES).
|
||||||
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
||||||
Superintendent, QA/QC, …). Carries no permissions; it's what
|
Superintendent, QA/QC, …). Carries no permissions; it's what
|
||||||
the SOP team pickers and notification routing read.
|
the SOP team pickers and notification routing read.
|
||||||
@@ -189,8 +205,11 @@ class ProjectMember(Base):
|
|||||||
entirely). One row per (user, project) pair.
|
entirely). One row per (user, project) pair.
|
||||||
|
|
||||||
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||||
one job and a normal Project User on another. Empty means "inherit the account's
|
one job and a normal Project User on another, or a Project Super User (who
|
||||||
own role" (User.role), which is how every existing row behaves."""
|
administers that job's user accounts) on one job only. Empty means "inherit the
|
||||||
|
account's own role" (User.role), which is how every existing row behaves.
|
||||||
|
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
|
||||||
|
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
|
||||||
__tablename__ = "project_members"
|
__tablename__ = "project_members"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ gunicorn==26.0.0
|
|||||||
sqlalchemy==2.0.51
|
sqlalchemy==2.0.51
|
||||||
alembic==1.18.5 # database migrations
|
alembic==1.18.5 # database migrations
|
||||||
psycopg[binary]==3.3.4
|
psycopg[binary]==3.3.4
|
||||||
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
|
|
||||||
# Chosen over pyodbc because it ships self-contained wheels —
|
|
||||||
# pyodbc would also need msodbcsql18 + unixODBC installed in
|
|
||||||
# the image. To use pyodbc instead, add it here, install the
|
|
||||||
# Microsoft ODBC driver in the Dockerfile, and switch
|
|
||||||
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
bcrypt==5.0.0 # password hashing
|
bcrypt==5.0.0 # password hashing
|
||||||
|
|||||||
@@ -5,6 +5,24 @@ Exercises the real HTTP endpoints the way the front end does, proving that
|
|||||||
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||||
|
|
||||||
|
AUTHENTICATION
|
||||||
|
Every /api/ route except /api/health requires a session (auth_gate in
|
||||||
|
server/app.py), so the script signs in first and keeps the session cookie for
|
||||||
|
the rest of the run. Credentials come from the environment by preference, so a
|
||||||
|
password never has to appear in a command line or shell history:
|
||||||
|
|
||||||
|
export WP_SMOKE_USER=smoketest
|
||||||
|
export WP_SMOKE_PASSWORD='…'
|
||||||
|
python3 server/smoketest.py https://wp-suite.company.local
|
||||||
|
|
||||||
|
…or pass --user / --password explicitly.
|
||||||
|
|
||||||
|
Use an ADMIN account. The script creates a project and deletes it again at the
|
||||||
|
end, and deleting one takes Project Admin on that project (require_project_admin);
|
||||||
|
a plain project_user can create a project but not clean it up. The script checks
|
||||||
|
the signed-in role up front and warns if it is too low, rather than letting you
|
||||||
|
discover it in the cleanup step.
|
||||||
|
|
||||||
USAGE
|
USAGE
|
||||||
# Against the deployed site (through the NGINX proxy):
|
# Against the deployed site (through the NGINX proxy):
|
||||||
python3 server/smoketest.py https://wp-suite.company.local
|
python3 server/smoketest.py https://wp-suite.company.local
|
||||||
@@ -13,16 +31,24 @@ USAGE
|
|||||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||||
|
|
||||||
# From inside the api container (hits FastAPI directly):
|
# From inside the api container (hits FastAPI directly):
|
||||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||||
|
python /app/server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
# Leave the demo project in the database so you can open it in the UI:
|
# Leave the demo project in the database so you can open it in the UI:
|
||||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||||
|
|
||||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||||
Exit code 0 = all checks passed, 1 = one or more failed.
|
|
||||||
|
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
|
||||||
|
could not start (unreachable host, missing or rejected credentials). 2 is kept
|
||||||
|
distinct on purpose: "I could not test this" is not the same answer as "this is
|
||||||
|
broken", and conflating them is what made an unauthenticated version of this
|
||||||
|
script report a wall of failures against a perfectly healthy stack.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import http.cookiejar
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -40,6 +66,18 @@ def check(name, cond, detail=""):
|
|||||||
|
|
||||||
BASE = ""
|
BASE = ""
|
||||||
CTX = None
|
CTX = None
|
||||||
|
# One opener for the whole run, carrying the cookie jar that holds the session
|
||||||
|
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
|
||||||
|
# session used to be dropped on the floor and every data route answered 401.
|
||||||
|
OPENER = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_opener(ctx=None):
|
||||||
|
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
||||||
|
if ctx is not None:
|
||||||
|
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||||
|
return urllib.request.build_opener(*handlers)
|
||||||
|
|
||||||
|
|
||||||
def call(method, path, body=None):
|
def call(method, path, body=None):
|
||||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||||
@@ -50,7 +88,7 @@ def call(method, path, body=None):
|
|||||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
with OPENER.open(req, timeout=20) as r:
|
||||||
raw = r.read().decode(); status = r.status
|
raw = r.read().decode(); status = r.status
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
raw = e.read().decode(); status = e.code
|
raw = e.read().decode(); status = e.code
|
||||||
@@ -61,33 +99,95 @@ def call(method, path, body=None):
|
|||||||
return status, parsed
|
return status, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def abort(msg, hint=""):
|
||||||
|
"""Could not run — distinct from 'ran and found problems'. See exit codes above."""
|
||||||
|
print(_c("\nABORT", "31") + " " + msg)
|
||||||
|
if hint:
|
||||||
|
print(hint)
|
||||||
|
print()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global BASE, CTX
|
global BASE, CTX, OPENER
|
||||||
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
||||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||||
help="Site root, no /api (default: http://localhost:8000)")
|
help="Site root, no /api (default: http://localhost:8000)")
|
||||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||||
|
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
||||||
|
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||||
|
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||||
|
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
|
||||||
|
"so it stays out of shell history)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
BASE = args.base_url.rstrip("/")
|
BASE = args.base_url.rstrip("/")
|
||||||
if args.insecure:
|
if args.insecure:
|
||||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||||
|
OPENER = build_opener(CTX)
|
||||||
|
|
||||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||||
|
|
||||||
|
# Refuse to start without credentials rather than running headlong into 401s.
|
||||||
|
if not args.user or not args.password:
|
||||||
|
missing = " and ".join(
|
||||||
|
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
||||||
|
return abort(
|
||||||
|
f"no credentials — {missing} not set.",
|
||||||
|
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||||
|
" meaningful to test without one. Set them and re-run:\n\n"
|
||||||
|
" export WP_SMOKE_USER=<admin-account>\n"
|
||||||
|
" export WP_SMOKE_PASSWORD='…'\n\n"
|
||||||
|
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
||||||
|
" and deletes it again, and the delete needs Project Admin on it.")
|
||||||
|
|
||||||
project_id = None
|
project_id = None
|
||||||
|
# Guards the sign-out in `finally`. Without it an ABORT on a rejected login still
|
||||||
|
# ran the logout checks, which "passed" — a session that never existed is trivially
|
||||||
|
# refused after logout — and printed PASS lines underneath an abort message.
|
||||||
|
logged_in = False
|
||||||
try:
|
try:
|
||||||
# 1) Health — API is up and reachable through the proxy.
|
# 1) Health — API is up and reachable through the proxy. Exempt from auth,
|
||||||
|
# so this also isolates "host unreachable" from "credentials rejected".
|
||||||
try:
|
try:
|
||||||
st, body = call("GET", "/api/health")
|
st, body = call("GET", "/api/health")
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
|
return abort(f"cannot reach {BASE}/api/health — {e}",
|
||||||
" Is the stack up (docker compose ps) and the URL correct?\n")
|
" Is the stack up (docker compose ps) and the URL correct?")
|
||||||
return 1
|
|
||||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||||
f"status={st} body={body}")
|
f"status={st} body={body}")
|
||||||
|
|
||||||
# 2) Create a project (writes to the projects table).
|
# 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).
|
||||||
st, proj = call("POST", "/api/projects", {
|
st, proj = call("POST", "/api/projects", {
|
||||||
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
||||||
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
||||||
@@ -96,14 +196,14 @@ def main():
|
|||||||
project_id = proj.get("id") if isinstance(proj, dict) else None
|
project_id = proj.get("id") if isinstance(proj, dict) else None
|
||||||
check("create project", st == 200 and bool(project_id), f"status={st}")
|
check("create project", st == 200 and bool(project_id), f"status={st}")
|
||||||
|
|
||||||
# 3) Read it back + confirm it's in the list (SQL round-trip).
|
# 5) Read it back + confirm it's in the list (SQL round-trip).
|
||||||
st, got = call("GET", f"/api/projects/{project_id}")
|
st, got = call("GET", f"/api/projects/{project_id}")
|
||||||
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
||||||
st, lst = call("GET", "/api/projects")
|
st, lst = call("GET", "/api/projects")
|
||||||
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
||||||
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
||||||
|
|
||||||
# 4) Create a SOP linked to the project.
|
# 6) Create a SOP linked to the project.
|
||||||
st, sop = call("POST", "/api/sops", {
|
st, sop = call("POST", "/api/sops", {
|
||||||
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
||||||
"complete": True, "created_by": "smoketest",
|
"complete": True, "created_by": "smoketest",
|
||||||
@@ -116,7 +216,7 @@ def main():
|
|||||||
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
||||||
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
||||||
|
|
||||||
# 5) Create a Work Package with one OPEN constraint (not release-ready).
|
# 7) Create a Work Package with one OPEN constraint (not release-ready).
|
||||||
st, wp = call("POST", "/api/wps", {
|
st, wp = call("POST", "/api/wps", {
|
||||||
"project_id": project_id, "sop_id": sop_id,
|
"project_id": project_id, "sop_id": sop_id,
|
||||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||||
@@ -128,11 +228,11 @@ def main():
|
|||||||
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
||||||
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
||||||
|
|
||||||
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
# 8) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||||
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
||||||
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
||||||
|
|
||||||
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
# 9) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||||
call("POST", "/api/wps", {
|
call("POST", "/api/wps", {
|
||||||
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||||
@@ -146,16 +246,16 @@ def main():
|
|||||||
f"status={st}")
|
f"status={st}")
|
||||||
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
||||||
|
|
||||||
# 8) Status transition endpoint.
|
# 10) Status transition endpoint.
|
||||||
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
||||||
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
||||||
|
|
||||||
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
|
# 11) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||||
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
||||||
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
||||||
f"status={st} metrics={m}")
|
f"status={st} metrics={m}")
|
||||||
|
|
||||||
# 10) Comment / feedback write + read.
|
# 12) Comment / feedback write + read.
|
||||||
st, c = call("POST", "/api/feedback", {
|
st, c = call("POST", "/api/feedback", {
|
||||||
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
||||||
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
||||||
@@ -164,11 +264,11 @@ def main():
|
|||||||
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
||||||
f"status={st}")
|
f"status={st}")
|
||||||
|
|
||||||
# 11) WPs filter by project.
|
# 13) WPs filter by project.
|
||||||
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
||||||
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
||||||
|
|
||||||
# 12) Archiving a project: it leaves the default list, stays reachable with
|
# 14) Archiving a project: it leaves the default list, stays reachable with
|
||||||
# archived=all, and freezes read-only — then unarchiving restores all three.
|
# 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.
|
# 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})
|
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
|
||||||
@@ -193,7 +293,7 @@ def main():
|
|||||||
check("writing succeeds again once unarchived", st == 200, f"status={st}")
|
check("writing succeeds again once unarchived", st == 200, f"status={st}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 13) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
# 15) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||||
if project_id and not args.keep:
|
if project_id and not args.keep:
|
||||||
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||||
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||||
@@ -203,6 +303,15 @@ def main():
|
|||||||
elif project_id and args.keep:
|
elif project_id and args.keep:
|
||||||
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
||||||
|
|
||||||
|
# 16) Sign out. Exercises the logout endpoint, and means a run does not end
|
||||||
|
# holding a live session — which matters when this is run from a shared
|
||||||
|
# jump host or a CI worker. Only if we got one: see `logged_in`.
|
||||||
|
if logged_in:
|
||||||
|
st, _ = call("POST", "/api/auth/logout")
|
||||||
|
check("logout clears the session", st == 200, f"status={st}")
|
||||||
|
st, _ = call("GET", "/api/auth/me")
|
||||||
|
check("session is refused after logout (401)", st == 401, f"status={st}")
|
||||||
|
|
||||||
# ── summary ────────────────────────────────────────────────────────────────
|
# ── summary ────────────────────────────────────────────────────────────────
|
||||||
total = len(_PASS) + len(_FAIL)
|
total = len(_PASS) + len(_FAIL)
|
||||||
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
||||||
|
|||||||
456
tests/browser_check.py
Normal file
456
tests/browser_check.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
#!/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())
|
||||||
329
tests/cdp.py
Normal file
329
tests/cdp.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""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()
|
||||||
Reference in New Issue
Block a user