Compare commits

...

5 Commits

Author SHA1 Message Date
9459e76a6c Add a front-end browser check, so the pages are testable and not just readable
server/smoketest.py proves the API works; nothing proved the PAGES work. That gap
is why users.js, wp-sidenav.js and the extracted console.css shipped unexecuted and
had to be written up as a known issue instead of verified. This closes the gap with
a tool rather than a one-off, so the next front-end change is cheap to check.

  tests/cdp.py           a minimal DevTools Protocol client — hand-rolled stdlib
                         WebSocket (handshake, masked frames), browser discovery for
                         Edge/Chrome across platforms, and process teardown.
  tests/browser_check.py the fixture and 71 assertions.

Stdlib only, matching smoketest.py's rule: these have to run on a plain Python
install on whatever machine is to hand. No pip, no Selenium, no node.

Self-contained — it builds a throwaway database, seeds a fixture, starts its own
uvicorn on a free port, drives the browser, and tears everything down. The real
database is never touched. Sessions come from minting a token with the app's own
auth.create_token() rather than scripting the login form.

What it asserts, beyond "no JavaScript errors on boot" (the thing that actually
went unverified): the three role-dependent renderings of the directory, one-line
rows and no sideways scroll, the roles each caller may grant, the project-access
dialog opening and closing, the drawer's open/Escape/scrim/focus/aria behaviour and
its role gating, ?project= carried only onto project-scoped links, and — the reason
this matters most — that admin.html still has its tokens, cards, headings and dense
sticky tables after console.css was lifted out of its inline <style>.

Three things the build had to get right, each learned the hard way:

  - Teardown kills the browser's whole process tree AND sweeps anything still
    holding the unique temp profile, matched on that path so a browser window the
    user has open is never touched. proc.kill() alone left 98 strays.
  - Launching retries with a fresh profile and port: a browser can hand off to
    another instance and exit rc=0 without ever binding the debugging port.
  - Cleanup waits for the server to exit and disposes the harness's own SQLAlchemy
    engine before removing the temp directory, or the open SQLite file blocks the
    delete and ignore_errors hides it.

The fixture includes an account on a project the super user cannot see, without
which the admin and the super user would see the same number of rows and the
scoping assertion would prove nothing.

Documented in DEPLOYMENT.md next to the smoke test. 71/71 across repeated runs,
leaving no stray processes or temp directories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:09:04 -05:00
7f831bf1ca Close the browser-verification gap: the front end has now been run
Deletes known issue 3. users.js, wp-sidenav.js and the extracted console.css had
never been executed, because there is no node/deno on the machine they were written
on. Edge is, so the pass was driven through the DevTools Protocol with a hand-rolled
stdlib WebSocket client, signing in by minting a session with the app's own
auth.create_token() rather than scripting the login form.

70 checks, twice, all passing — covering the five steps that entry listed:

  1. users.html as an admin: 9 columns, one-line rows, no sideways scroll, all four
     grantable roles, the project-access dialog opening and closing on Escape, and
     your own permissions cell locked to a tag while your job function stays editable.
  2. As a Project Super User: banner naming the project, only in-scope accounts
     listed, out-of-scope rows read-only with the reason on hover, and exactly the
     two roles they may grant.
  3. As an ordinary project user: 6 columns, no create form, zero controls, emails
     still reachable as mailto links.
  4. field.html: drawer opens, closes on Escape and on the scrim, aria-expanded and
     aria-current correct, focus moves inside, 44px tap targets, Admin Console hidden
     from non-admins, and ?project= carried onto project-scoped links only.
  5. admin.html: console.css loaded, --ctl resolving, cards and headings and sticky
     dense tables intact after the extraction, user administration gone and replaced
     by a link, and the admins-only gate still holding for a non-admin.

Every page boots with no JavaScript errors, which was the actual unknown.

Two things the pass surfaced, neither a defect: a 404 on /api/sops/latest is the
API's designed answer for a project with no SOP ("No SOP found") and the browser
logs every 4xx, so the fixture now seeds one; and role pills only appear where a row
is rendered read-only, since an editable row shows a dropdown instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:53:37 -05:00
64a5fd5612 Make the smoke test sign in; enforce SQLite foreign keys
Closes known issue 3. server/smoketest.py predated the login portal and had no
login step at all, so auth_gate refused every route after /api/health and the
documented way to verify a deploy reported a wall of failures against a healthy
stack.

  - Signs in first, holding the session in an http.cookiejar on a shared opener.
    urlopen() has no cookie support, which is why the session was dropped.
  - Credentials from WP_SMOKE_USER / WP_SMOKE_PASSWORD, or --user/--password, so
    a password need not land in shell history. Refuses to start without them
    rather than running headlong into 401s.
  - Checks the signed-in role up front and warns when it cannot archive or delete
    a project, instead of failing six checks later for an unexplained reason.
  - New exit code 2 for "could not run" (unreachable, or credentials missing or
    rejected), kept distinct from 1 "ran and found problems".
  - Also asserts the session is accepted on an authenticated route and refused
    after sign-out; signs out at the end so a run on a shared host leaves none.

The working smoke test immediately caught a real bug: SQLite ships with foreign
keys disabled and the pragma is per-connection, so every ondelete="CASCADE" was
silently a no-op on dev while working on Postgres. Deleting a project orphaned its
SOPs, work packages and membership rows; deleting a user orphaned theirs. db.py
now sets PRAGMA foreign_keys=ON for SQLite, so dev matches production.

Enforcing them exposed two things that had been getting away with it:

  - create_user adds an account and its ProjectMember rows in one flush, and the
    ORM takes flush order from relationship() declarations. models.py has none by
    design, so it emitted the child INSERT first and the database rejected it.
    Fixed with a db.flush() after the account, and documented at the top of
    models.py so the next same-flush pair does not rediscover it. The other three
    call sites already commit the parent first.
  - A write aimed at a since-deleted project used to leave an orphan row; with FKs
    enforced it would have been an IntegrityError surfacing as a 500, which the
    browser outbox retries forever (it only retires 4xx). require_project_writable
    now refuses a vanished project with 409, like the archived case beside it.

Verified: smoke test 27/27 exit 0 against a live server (the cascade assertion now
passes on SQLite, which is what used to fail); credentials missing and credentials
rejected both abort cleanly with exit 2 and no stray PASS lines; a project_user run
warns up front and fails as described. Scope tests 93/93, live HTTP checks 29/29,
static JS checks 33/33. No orphan rows left in the database afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:19:36 -05:00
c99ef08cf1 Record the smoketest auth gap and the browser-verification gap
Two things surfaced while building the User Directory that are worth a decision
rather than a mention in a handover.

3. server/smoketest.py has no login step, so auth_gate 401s every check after
   /api/health. DEPLOYMENT.md presents it as the way to prove the stack works
   end-to-end, including a docker compose exec invocation, so the documented
   verification path reports failure on a healthy system -- the failure mode most
   likely to be believed. Rated Medium for that reason. Predates the login
   portal; confirmed unrelated to this branch by stashing it and re-running. The
   Admin Console's in-browser smoke test is the working equivalent today.

4. users.js and wp-sidenav.js have never been executed -- no JS engine on the
   machine they were written on. Logged as a verification gap, not a defect, with
   what WAS checked (server tests, delimiter balance, handler resolution, id
   targets) and what only a browser can settle (layout, transitions, focus trap).
   Includes the five-step manual pass that closes it, and the hard-reload note,
   since sw.js bumped to wp-suite-shell-v6 and a soft reload serves the old
   shell.

Both entries follow the file's existing shape: what is wrong, what it costs, why
it is still open, what closing it takes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:06:47 -07:00
4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00
23 changed files with 2788 additions and 670 deletions

View File

@@ -195,12 +195,18 @@ good deploy look broken.
Sanity checks — all four should take under a minute:
1. Log in. The home page offers to select or create a project.
2. Open **Admin Console** (the link is on the home page; you need an admin account).
The user table should read as **one line per user** — if rows are three lines tall
and the table spills outside its white card, you are still on the old cached
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
home page). The table should read as **one line per user** — if rows are three lines
tall and the table spills outside its white card, you are still on the old cached
files: hard-reload again.
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
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived**
it should reappear marked `archived`. Click **Unarchive** to put it back. That

View File

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

View File

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

View File

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

View File

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

View File

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

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

191
html/console.css Normal file
View 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%; }
}

View File

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

View File

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

View File

@@ -14,15 +14,16 @@
'use strict';
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v5';
const CACHE = 'wp-suite-shell-v6';
const SHELL = [
'/', '/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',
'/wp-chrome.css',
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-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',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];

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

90
html/wp-sidenav.css Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
function activeProjectId() {
try {
var q = new URLSearchParams(location.search).get('project');
if (q) return q;
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
} catch (e) { return ''; }
}
// The suite page reads ?tab= and ?project=; keeping the current project on the link
// is the difference between "open the dashboard" and "open the dashboard, then pick
// the job again".
function hrefFor(item) {
if (!item.project) return item.href;
var pid = activeProjectId();
if (!pid) return item.href;
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
return item.href + sep + 'project=' + encodeURIComponent(pid);
}
// Current-page marking. The three suite tabs share one file, so they're told apart
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
function isCurrent(item) {
var path = location.pathname;
if (item.tab) {
if (!/work-package-suite\.html$/.test(path)) return false;
var tab = '';
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
return tab === item.tab;
}
return !!(item.match && item.match.test(path));
}
// ── build ──────────────────────────────────────────────────────────────────
var drawer, scrim, btn, lastFocus = null;
function buildDrawer(user) {
scrim = document.createElement('div');
scrim.className = 'wp-navscrim';
scrim.hidden = true;
scrim.addEventListener('click', close);
drawer = document.createElement('nav');
drawer.className = 'wp-sidenav';
drawer.id = 'wp-sidenav';
drawer.setAttribute('aria-label', 'Suite navigation');
drawer.setAttribute('aria-hidden', 'true');
var rows = '';
LINKS.forEach(function (item) {
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
if (item.show && !item.show()) return;
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
'<span class="wp-sidenav-label">' + esc(item.label) +
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span></a>';
});
var who = user ? (user.full_name || user.username || '') : '';
drawer.innerHTML =
'<div class="wp-sidenav-head">' +
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
'</div>' +
'<div class="wp-sidenav-body">' + rows + '</div>' +
'<div class="wp-sidenav-foot">' +
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
'<span class="wp-sidenav-label">Sign out</span></button>' +
'</div>';
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
if (typeof window.wpLogout === 'function') window.wpLogout();
});
document.body.appendChild(scrim);
document.body.appendChild(drawer);
}
function focusables() {
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
}
function open() {
if (!drawer) return;
lastFocus = document.activeElement;
scrim.hidden = false;
// Two frames: the element has to be laid out un-transitioned before the class
// that animates it lands, or it simply appears.
requestAnimationFrame(function () {
scrim.classList.add('is-open');
drawer.classList.add('is-open');
});
drawer.setAttribute('aria-hidden', 'false');
btn.setAttribute('aria-expanded', 'true');
var f = focusables();
if (f.length) f[0].focus();
}
function close() {
if (!drawer) return;
drawer.classList.remove('is-open');
scrim.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
btn.setAttribute('aria-expanded', 'false');
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
// off into the page behind the scrim.
document.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') return;
var f = focusables();
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
// ── mount ──────────────────────────────────────────────────────────────────
// The button goes at the START of the bar, before the brand: that is where a menu
// affordance is looked for, and it keeps clear of the project switcher and search
// that wp-chrome.js inserts into the middle of the same bar.
function mount() {
if (document.getElementById('wp-sidenav')) return;
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (!host) return;
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wp-navbtn';
btn.id = 'wp-navbtn';
btn.title = 'Menu';
btn.setAttribute('aria-label', 'Open navigation');
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', 'wp-sidenav');
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round"/></svg>';
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
host.insertBefore(btn, host.firstChild);
buildDrawer(window.WP_USER);
}
// Wait for the auth guard: the gated links depend on the signed-in role, and an
// unauthenticated page is about to redirect anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();

View File

@@ -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
access AND Project Admin *on that project*."""
require_project_access(db, user, project_id)
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
if effective_role(db, user, project_id) not in (
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN,
):
raise HTTPException(
status_code=403,
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:
return
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(
status_code=409,
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
# ── User-administration scope ──────────────────────────────────────────────────
# User administration used to be one thing: an app admin did all of it. It is now
# two, because a project admin has to be able to staff their own job without an app
# admin on the phone. An app admin still manages every account; a PROJECT SUPER USER
# manages the accounts on the projects they hold that role on.
#
# Three questions, deliberately separate, because they have different answers:
# managed_project_ids which projects do I administer the users of?
# visible_user_ids whose entry may I SEE in the directory?
# manage_user_problem may I change this account? (much narrower than seeing it)
def managed_project_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
"""Projects where `caller` may administer user accounts. None means every project
(an app admin). Read per-membership so the per-project override decides: a super
user demoted to plain member on one job does not administer its users, and an
ordinary account made super user on one job does administer that one."""
if auth.is_admin(caller):
return None
rows = db.scalars(
select(models.ProjectMember).where(models.ProjectMember.user_id == caller.id)
).all()
account_role = auth.normalize_role(caller.role)
out = set()
for r in rows:
role = auth.normalize_role(r.role) if (r.role or "").strip() else account_role
if role == auth.ROLE_PROJECT_SUPER:
out.add(r.project_id)
return out
def is_user_manager(db: Session, user: "models.User") -> bool:
"""May this account administer users at all? THE one definition — derived from the
managed set, never from the account role alone, because the super-user role can be
held on a single project (ProjectMember.role) by an otherwise ordinary account.
Falls out of it that a super user with no project memberships manages nobody,
which is right: the authority comes from the jobs, not the job title."""
managed = managed_project_ids(db, user)
return managed is None or bool(managed)
def require_user_manager(user: "models.User" = Depends(auth.get_current_user),
db: Session = Depends(get_db)) -> "models.User":
"""First gate on the user-administration routes: does this caller administer the
users of ANY project? Which accounts they may then touch is a second, narrower
check per target — require_manage_user."""
if not is_user_manager(db, user):
raise HTTPException(
status_code=403,
detail="Managing user accounts requires the Administrator role, or Project "
"Super User on a project",
)
return user
def member_project_ids(db: Session, user_id: str) -> set[str]:
"""Every project this user has a membership row for (no admin shortcut — this is
the raw set, which is exactly what the scope checks need to reason about)."""
return set(db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)
).all())
def visible_user_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
"""Whose directory entry `caller` may read. None means everyone (an app admin).
Anyone signed in may look up the people they actually work with — their own
projects' members — plus the app admins, who are on every project implicitly and
are who you go to when something needs unblocking. Nobody else: the directory
must not become a company-wide address book for a single-project subcontractor."""
if auth.is_admin(caller):
return None
ids = {caller.id}
mine = accessible_project_ids(db, caller) or set()
if mine:
ids |= set(db.scalars(
select(models.ProjectMember.user_id).where(models.ProjectMember.project_id.in_(mine))
).all())
ids |= set(db.scalars(
select(models.User.id).where(models.User.role == auth.ROLE_ADMIN)
).all())
return ids
def manage_user_problem(db: Session, caller: "models.User", target: "models.User",
cache: Optional[dict] = None) -> Optional[str]:
"""None if `caller` may make ACCOUNT-level changes to `target` (password, name,
permissions role, enable/disable, delete); otherwise the reason they may not, in
words the console can show verbatim.
An app admin may always. A super user may only when the account sits ENTIRELY
inside the projects they administer, and is not itself an admin or super user.
Both limits matter:
• Exclusive scope, because these changes are global. Resetting a password or
disabling an account reaches every project that person is on, so a super user
must not be able to reach into a job they don't run by way of a shared member.
• No admin/super targets, because otherwise the role could be used to take over
a peer's account and inherit their scope.
Project-scoped changes (adding someone to MY project, their role THERE) are not
account-level and are checked against `managed_project_ids` instead.
`cache` lets a caller judging a whole page of users hand in the two lookups this
needs ('managed', and 'members' as {user_id: {project_id}}) so the verdict for
thirty rows costs two queries instead of sixty. The rule itself lives only here."""
if auth.is_admin(caller):
return None
cache = cache if cache is not None else {}
managed = cache.get("managed")
if managed is None:
managed = cache["managed"] = managed_project_ids(db, caller) or set()
if not managed:
return ("You don't administer the users of any project — that needs the Project "
"Super User role on the project")
if auth.normalize_role(target.role) in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER):
return "Only an application administrator can change an Administrator or Project Super User account"
members = cache.get("members")
theirs = members.get(target.id, set()) if members is not None else member_project_ids(db, target.id)
if not theirs:
return ("This account isn't on any project, so only an application administrator "
"can change it")
outside = theirs - managed
if outside:
return (f"{target.username} is also on {len(outside)} project(s) you don't administer — "
"account changes there have to come from an application administrator. "
"You can still change their access and role on your own projects.")
return None
def require_manage_user(db: Session, caller: "models.User", target: "models.User") -> None:
problem = manage_user_problem(db, caller, target)
if problem:
raise HTTPException(status_code=403, detail=problem)
def require_see_user(db: Session, caller: "models.User", target: "models.User") -> None:
"""404, not 403: whether an account exists outside your projects is itself not
yours to learn, and a 403 would confirm the username."""
visible = visible_user_ids(db, caller)
if visible is not None and target.id not in visible:
raise HTTPException(status_code=404, detail="User not found")
def grantable_roles(caller: "models.User") -> tuple:
"""Permissions roles `caller` may hand out. A super user may staff their job with
project admins and project users — never another admin or super user, which is the
line that keeps the role from being a route to app-wide control."""
if auth.is_admin(caller):
return auth.ROLES
return (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
def load_target_user(db: Session, user_id: str) -> "models.User":
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
# ── Audit trail ────────────────────────────────────────────────────────────────
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
@@ -392,6 +563,10 @@ class NewUserIn(BaseModel):
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
project_role: str = "" # job function on the project (no permissions)
# Projects to put the new account on straight away. Optional for an app admin
# (who can assign later); REQUIRED for a super user, whose authority over an
# account comes from the projects it is on — see create_user.
project_ids: list[str] = Field(default_factory=list)
class ProjectRoleIn(BaseModel):
@@ -603,8 +778,11 @@ def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
@app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load."""
return {"user": user.to_dict()}
"""Who is logged in. The frontend guard calls this on every page load.
`role` is normalized here so no page has to know that a pre-roles account stores
'user' where it now means 'project_user'."""
return {"user": {**user.to_dict(), "role": auth.normalize_role(user.role)}}
# ── Display preferences (self-service) ─────────────────────────────────────────
@@ -667,22 +845,122 @@ def change_password(body: PasswordChangeIn, request: Request, response: Response
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")
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
return [u.to_dict() for u in rows]
def list_users(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""The user directory, scoped to the caller. An app admin sees every account; a
project member sees the people on their own projects (plus the app admins)."""
visible = visible_user_ids(db, user)
stmt = select(models.User).order_by(models.User.username)
if visible is not None:
stmt = stmt.where(models.User.id.in_(visible))
rows = db.scalars(stmt).all()
# One pass over the membership table serves both the project-access count and the
# per-row "may I manage this account" verdict. The old console fetched the counts
# with one HTTP request per user.
counts, cache = None, None
if is_user_manager(db, user):
members: dict[str, set] = {}
for uid, pid in db.execute(
select(models.ProjectMember.user_id, models.ProjectMember.project_id)
).all():
members.setdefault(uid, set()).add(pid)
counts = {uid: len(pids) for uid, pids in members.items()}
cache = {"members": members}
return [directory_entry(db, u, user, counts, cache) for u in rows]
@app.get("/api/auth/user-scope")
def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""What the signed-in account may do on the user directory page, so the page can
render the right controls instead of guessing at the rules and drawing buttons
that 403. Advisory only — every route re-checks server-side."""
managed = managed_project_ids(db, user)
if managed is None:
rows = db.scalars(select(models.Project).order_by(models.Project.name)).all()
else:
rows = db.scalars(
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
).all() if managed else []
manager = managed is None or bool(managed)
return {
"can_manage_users": manager,
"scope": "all" if managed is None else "projects",
"role": auth.normalize_role(user.role),
"grantable_roles": list(grantable_roles(user)) if manager else [],
"grantable_project_roles": list(
auth.PROJECT_SCOPED_ROLES if auth.is_admin(user)
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
) if manager else [],
"role_labels": auth.ROLE_LABELS,
"project_roles": list(auth.PROJECT_ROLES),
"managed_projects": [{"id": p.id, "name": p.name, "number": p.number,
"archived": p.archived_at is not None} for p in rows],
}
@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)
if problem:
raise HTTPException(status_code=400, detail=problem)
if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
if auth.find_user(db, body.username):
raise HTTPException(status_code=409, detail="A user with that username already exists")
managed = managed_project_ids(db, actor)
requested = [p for p in dict.fromkeys(body.project_ids) if p]
for pid in requested:
check_id(pid)
if managed is not None:
# A super user's authority over an account is derived from the projects that
# account is on. Creating one with no project — or on a job they don't run —
# would either produce an account they instantly cannot manage, or reach into
# someone else's job. Both are refused rather than silently narrowed.
if not requested:
raise HTTPException(
status_code=400,
detail="Choose at least one project for the new account — you administer users per project",
)
outside = [p for p in requested if p not in managed]
if outside:
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
missing = [p for p in requested if p not in valid]
if missing:
raise HTTPException(status_code=400, detail="One of those projects no longer exists")
u = models.User(
id=gen_id("user"),
username=body.username.strip(),
@@ -693,54 +971,75 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
project_role=body.project_role.strip()[:120],
)
db.add(u)
log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role})
# Flush the account before adding memberships that point at it. The ORM decides
# flush order from relationship() declarations, and models.py deliberately has
# none (plain columns + ForeignKey), so it will happily emit the project_members
# INSERT before the users one — which the database then rejects. Without this the
# whole call fails with a foreign-key violation on any engine that actually
# enforces them, which is every engine we run: Postgres always, and SQLite since
# db.py started setting `PRAGMA foreign_keys=ON`.
db.flush()
log_event(db, actor, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role,
"projects": len(valid)})
for pid in requested:
if pid in valid:
grant_project_access(db, u.id, pid)
if valid:
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
detail={"projects": len(valid), "reason": "created_with_access"})
db.commit()
db.refresh(u)
return u.to_dict()
return directory_entry(db, u, actor)
@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)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id and not body.is_active:
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
if u.id == actor.id and not body.is_active:
raise HTTPException(status_code=400, detail="You cannot disable your own account")
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)})
db.commit()
return u.to_dict()
return directory_entry(db, u, actor)
@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)):
"""Change a user's PERMISSIONS role (admin / project_admin / project_user).
Their job function on the project is separate — see set_user_project_role.
def set_user_role(user_id: str, body: RoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Change a user's PERMISSIONS role. Their job function on the project is separate
— see set_user_project_role.
Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id:
Guards: you can't change your own role (avoids self-lockout), the last remaining
admin can't be demoted (keeps the app manageable), and a super user may only hand
out the roles in `grantable_roles` — never admin or another super user."""
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
if u.id == actor.id:
raise HTTPException(status_code=400, detail="You cannot change your own role")
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
other_admins = db.scalars(
@@ -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_role = ""
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.refresh(u)
return u.to_dict()
return directory_entry(db, u, actor)
@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,
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
and notification routing read, so it's worth keeping accurate."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
old = u.project_role or ""
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})
db.commit()
db.refresh(u)
return u.to_dict()
return directory_entry(db, u, actor)
@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
an optional role on those projects. It only touches NEW projects — existing
assignments stay under the admin's hand (see set_user_projects), because
back-filling everyone onto historical jobs is never what this flag means."""
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
back-filling everyone onto historical jobs is never what this flag means.
App-admin only, unlike the rest of user administration: this is a standing rule
about every project that will ever exist, including the ones a super user has no
part in."""
allowed = ("",) + auth.PROJECT_SCOPED_ROLES
role = (body.role or "").strip()
if role not in allowed:
raise HTTPException(
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)
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}")
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id:
def delete_user(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
if u.id == actor.id:
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.commit()
return {"deleted": user_id}
@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)):
"""Which projects a user is assigned to, plus the full project list for the
assignment UI. (Admins implicitly access every project regardless.)"""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
def get_user_projects(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the project list to choose from.
(Admins implicitly access every project regardless of what's ticked here.)
A super user is shown ONLY the projects they administer, and `other_projects` says
how many more the person is on — enough for the dialog to be honest that it is
editing a slice of this account's access, without naming jobs that aren't theirs."""
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
managed = managed_project_ids(db, actor)
if managed is None:
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
in_scope = rows
else:
projects = db.scalars(
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
).all() if managed else []
in_scope = [r for r in rows if r.project_id in managed]
return {
"user": u.to_dict(),
"assigned": [r.project_id for r in rows],
"user": directory_entry(db, u, actor),
"assigned": [r.project_id for r in in_scope],
# 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
# to remain visible and removable — but they're flagged so the dialog can say
# so, rather than offering a finished job as though it were live work.
"projects": [{"id": p.id, "name": p.name, "number": p.number,
"archived": p.archived_at is not None} for p in projects],
"other_projects": len(rows) - len(in_scope),
"grantable_project_roles": list(
auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor)
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
),
}
@app.put("/api/auth/users/{user_id}/projects")
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
# Only the two project-scoped roles make sense here: app admin is global, and
# anything unrecognised falls back to inheriting the account's own role.
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
def set_user_projects(user_id: str, body: ProjectAssignIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set.
For an app admin the given set IS the whole answer. For a super user it replaces
only their own slice: memberships on projects they don't administer are left
exactly as they were, because a payload that simply omits them would otherwise cut
someone off from a job the caller can't even see."""
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
requested = [p for p in dict.fromkeys(body.project_ids) if p]
for pid in requested:
check_id(pid)
managed = managed_project_ids(db, actor)
if managed is not None:
outside = [p for p in requested if p not in managed]
if outside:
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
# A super user may hand out the project-scoped roles below their own; only an app
# admin can make someone a super user on a project. Anything unrecognised falls
# back to inheriting the account's own role.
allowed = auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor) else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
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:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=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),
"scope": "all" if managed is None else "managed",
"overrides": {p: r for p, r in roles.items() if p in valid}})
db.commit()
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}

View File

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

View File

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

View File

@@ -45,8 +45,12 @@ def _prompt_password(provided: str | None, username: str = "") -> str:
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
if role not in ("admin", "user"):
sys.exit("role must be 'admin' or 'user'")
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
@@ -70,9 +74,10 @@ def cmd_list(args) -> None:
if not rows:
print("No users yet. Create one with: create-admin <username>")
return
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
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:
@@ -113,7 +118,8 @@ def main() -> None:
add_create("create-admin", "create an admin 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")

View File

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

View File

@@ -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
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
AUTHENTICATION
Every /api/ route except /api/health requires a session (auth_gate in
server/app.py), so the script signs in first and keeps the session cookie for
the rest of the run. Credentials come from the environment by preference, so a
password never has to appear in a command line or shell history:
export WP_SMOKE_USER=smoketest
export WP_SMOKE_PASSWORD=''
python3 server/smoketest.py https://wp-suite.company.local
…or pass --user / --password explicitly.
Use an ADMIN account. The script creates a project and deletes it again at the
end, and deleting one takes Project Admin on that project (require_project_admin);
a plain project_user can create a project but not clean it up. The script checks
the signed-in role up front and warns if it is too low, rather than letting you
discover it in the cleanup step.
USAGE
# Against the deployed site (through the NGINX proxy):
python3 server/smoketest.py https://wp-suite.company.local
@@ -13,16 +31,24 @@ USAGE
python3 server/smoketest.py https://wp-suite.company.local --insecure
# 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:
python3 server/smoketest.py https://wp-suite.company.local --keep
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 http.cookiejar
import json
import os
import ssl
import sys
import urllib.error
@@ -40,6 +66,18 @@ def check(name, cond, detail=""):
BASE = ""
CTX = None
# One opener for the whole run, carrying the cookie jar that holds the session
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
# session used to be dropped on the floor and every data route answered 401.
OPENER = None
def build_opener(ctx=None):
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers)
def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
@@ -50,7 +88,7 @@ def call(method, path, body=None):
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
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
except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code
@@ -61,33 +99,95 @@ def call(method, path, body=None):
return status, parsed
def abort(msg, hint=""):
"""Could not run — distinct from 'ran and found problems'. See exit codes above."""
print(_c("\nABORT", "31") + " " + msg)
if hint:
print(hint)
print()
return 2
def main():
global BASE, CTX
global BASE, CTX, OPENER
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (default: http://localhost:8000)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
"so it stays out of shell history)")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX)
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
# Refuse to start without credentials rather than running headlong into 401s.
if not args.user or not args.password:
missing = " and ".join(
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
return abort(
f"no credentials — {missing} not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n"
" meaningful to test without one. Set them and re-run:\n\n"
" export WP_SMOKE_USER=<admin-account>\n"
" export WP_SMOKE_PASSWORD=''\n\n"
" Or pass --user/--password. Use an admin account: the run creates a project\n"
" and deletes it again, and the delete needs Project Admin on it.")
project_id = None
# Guards the sign-out in `finally`. Without it an ABORT on a rejected login still
# ran the logout checks, which "passed" — a session that never existed is trivially
# refused after logout — and printed PASS lines underneath an abort message.
logged_in = False
try:
# 1) Health — API is up and reachable through the proxy.
# 1) Health — API is up and reachable through the proxy. Exempt from auth,
# so this also isolates "host unreachable" from "credentials rejected".
try:
st, body = call("GET", "/api/health")
except urllib.error.URLError as e:
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
" Is the stack up (docker compose ps) and the URL correct?\n")
return 1
return abort(f"cannot reach {BASE}/api/health — {e}",
" Is the stack up (docker compose ps) and the URL correct?")
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
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", {
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
"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
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}")
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
st, lst = call("GET", "/api/projects")
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
# 4) Create a SOP linked to the project.
# 6) Create a SOP linked to the project.
st, sop = call("POST", "/api/sops", {
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
"complete": True, "created_by": "smoketest",
@@ -116,7 +216,7 @@ def main():
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
# 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", {
"project_id": project_id, "sop_id": sop_id,
"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
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")
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", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
@@ -146,16 +246,16 @@ def main():
f"status={st}")
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"})
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}")
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
f"status={st} metrics={m}")
# 10) Comment / feedback write + read.
# 12) Comment / feedback write + read.
st, c = call("POST", "/api/feedback", {
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
@@ -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),
f"status={st}")
# 11) WPs filter by project.
# 13) WPs filter by project.
st, wps = call("GET", f"/api/wps?project_id={project_id}")
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
# 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.
# 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})
@@ -193,7 +293,7 @@ def main():
check("writing succeeds again once unarchived", st == 200, f"status={st}")
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:
st, _ = call("DELETE", f"/api/projects/{project_id}")
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
@@ -203,6 +303,15 @@ def main():
elif project_id and args.keep:
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
# 16) Sign out. Exercises the logout endpoint, and means a run does not end
# holding a live session — which matters when this is run from a shared
# jump host or a CI worker. Only if we got one: see `logged_in`.
if logged_in:
st, _ = call("POST", "/api/auth/logout")
check("logout clears the session", st == 200, f"status={st}")
st, _ = call("GET", "/api/auth/me")
check("session is refused after logout (401)", st == 401, f"status={st}")
# ── summary ────────────────────────────────────────────────────────────────
total = len(_PASS) + len(_FAIL)
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")

456
tests/browser_check.py Normal file
View 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
View 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()