Compare commits

...

16 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
3cccdf1c4b Merge branch 'docs/deploy-runbook': project archiving, default members, admin console rebuild
Brings in the 2026-08-05 work plus the deploy runbook and KNOWN-ISSUES.md.
Carries migration a7c31f9e5b02 (additive, with server defaults). No overlap with
the entrypoint/backup-script changes already on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:46:27 -07:00
153fe97a31 Record the SOP discipline-name XSS as a known issue
It is a real hole and we are shipping without fixing it, so it needs to be written
down somewhere that outlives the conversation it came up in.

Discipline names are rendered into inline handlers in the WP creator escaped with
esc(), which maps ' to &#39;. That is right for text and wrong here: the browser
decodes entities in an attribute before the JS parser sees it, so the entity
becomes a bare quote and closes the handler's string literal. Escaping for a
handler argument has to go backslash, then quote, then HTML — esc() only does the
last part. Same bug, same ordering, as the two fixed on 2026-08-05 (jsq() in
admin.js, escHandlerArg() in work-package-suite-app.js); this one predates that
work and sits in a file it did not touch.

Left open rather than fixed because the suite is internal, behind a login, with
named employee accounts and no anonymous input path — the likely cost is a
discipline named "Owner's Equipment" silently breaking its own pill, not an attack.
The entry records the conditions that change that judgement (exposure outside the
corporate network, accounts for subcontractors or clients, self-registration), so
the rating cannot go quietly stale if the deployment story changes. Neither CSP nor
the CSRF gate mitigates it, and both are noted so nobody re-derives that hopefully.

Also records the archived-project rough edge from the same day: the server refuses
writes with 409, but the two big apps still present Save and Issue buttons, so the
failure is safe but late. data-wp-archived is already on the document element for
whoever closes it.

Each entry says what closing it takes, and entries get deleted in the commit that
fixes them — otherwise this file becomes a museum instead of a queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:56:27 -07:00
928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00
7bb1f66588 Merge pull request 'Add entrypoint fallback for missing bind-mounted scripts' (#2) from scripts/entrypoint into main
Reviewed-on: #2
2026-08-05 01:56:49 +00:00
26f4a9242a Entrypoint static variable removed to allow validation of scripting files before startup 2026-08-04 18:51:08 -07:00
6c1dc7d45f Add entrypoint fallback for missing bind-mounted scripts
- scripts/entrypoint.sh (new): prefers the live bind-mounted backup-cron.sh, but falls back to a copy baked into the image at build time if the mount is missing. If neither exists, it stays up and idle (instead of crash-looping) so the container remains reachable via console/exec for diagnosis.
- scripts/backup-cron.sh (updated): resolves db-backup.sh the same live-or-fallback way, re-checked on every loop iteration, so if the bind mount comes back healthy later, this container picks up the live scripts on its next backup run with no restart needed.
- scripts/backup.Dockerfile (updated): bakes all three scripts into the image under /app/scripts-default/ as the fallback, and sets the new wrapper as ENTRYPOINT.
2026-08-04 18:44:34 -07:00
e5977758c0 Hand-off runbook for the 2026-08-04 deploy
A step-by-step deploy procedure for someone who administers the Docker host
but does not know this app. Two things about this deploy need spelling out for
them, and neither is obvious from DEPLOYMENT.md:

- nginx's config and all of html/ are baked into the image, so the stack has to
  be re-pulled and re-built. A restart deploys nothing and looks like a success.
- the pending users.role rewrite (b41c7ae9) is one-way as far as the app is
  concerned: rolling the API image back after it commits breaks logins, because
  the old code doesn't recognise 'project_user'. So the runbook records
  `alembic current` and both image IDs up front, and splits rollback by symptom
  — an nginx-only failure is a safe code-only rollback they can do alone, a
  failed migration is escalate-don't-improvise.

Backups go through the existing sidecar rather than an ad-hoc pg_dump: it works
from Portainer's console without SSH, writes an encrypted timestamped dump to
backups/ on the host, and prints a success line worth checking. Commands use
`docker exec <name>` throughout, since `docker compose` from an SSH session
can't find a Portainer-managed stack's compose project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:10:15 -07:00
a9b22f2add NGINX: set Cache-Control via a map, not a nested location, so security headers survive
The Cache-Control rule I added in the previous commit used a nested
`location ~* \.(html|css|js|webmanifest)$`. nginx does NOT inherit add_header into a
block that declares its own add_header, so every HTML, CSS and JS response would have
been served WITHOUT the CSP, HSTS, X-Frame-Options, Referrer-Policy and nosniff headers
from the Phase S hardening — the headers dropped for exactly the files that matter most,
and silently, since the pages would still work.

Now computed by `map $uri $wp_cache_control` at http level and applied with one
server-level add_header alongside the security headers, so nothing is scoped away. An
empty value makes nginx omit the header entirely, so images and fonts stay cacheable.
Applied to both the Docker config (nginx/conf.d/wp-suite.conf) and the bare-metal one
(nginx-wp-suite.conf), which carries the same header set.

Caught while checking whether the stack was safe to redeploy. Not verified with
`nginx -t` — this machine has neither nginx nor docker — so DEPLOYMENT.md now records
the rule and the one-line curl that confirms both headers are present after a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:37:18 -07:00
e3527a6e1d Act on the fragility audit: boot-order crash, real cache correctness, deep links
A 55-agent audit of the last few commits confirmed 32 findings. The high and medium
ones are fixed here; the ranked leftovers are listed at the end.

Boot-order crash (my regression, wave 2)
- wp-format.js loaded AFTER wp-creation-app.js on every page, but the creator boots
  synchronously at parse time and its comment renderer calls wpFormatDateTime(). With
  any review comment present that threw a ReferenceError and aborted the rest of boot.
  The formatter now parses before the app scripts on all five pages. Verified with a
  comment seeded: the date renders and boot completes.

The network-first fix didn't actually work
- `fetch(req)` inherits the request's default cache mode, so it consults the browser
  HTTP cache — the previous commit's "network-first" still allowed a page to run
  against a stale sibling. Code is now fetched with cache:'no-cache' and precached
  with cache:'reload'.
- Nothing pinned freshness on the wire either: no Cache-Control anywhere, so browsers
  applied heuristic caching (~10% of a file's age) and each file expired at a
  different moment. NGINX and the dev server now send no-cache for html/css/js/
  webmanifest; images stay cacheable. Verified on the wire.
- Non-ok responses were returned verbatim, so a 502 broke pages the cache could have
  served; they now fall back to the cache. Cache keys drop the query string, which
  fixes both the offline miss on every in-app link (?project=…&tab=…) and unbounded
  cache growth. respondWith can no longer resolve to undefined. Cache bumped to v5.

Embedded creator
- Dropped the &t=Date.now() cache-buster and made the frame's identity the PROJECT.
  The view and which package to open are now applied by calling into the loaded
  document, so switching tabs no longer reloads it — that reload discarded unsaved form
  edits, made the creator unreachable offline, and stored a fresh copy per click.
- ?view=dashboard was re-read on every tab switch, so after one deep link the
  "Work Package Creation" tab kept opening the Dashboard for the rest of the session.
  Deep-link params are consumed once now.
- ?wp=<id> — which the global search has been emitting since wave 2 — was read by
  nothing, so picking a work package in search opened a blank one. The creator now
  exposes openWpById() and the shell applies it after a new 'wp-creator-ready' event,
  because the frame's load fires before pullProject() resolves.
- Math.max(320,…) could make the frame taller than the space available while page
  scrolling was disabled, pushing content off a window that couldn't scroll. Full-bleed
  is now only used when at least 460px remains, and the SOP-incomplete gate never runs
  inside it. A ResizeObserver re-measures when wp-chrome.js grows the app bar.

Contract drift
- .field-hint and .user-pick are used on the SOP suite page but their only rules lived
  in wp-creation-styles.css, which that page doesn't link — the CM hint and the
  sign-off pickers had no styling at all. Rules added to the suite's stylesheet.
- The creator's critical floor now also hides modal overlays (a stale stylesheet
  rendered their contents inline in the form) and gives the jump bar a sane sticky top.
- login.js dereferenced ids unguarded where the old version guarded, so a cached older
  login.html would break sign-in itself. Guarded.
- The "Language & time" menu item was added only if wp-format.js had already parsed;
  the check now happens at click time.

Verified: 157 API checks across five suites on a clean database, plus 22 driven UI
checks — boot-with-comment, tab switching with a no-reload probe, short-viewport
fallback, and the search deep link landing on the right package.

Not done, ranked: ~50 dead CSS rules across three stylesheets; dead .team-pick and
.constraint-option contracts; wp-chrome.js's documented '.header' mount branch is
unreachable because the creator loads neither wp-chrome.js nor its CSS; the squeeze
half of the embed layout (.content-area.embed-full) is still CSS-only, which degrades
to the old narrow column rather than breaking; fingerprinted asset URLs would make a
mismatched pair unrepresentable rather than merely unlikely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:07:20 -07:00
917a728399 Rebuild the work-package side panel in the style of MS Planner
The auto-hiding drawer was the wrong model — a list you navigate by shouldn't appear
and disappear under the pointer, and its vertical text tab read as a stray artifact.
Replaced with a persistent side panel following the Planner reference:

- Collapse toggle at the top (the panel glyph, arrow flips), remembered across visits.
  Collapsed leaves a 56px icon rail where the coloured package badges are still
  clickable, rather than hiding the list entirely.
- One primary action: "+ New work package" with a split caret for Duplicate, Split by
  discipline and Export all.
- Icon nav with counts: My packages (owned by you), All packages, Needs attention
  (on hold or not release-ready), Dashboard. These filter the list below.
- Packages as rows with a colour-coded initial badge, number, subject and readiness
  state, still grouped by status, with a left accent bar on the current package.
  The badge colour is hashed from the WP number, so a package keeps its swatch
  instead of shuffling when another is added or deleted.
- The panel sits IN the layout: the form and the full-width chrome shift beside it
  rather than being overlaid.

Also, the reason it appeared as loose unstyled widgets in the middle of the form: the
panel's markup and its stylesheet are cached independently, so a browser can run new
markup against old CSS. Its essential layout (fixed position, width, the row/badge
flex, the collapsed rules) is now injected by wp-creation-app.js as a floor, inserted
first in <head> so the stylesheet still wins on everything it defines. Same lesson as
the iframe: a component whose CSS-missing state is "broken" rather than "plain" must
carry its own critical layout.

Verified with 25 driven checks in headless Chrome: persistence, the four nav links,
badge colours and text, view filtering, collapse/expand, the split menu, row selection
and highlighting — and, with wp-creation-styles.css removed from the page entirely, the
panel is still a fixed 288px side panel with the form shifted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:47:40 -07:00
40bd19b6cf Stop the embedded creator collapsing to a 300x150 box; serve code network-first
The Creator rendered as a tiny double-scrolling square in the WP tab. My fault, and
the mechanism matters more than the symptom:

I had moved the iframe's sizing (width:100%, border:0, min-height) out of its inline
style attribute and into work-package-suite-styles.css. The service worker cached the
HTML and the stylesheet as INDEPENDENT entries, cache-first — so a browser could hold
the new HTML together with the old CSS. With the inline sizing gone and the new rule
absent, the iframe fell back to the HTML default 300x150 box and the whole tool
collapsed. Moving self-contained markup into a separately-cached file created that
window; nothing about the layout itself was wrong.

Three layers so it cannot recur:
- The iframe's width/border/min-height are inline again, on purpose, with a comment
  saying why. An iframe with no intrinsic size has a catastrophic failure mode, so its
  sizing must not depend on another file being in step.
- applyEmbedLayout() now sets the fill height and width as INLINE styles via
  sizeWPFrame(). Inline beats any stylesheet, including a stale cached one, so the
  class is a refinement rather than a requirement.
- sw.js: HTML/CSS/JS are now fetched NETWORK-FIRST with the cache as offline fallback;
  images/icons/manifest stay stale-while-revalidate. These files reference each other,
  so a page must never run against a stale sibling — this same staleness had already
  masked two other fixes during development. Cache bumped to v4.

Verified: at 2560x1440 the tool spans the window with a single scrollbar; with
work-package-suite-styles.css removed entirely (strictly worse than stale) the frame
still measures 1469x662 instead of 300x150, and re-running the layout pass keeps it
there; 12 checks across sop -> wp -> dashboard -> sop confirm body.embed-full, the
content-area class, the fill class and the inline height are all cleared on the way
out, so the wizard never ends up unscrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:27:53 -07:00
41 changed files with 4769 additions and 769 deletions

View File

@@ -0,0 +1,290 @@
# Deploy runbook — WP Suite
**For:** IT / whoever administers the Docker host and Portainer
**From:** n.siegfried@prime-controls.com
**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure,
but the deploy now carries a second database migration and a new admin screen. If
you already have the earlier copy, work from this one instead.
**Expected duration:** 1015 minutes, including the backup
**Expected downtime:** under a minute, while containers are recreated
---
## Fill these in before handing this over
| Thing | Value |
|---|---|
| Docker host (SSH target) | `________________` |
| Stack name in Portainer | `________________` |
| Site URL | `https://________________` |
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
Container names are fixed by the compose file and are the same on every host:
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
---
## What this deploy changes
Front-end and nginx changes, a new admin screen, plus pending database migrations
that run automatically. Three things make it more than a routine restart:
1. **The nginx config and the entire `html/` directory are baked into the
container image at build time.** A plain restart deploys nothing — the stack
must be re-pulled and re-built.
2. **A pending migration rewrites existing rows** in the `users.role` column
(`b41c7ae90d52`, values `user``project_user`). That is why step 1 is a backup
and not optional. If a previous deploy already applied it, it will not run again —
step 0 tells you which of these you are actually about to run.
3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on
projects, and two default-membership fields on users). This one is additive and
has database defaults for existing rows, so it does not rewrite anything.
For the people using the app, the visible changes are: projects can now be
**archived** from the Admin Console (they disappear from the pickers and go
read-only, and can be brought back), certain users can be set to join **every new
project automatically**, and the Admin Console has been rebuilt so the user table
fits on screen.
Migrations run themselves when the `wp_api` container starts. There is nothing
to type and **no new environment variables** — do not change the stack's
environment variables.
---
## Step 0 — Record the current state (needed for rollback)
SSH to the Docker host and run:
```bash
docker exec wp_api alembic -c server/alembic.ini current
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
docker inspect wp_api --format 'api image: {{.Image}}'
```
**Copy the output into your ticket.** Also note the Git commit the Portainer
stack is currently on (Portainer → the stack → the Git reference / last-updated
commit). Without these, rollback is guesswork.
The first command prints the migration the database is currently on. Use it to see
which migrations this deploy will actually run:
| `alembic current` shows | What will run | What that means |
|---|---|---|
| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. |
| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. |
| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. |
Take the backup either way.
---
## Step 1 — Back up the database
On the Docker host:
```bash
docker exec wp_db_backup /scripts/db-backup.sh
```
This triggers the stack's existing backup sidecar once, on demand. Expected
output ends with a line like:
```
[db-backup] wrote 1.4M /backups/wpsuite-20260804-141233Z.sql.gz.enc
```
Confirm the file is on the host (substitute the stack directory):
```bash
ls -lt <stack-dir>/backups | head -3
```
**Record that filename.** Do not continue until you have seen the `wrote …`
line and the file in that listing.
- A `.sql.gz.enc` extension means backups are encrypted — expected and correct.
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
means backups are unencrypted. Not a blocker for this deploy; report it back.
- **No SSH access?** Portainer → **Containers**`wp_db_backup`**Console**
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
dump lands on the host, because `/backups` is a bind mount.
---
## Step 2 — Redeploy the stack in Portainer
1. Portainer → **Stacks** → select the stack.
2. **Pull and redeploy** — with re-pull / re-build **enabled**.
3. Wait for it to report success.
A plain "restart" or "stop/start" will **not** deploy this change. See "What
this deploy changes" above.
---
## Step 3 — Confirm the containers came up
```bash
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
```
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
applied its migrations cleanly:
```bash
docker logs wp_api --tail 40
```
You are looking for Alembic `Running upgrade …` lines followed by gunicorn
starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The
API deliberately refuses to start if a migration fails, so a restarting `wp_api`
container means the migration failed — go to Rollback.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `a7c31f9e5b02 (head)`.
Then verify nginx's own view of its config:
```bash
docker exec nginx_webserver nginx -t
```
Expected: `syntax is ok` / `test is successful`.
---
## Step 4 — Confirm the response headers
```bash
curl -sI https://<site-url>/work-package-suite.html | grep -Ei 'cache-control|content-security-policy'
```
Add `-k` if the site uses an internal or self-signed certificate.
**Both lines must come back.** Expected, approximately:
```
cache-control: no-cache, must-revalidate
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...
```
If the `content-security-policy` line is **missing** while `cache-control` is
present, the deploy is bad — go to Rollback and send me the nginx log. (This is
the specific regression this deploy fixes; the two headers must coexist.)
Also confirm the API is reachable through the proxy:
```bash
curl -s https://<site-url>/api/health # → {"ok": true}
```
---
## Step 5 — Hard-reload once in a browser
Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app
uses a service worker; a normal reload can serve the previous version and make a
good deploy look broken.
Sanity checks — all four should take under a minute:
1. Log in. The home page offers to select or create a project.
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
home page). The table should read as **one line per user** — if rows are three lines
tall and the table spills outside its white card, you are still on the old cached
files: hard-reload again.
> Changed since this runbook was written: user accounts moved out of the Admin
> Console into `users.html` when the **Project Super User** role was added, so that
> a project admin can create accounts on their own job. If you are deploying a build
> from before that change, read this step as "Admin Console → the user table".
3. Open **Admin Console** (admin account required). Two new cards are present and
load: **Projects**, and **Default members on new projects**. Both should list rows,
not an error.
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
round trip proves the new migration and the new endpoint are both live.
**Deploy complete.** Please report back: the step 0 output (including which
migrations ran), the backup filename, and the two header lines from step 4.
---
## Rollback
Pick the case that matches.
### Case A — nginx won't start, or the CSP header is missing
The database is untouched by this, so this is a code-only rollback. In Portainer,
redeploy the stack pinned to the **previous Git commit** recorded in step 0
(Portainer → the stack → change the Git reference to that commit → Pull and
redeploy). Then re-run step 3 and step 4.
**Before you do:** grab the log, because it is what I need to fix this.
```bash
docker logs nginx_webserver --tail 100
```
Send me that output. If the container is in a restart loop the log still works.
### Case B — `wp_api` is restarting / a migration failed
```bash
docker logs wp_api --tail 100
```
Send me that output. **Do not restore the database and do not roll the API back
without contacting me first.** Which migration got as far as committing decides what
is safe, and they are not the same:
- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling
the API back to the previous image is safe on its own — the old code simply
ignores the extra columns. Nothing needs converting.
- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed,
rolling the API back without converting those values back **will break logins**.
That conversion is a one-line command, but it has to match what actually ran.
The `alembic current` output from step 0, plus the `Running upgrade …` lines in the
log above, are exactly what tells us which case you are in — please include both.
Reach me at n.siegfried@prime-controls.com.
### Case C — restoring the backup (only if I ask for it)
Destructive: this drops and recreates the current schema and data. For an
encrypted dump, on the Docker host, in the `backups` directory:
```bash
export BACKUP_ENC_PASSPHRASE='<the passphrase — from the stack env vars>'
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
-in wpsuite-<timestamp>.sql.gz.enc \
| gunzip \
| docker exec -i wp_db psql -U wpsuite -d wpsuite
unset BACKUP_ENC_PASSPHRASE
```
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
not `wpsuite`.
---
## Notes
- Do not add or change environment variables for this deploy.
- Do not run `docker compose down -v` — the `-v` flag deletes the `pgdata`
volume and with it the entire database.
- `docker compose …` commands are avoided throughout this runbook on purpose:
for a Portainer-managed Git stack the compose project lives under Portainer's
own data directory, so `docker compose` from an SSH session usually can't find
it. The `docker exec <container-name>` form used here works from any directory.
- Full background documentation: `DEPLOYMENT.md` in the repository.

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
@@ -209,11 +253,11 @@ users on the same project see the same server-stored SOP and Work Packages.
| Table | Holds | Key columns |
|-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` |
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -224,18 +268,23 @@ column; frequently-listed fields are promoted to real columns for filtering.
### Endpoints (summary)
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`,
`POST /api/projects/{id}/archive` ·
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
under `/api/auth/users` · Admin-only `GET/PUT /api/settings`,
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
`exclude`** — anything that needs to see archived rows (the admin console, the demo
cleanup) must ask for them. Full reference and request shapes: `/api/docs` and
[`server/README.md`](server/README.md).
---
@@ -327,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
@@ -416,8 +492,9 @@ not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
(`scope_to_access`) and hides archived work packages, archived projects, and
anything belonging to an archived project. LIKE wildcards in the query are escaped,
so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
@@ -469,3 +546,87 @@ project-scoped roles. Changes are audit-logged as `project_access_changed`.
the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.
### Default members on new projects
Memberships are also created automatically. **Admin console → Default members on
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
the PM who runs them all, the QC lead — with the role they should hold there
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
inherit the account's own).
- It applies **only to projects created after the flag is set**. Nothing is
back-filled onto existing jobs; use **Project access** for those.
- App admins are skipped (they already reach every project) and the flag is cleared
if an account is promoted to admin. Inactive accounts are skipped.
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
covers every route into project creation — the home page, the sample project, the
demo seeder. An update never re-runs it.
- If the creator is themselves a flagged member, the membership created for them as
creator carries their `auto_add_role`, so they aren't silently downgraded on the
one job they started.
- Audit-logged once per project as `project_access_granted` with
`detail.reason = "auto_add_projects"`.
## Archiving a project
A finished job is archived rather than deleted: `projects.archived_at`, set from
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
Project Admin **on that project**, same bar as deleting it).
An archived project is **hidden and frozen**:
- It leaves the home picker, the app-bar switcher and global search, because
`GET /api/projects` defaults to `archived=exclude`.
- It is still readable by id, so a deep link renders it — with a read-only banner
from `wp-chrome.js` — and the admin console still lists it under
`?archived=all`.
- Every write that lands on it is refused with **409** by
`require_project_writable()`: saving a project, SOP or work package, deleting
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
Moving a work package *into* or *out of* an archived project is refused too.
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
instead of retrying them forever.
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
project must accept, and archive-then-delete is a normal sequence.
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
asserts the whole round trip.
## Asset freshness (why the app can't run half-updated)
A page must never run against a stylesheet or script from a previous deploy. Three
things enforce that, and all three are needed:
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
roughly 10% of each file's age, so the least recently changed file gets the longest
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
make each revalidation a cheap 304.
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
default cache mode and consults the browser HTTP cache, so "network-first" alone
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
page the cache could still serve, and cache keys drop the query string so in-app
links (`?project=…&tab=…`) still resolve offline.
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
it), and the work-package panel injects a floor of positioning rules from
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
dumped loose into the form — that a missing rule turned into a broken page rather
than a plain one.
If you change the shell file list in `sw.js`, bump `CACHE`.
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
> at http level, applied with a single server-level `add_header`. Do **not** move it
> into a `location` block: nginx does not inherit `add_header` into a block that
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
> files. After deploying, confirm both are present on one response:
>
> ```bash
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
> ```

163
KNOWN-ISSUES.md Normal file
View File

@@ -0,0 +1,163 @@
# Known issues — Work Package Suite
Defects and limitations we know about and have decided not to fix yet. An entry
here is a commitment to a decision, not a bug tracker: it says what is wrong, what
it costs, why it is still open, and what closing it takes.
Anything genuinely urgent does not belong here — it belongs in the next deploy.
Close an entry by deleting it in the same commit that fixes it.
| # | Issue | Severity | Raised | Status |
|---|-------|----------|--------|--------|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
---
## 1. XSS via SOP discipline names in the WP creator
**Files:** `html/wp-creation-app.js` lines 684, 723, 727, 729 · escaping helper at
line 63
**Predates:** the 2026-08-05 archive/admin-console work. Not introduced by it.
### What is wrong
Discipline names are rendered into inline event handlers escaped with `esc()`,
which maps `'` to `&#39;`. That is correct for text and wrong here. The browser
decodes entities in an attribute value **before** the JavaScript parser sees it, so
`&#39;` becomes a bare `'` inside the handler's string literal and closes it early.
```js
// html/wp-creation-app.js:684 — esc() is not sufficient for a handler argument
onchange="toggleDiscipline('${esc(d)}',this.checked)"
```
Escaping for an inline handler has to happen in this order: **backslash, then
quote** (for the JS string literal), **then HTML** (for the attribute carrying it).
`esc()` only does the last part.
### How it is reached
1. `gov_disciplines` (`html/work-package-suite.html:219`) is a free-text field. Its
value is comma-split with no validation at `work-package-suite-app.js:1224`.
2. It is saved into `sops.data` and syncs to the server via `ProjectData.pushSOP`.
3. Every other member of that project pulls it with `pullProject()` and renders it
in the WP creator — so this is **stored** and **cross-user**, and it fires on
page load rather than needing the victim to click anything.
Any **project_user** on the job can set it while the SOP is a draft (after the SOP
is marked complete it takes project_admin). The victim is anyone who opens the WP
creator for that project, which includes administrators.
### What it costs
**The likely cost is a broken screen, not an attack.** A discipline named
`Owner's Equipment` — an ordinary thing to type — produces a syntax error in the
handler, so the discipline pill and its scope-step buttons silently stop
responding. No error message, nothing a field user can diagnose.
**The security ceiling is project_user → admin.** The session cookie is HttpOnly so
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
`script-src 'self' 'unsafe-inline'`, and `'unsafe-inline'` is what permits inline
event handlers in the first place.
- **The CSRF gate does not mitigate it.** `_csrf_ok` (`server/app.py:67`) only
requires a same-origin `Origin`, and code running inside our own page is
same-origin.
### Why it is still open
The suite is internal, behind a login, on the corporate network, with a small set
of named employee accounts and no anonymous input path. Exploiting it means an
employee deliberately attacking colleagues, and the audit log carries their name on
the SOP edit. The accidental-breakage case is far more likely to be met than the
malicious one.
**Re-rate this as High and fix it immediately if any of these become true:** the
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
builder on 2026-08-05 for the same bug in custom constraint names:
```js
// html/work-package-suite-app.js:1120
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
```
1. Add the same helper to `html/wp-creation-app.js` alongside `esc()`.
2. Use it at lines 684, 723, 727 and 729 in place of `esc(d)`.
3. Sweep the other inline handlers in that file for the same pattern. The remaining
ones interpolate server-generated ids that `check_id()` already constrains to a
safe charset, or hardcoded enum values, so they are not currently reachable —
converting them anyway keeps the pattern from coming back.
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
clicks and the name must display intact.
The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved
out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) —
same ordering, same reasoning, worth reading before starting.
---
## 2. Archived projects: the two big apps don't grey out their own controls
**Files:** `html/wp-creation-app.js`, `html/work-package-suite-app.js`
**Raised:** 2026-08-05, with the project-archiving work.
### What is wrong
Archiving a project freezes it server-side — every write returns 409 (see
`require_project_writable` in `server/app.py`, and the *Archiving a project*
section of `DEPLOYMENT.md`). The front end tells the user, but does not stop them:
`wp-chrome.js` shows a read-only banner and sets `data-wp-archived="1"` on the
document element, and nothing reads that attribute yet. So on an archived project
the WP creator and the SOP builder still present working Save and Issue buttons.
### What it costs
Low, and it fails safe — the server refuses the write, so nothing is corrupted and
no data is lost. The cost is wasted effort and a confusing moment: someone deep-
linked to an archived job can fill in a form and only learn it was refused when the
sync indicator reports the change did not save.
Reaching an archived project at all takes a deep link or a stale tab, since it is
gone from every picker, switcher and search — which is why this is a rough edge
rather than a defect.
### Why it is still open
Gating every control in two large single-page apps is materially bigger than the
archive feature itself, and the server is the real enforcement boundary either way.
The banner plus the sync indicator were judged enough for a first release.
### What closing it takes
`data-wp-archived` is already on the document element for exactly this purpose.
Either add `[data-wp-archived]` rules in `wp-chrome.css` that disable and dim the
save/issue controls, or add a boot check in each app that disables them and shows a
read-only notice inline. Decide separately how the embedded creator
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
app bar — and therefore the banner — is deliberately skipped.

View File

@@ -82,7 +82,6 @@ services:
volumes:
- ./scripts:/scripts:ro
- ./backups:/backups
entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"]
restart: unless-stopped
depends_on:
db:

View File

@@ -5,64 +5,54 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — 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>
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; }
*{ 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); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:#1e54bb; color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius:0; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 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:8px; }
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:0; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
select.role-select{ padding:4px 8px; 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:700; }
/* 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.
These are addressed by ID because admin.js emits the tables without per-cell
classes. */
/* 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); }
/* 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. */
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
overflow:visible; max-height:none; }
/* Comment text and audit detail are the two columns you are actually here to
read, so they wrap inside a sane width instead of truncating. */
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
/* 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); }
@media (max-width:900px){
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
max-height:none; }
}
</style>
</head>
<body>
@@ -79,61 +69,75 @@
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
<h2>Admins only</h2>
<p class="sub" style="margin:0 0 12px">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<p class="sub">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
</div>
</div>
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
<div><h1>Admin Console</h1><div class="sub" style="margin:0">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
<!-- CONNECTIVITY -->
<div class="card">
<h2>API connectivity</h2>
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<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" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
<div id="users-banner"></div>
<div id="users-table" style="margin-top:12px"></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>
<h2 style="margin-top:22px">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>
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
<div class="card">
<h2>Projects</h2>
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is.
Unarchive here to bring it back; the project returns unchanged.</div>
<div class="toolbar">
<button onclick="loadProjects()">Refresh projects</button>
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
</div>
<div id="users-create-msg" class="note"></div>
<div id="projects-banner"></div>
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
</div>
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
<div class="card">
<h2>Default members on new projects</h2>
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
with the role chosen here. It does not touch projects that already exist — for those, use
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
Administrators are listed with nothing to set: they already reach every project. This card stays
in the console because it is a rule about <em>every</em> future project, including the ones a
Project Super User has no part in — so only an admin sets it.</div>
<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>
</div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div class="sub">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div class="sub">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div>
@@ -141,20 +145,20 @@
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="row">
<div class="sub">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="toolbar">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="comments-admin" class="note">Click refresh to load.</div>
</div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<h2>Activity log</h2>
<div class="sub" style="margin-bottom:10px">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="row">
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="toolbar">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
@@ -163,42 +167,42 @@
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
</div>
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="audit-admin" class="note">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="row">
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="toolbar">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="usage-admin" class="note">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT -->
<div class="card">
<h2>Database snapshot</h2>
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
<div class="toolbar"><button onclick="snapshot()">Refresh counts</button></div>
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div>
<!-- SMOKE TEST -->
<div class="card">
<h2>End-to-end smoke test</h2>
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<div class="sub">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre>
</div>
<!-- DEMO DATA -->
<div class="card">
<h2>Demo data</h2>
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="row">
<div class="sub">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="toolbar">
<button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div>
@@ -207,8 +211,9 @@
</div>
</div>
<script src="console-util.js"></script>
<script src="admin.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -4,14 +4,22 @@
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();
loadNotifications();
loadComments();
@@ -22,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');
@@ -53,15 +49,18 @@ async function checkHealth(){
// ── db snapshot ───────────────────────────────────────────────────────────────
async function snapshot(){
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
// archived=all: /api/projects now hides archived projects by default, and a row
// count that silently drops them is not a snapshot of the database.
const [p,s,w,c] = await Promise.all([
api('GET','/api/projects'), api('GET','/api/sops'),
api('GET','/api/projects?archived=all'), api('GET','/api/sops'),
api('GET','/api/wps'), api('GET','/api/comments')]);
if(p.status!==200){
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
}
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0;
out.innerHTML = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}</td></tr>
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
@@ -95,6 +94,14 @@ async function runSmokeTest(){
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
// Archive round-trip: out of the default list, still there with archived=all,
// frozen against writes, and all three undone by unarchiving.
r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status);
r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid));
r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid));
r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}});
chk('write to an archived project refused (409)', r.status===409, 'status '+r.status);
r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status);
} catch(e){ chk('unexpected error', false, String(e)); }
finally {
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
@@ -141,7 +148,9 @@ async function seedDemo(){
async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
const r = await api('GET','/api/projects');
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
// this button — the default list hides it and nothing else here can delete it.
const r = await api('GET','/api/projects?archived=all');
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; }
@@ -150,23 +159,132 @@ 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;'); }
// ── 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
// suite and is frozen read-only; nothing is deleted. That makes this card the ONLY
// place an archived project is still visible, so it asks for archived=all and does
// the hiding itself — otherwise an admin could never find one to unarchive.
let _adminProjects = [];
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 loadProjects(){
const banner=document.getElementById('projects-banner');
const wrap=document.getElementById('projects-table');
if(!banner || !wrap) return;
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/projects?archived=all');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant archive or delete projects here.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_adminProjects = json;
renderProjects();
}
async function loadUsers(){
const banner=document.getElementById('users-banner');
const wrap=document.getElementById('users-table');
function renderProjects(){
const wrap=document.getElementById('projects-table');
if(!wrap) return;
const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked;
const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase();
const total = _adminProjects.length;
if(!total){ wrap.innerHTML='<div class="note">No projects yet.</div>'; return; }
const list = _adminProjects.filter(p => {
if(!showArchived && p.archived) return false;
if(!q) return true;
return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0;
});
const count = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
if(!list.length){
wrap.innerHTML = count + '<div class="note">Nothing matches'+
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
return;
}
const fmt = s => s ? wpFormatDateTime(s) : '—';
const rows = list.map(p => {
// Project names are free text written by whoever created the job — jsq(), not
// uesc(), is what makes them safe to bind into the handlers below.
const pid = jsq(p.id);
const pname = jsq(p.name||'(unnamed)');
const arch = !!p.archived;
return '<tr>'+
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
'<td>'+(arch
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
: '<span class="tag on">active</span>')+'</td>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
(arch?'Unarchive':'Archive')+'</button>'+
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML = count +
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
'work package on it for good. Archive first if there is any doubt.</div>';
}
// Both directions are explained in full before anything happens: archiving makes a
// project vanish for everyone else in the company, and there is no undo prompt on
// the other side of that.
async function archiveProject(id, name, archived){
const ask = archived
? 'Archive “'+name+'”?\n\n'+
'• It disappears from every project picker, switcher and search across the suite.\n'+
'• It becomes read-only — nobody can add or change its SOP or work packages.\n'+
'• Nothing is deleted. Unarchive here at any time to bring it back.'
: 'Unarchive “'+name+'”?\n\n'+
'It becomes visible in the pickers again and can be edited as normal.';
if(!confirm(ask)) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
// global shared with the other scripts the page loads, and "deleteProject" is broad
// enough to collide with one of them later. The -Admin suffix also says which of the
// two project deletions this is — the console's, not a project member's.
async function deleteProjectAdmin(id, name){
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'(database cascade). This cannot be undone.\n\n'+
'If you only want it out of the way, cancel and use Archive instead.')) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// ── default members on new projects ─────────────────────────────────────────────
// A rule about the FUTURE: flagged users are auto-added to every project created
// from now on. It is not a bulk assignment — existing projects are untouched, which
// is what the note under the table is there to say.
let _defMemUsers = [];
async function loadDefaultMembers(){
const banner=document.getElementById('defmem-banner');
const wrap=document.getElementById('defmem-table');
if(!banner || !wrap) return;
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';
banner.textContent='❌ Your account is not an admin, so you cant change who is added to new projects.';
wrap.innerHTML=''; return;
}
if(status===401){
@@ -176,265 +294,90 @@ async function loadUsers(){
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);
_defMemUsers = json;
renderDefaultMembers();
}
// 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 = uesc(u.username).replace(/'/g, "\\'");
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(\'' + 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');
// 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');
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(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
const delBtn = me
? ''
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">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 = uesc(u.username).replace(/'/g,"\\'");
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
function renderDefaultMembers(){
const wrap=document.getElementById('defmem-table');
if(!wrap) return;
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const rows = _defMemUsers.map(u => {
const uid = jsq(u.id);
const uname = jsq(u.username);
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(\''+u.id+'\',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(\''+u.id+'\',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>'+
'<td>'+uesc(u.email||'')+'</td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td>'+projAccessCell(u)+'</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 style="white-space:nowrap"><div class="row" style="gap:6px">'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
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.
if(role === 'admin'){
return '<tr>'+who+
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+
' <span class="note">Administrators already reach every project.</span></td>'+
'</tr>';
}
const on = !!u.auto_add_projects;
const cur = u.auto_add_role || '';
// Every project-scoped role is offered, super user included: this card is
// admin-only, and "the QA lead runs the users on every new job" is exactly the
// sort of standing rule it exists to express.
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
uesc(PERM_LABELS[role]||role)+')</option>']
.concat(PROJECT_SCOPED_ROLES.map(r =>
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
return '<tr>'+who+
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
'<td><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"'+
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
'</label></td>'+
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
' title="The role this user gets on those projects"'+
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
'</tr>';
}).join('');
wrap.innerHTML='<table class="users"><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 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>';
wrap.innerHTML =
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>User</th><th>Email</th>'+
'<th title="What this account may do in the app">Account permissions</th>'+
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
'<th title="Their role on those projects">Role on those projects</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
'Directory</a> to add someone to a project that already exists.</div>';
}
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();
// Saves on every tick and every dropdown change — there is no Save button, so a
// failure must not leave a control showing something the server never accepted.
// On success we swap in the row the server returned (it clears the role whenever
// the flag is off); on failure we reload so the controls snap back to the truth.
async function setAutoAdd(id, username){
const cb = document.getElementById('defmem-cb-'+id);
if(!cb) return;
const sel = document.getElementById('defmem-role-'+id);
const auto_add = !!cb.checked;
const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add',
{ auto_add, role: auto_add ? ((sel && sel.value) || '') : '' });
if(status===200 && json && json.id){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
renderDefaultMembers();
} else {
msg.style.color='var(--red)';
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadDefaultMembers();
}
}
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.
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>':'')+'</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+').');
};
}
// ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = [];
async function loadComments(){

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,10 +195,17 @@
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
if (typeof window.wpPreferences === 'function') {
wrap.appendChild(sep());
wrap.appendChild(link('Language & time', function () { window.wpPreferences(); }));
// 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());
wrap.appendChild(link('Language & time', function () {
if (typeof window.wpPreferences === 'function') window.wpPreferences();
}));
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;

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

@@ -5,11 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field View — 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="wp-sidenav.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
@@ -83,7 +87,7 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="field.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite — Prime Controls</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">
@@ -289,6 +292,10 @@
padding: 1.25rem; }
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
/* Shown once when the project someone had open turns out to have been archived
rather than deleted — otherwise the picker just silently resets on them. */
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
@@ -366,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 -->
@@ -411,12 +425,34 @@
_projects = list || [];
// Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive();
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null);
renderProjectPicker();
applyActiveProject();
// "No longer in the list" used to mean one thing — deleted. Now it also
// means archived, and resetting someone to "Select a project" with no word
// about it sends them hunting for a job that is merely finished.
if(dropped) explainDroppedProject(dropped);
});
}
function explainDroppedProject(p){
// The project is still readable when it's archived; a delete (or access being
// taken away) fails here, and that case genuinely has nothing to say.
ProjectData.get(p.id).then(full => {
if(!full || !full.archived) return;
const box = document.getElementById('project-picker');
if(!box || document.getElementById('proj-archived-note')) return;
const note = document.createElement('div');
note.id = 'proj-archived-note';
note.className = 'proj-archived-note';
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
archived — it is read-only and no longer listed here. An administrator can unarchive it
from the Admin Console.`;
box.insertBefore(note, box.firstChild);
}).catch(() => {});
}
function createFormHtml(){
return `<div class="proj-form" id="proj-form" style="display:none">
<div class="proj-form-grid">
@@ -662,7 +698,6 @@
.catch(() => {});
}
</script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -80,6 +80,9 @@
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
@@ -117,7 +120,7 @@
.catch(function () { resetAvailable = false; return false; });
}
byId('forgot-link').addEventListener('click', function (e) {
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
@@ -131,13 +134,13 @@
});
});
byId('back-to-login').addEventListener('click', function (e) {
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form');
var forgotBtn = byId('forgot-submit');
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
@@ -167,13 +170,13 @@
});
// ── set a new password (from the emailed link) ──────────────────────────────
byId('reset-to-login').addEventListener('click', function (e) {
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form');
var resetBtn = byId('reset-submit');
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();

View File

@@ -73,6 +73,10 @@
});
},
// Archiving a project lives in the admin console (html/admin.js), which doesn't
// load this file — deliberately not mirrored here, so there's only one
// implementation of it rather than two that can disagree.
// ── active project context ────────────────────────────────────────────────
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
@@ -208,7 +212,8 @@
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
// retrying, so a rejected write can't loop forever. `err` is the server's own
// explanation when it sent one; it is what the sync badge shows the user.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
@@ -229,7 +234,16 @@
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
else if (status >= 400 && status < 500 && status !== 429) {
// Refused once, refused forever — so the only useful thing left is the
// reason. A 409 here is the archived-project gate, whose detail tells
// the user the project is read-only and how to get it unarchived; a
// bare "HTTP 409" would leave them staring at a change that vanished.
return r.json().catch(function () { return null; }).then(function (j) {
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
markDead(op.opId, why);
});
}
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
@@ -250,12 +264,17 @@
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
// `failed` stays the total not-getting-through count (what listeners already
// read); `dead` splits out the ops the server has permanently refused, with the
// first reason it gave, because those two states need different words.
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
else if ((q[i].tries || 0) >= 3) failed++;
else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
@@ -281,7 +300,17 @@
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
// reason is the only thing that tells the user what to do (e.g. the project is
// archived). Stack it under the headline; the badge never hides in this state.
el.style.flexDirection = c.dead ? 'column' : 'row';
el.style.alignItems = c.dead ? 'flex-start' : 'center';
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
} else if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {

View File

@@ -5,23 +5,25 @@
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
same-origin GET → stale-while-revalidate (instant from cache, refreshed
in the background when online).
HTML / CSS / JS → network-first, cache as fallback. These reference each
other, so a page must never run against a stale sibling.
• images / icons / manifest → stale-while-revalidate (instant from cache).
*/
'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-v3';
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',
];
@@ -30,7 +32,10 @@ self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
.then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
// can be filled from stale HTTP entries, freezing a mismatched shell.
.then((c) => Promise.all(SHELL.map(
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
@@ -43,6 +48,17 @@ self.addEventListener('activate', (e) => {
);
});
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
// where offline speed actually comes from.
//
// Why not cache-first for code: these files reference each other, and the cache
// stores them as independent entries. Cache-first served whichever copy of each file
// happened to be stored, so a browser could run new HTML against old CSS — which is
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
// only ever run against the stylesheet and scripts it shipped with.
const CODE_RE = /\.(html|css|js)$|\/$/i;
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
@@ -50,18 +66,43 @@ self.addEventListener('fetch', (e) => {
if (url.origin !== self.location.origin) return; // third-party: default
if (url.pathname.startsWith('/api/')) return; // never cache the API
e.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req)
const isCode = CODE_RE.test(url.pathname);
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
// full URL both missed every offline navigation and grew the cache without bound.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
const store = (res) => {
if (res && res.ok && res.type !== 'opaque') {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
}
return res;
};
if (isCode) {
e.respondWith(
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
// the request's default cache mode, which consults the browser HTTP cache — so
// "network-first" alone still let a page run against a stale sibling file.
fetch(req, { cache: 'no-cache' })
.then((res) => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
// A 502/404 must not replace a page the cache could still serve.
if (!res || !res.ok) return fromCache().then((c) => c || res);
return store(res);
})
.catch(() => cached); // offline → cached copy
return cached || network; // cache-first, then refresh
.catch(() => fromCache()) // offline → last good copy
.then((res) => res || Response.error()) // never resolve to undefined
);
return;
}
e.respondWith(
caches.match(key).then((cached) => {
const network = fetch(req).then(store).catch(() => cached);
return cached || network.then((res) => res || Response.error());
})
);
});

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();

View File

@@ -291,10 +291,15 @@ window.addEventListener('DOMContentLoaded',()=>{
updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
// keep opening the dashboard for the rest of the session.
const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard');
_deepLinkWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
else if(_deepLinkWp) switchTool('wp');
}
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -479,7 +484,10 @@ function switchTool(tool){
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
if(contentTool === 'wp') renderWPTab(isDash);
applyEmbedLayout(contentTool === 'wp');
// Only go full-bleed when the creator is actually showing. With the SOP
// incomplete this tab shows a short 'complete the SOP first' gate; making the
// page unscrollable around it can clip its button off the bottom.
applyEmbedLayout(contentTool === 'wp' && sopComplete);
updateStepUI();
updateProjectDisplay();
@@ -488,23 +496,68 @@ function switchTool(tool){
// The embedded creator/dashboard fills the window below the app chrome, so there
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
// page — and the creator's sticky bars have a real viewport to stick to.
// A work package the global search asked us to open, handed to the creator on its
// next load and then cleared.
let _deepLinkWp = '';
function applyEmbedLayout(on){
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter
// than the creator's own sticky bars while the page itself can't scroll. Fall back
// to normal page flow and let the page scroll instead.
const MIN_FILL_H = 460;
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H;
const full = !!on && fits;
const area = document.querySelector('.content-area');
const frame = document.getElementById('wp-frame');
if(area) area.classList.toggle('embed-full', !!on);
if(frame) frame.classList.toggle('fill', !!on);
document.body.classList.toggle('embed-full', !!on);
if(on) measureChrome();
if(area) area.classList.toggle('embed-full', full);
if(frame) frame.classList.toggle('fill', full);
document.body.classList.toggle('embed-full', full);
sizeWPFrame(full);
}
// Height of the app bar + tab strip, so the iframe can be exactly the rest.
function measureChrome(){
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any
// stylesheet — including a stale cached one — so the creator can't collapse to the
// 300x150 default iframe box if the CSS and the HTML are ever out of step.
function sizeWPFrame(on){
const frame = document.getElementById('wp-frame');
if(!frame) return;
frame.style.width = '100%';
frame.style.border = '0';
if(on){
const h = viewportMinusChrome();
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px');
frame.style.height = h + 'px';
frame.style.minHeight = '0';
} else {
frame.style.height = '';
frame.style.minHeight = 'calc(100vh - 200px)';
}
}
function chromeHeight(){
const hdr = document.querySelector('.header');
const nav = document.querySelector('.main-nav');
const h = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
document.documentElement.style.setProperty('--wp-chrome-h', h + 'px');
return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
}
window.addEventListener('resize', () => { if(document.body.classList.contains('embed-full')) measureChrome(); }, {passive:true});
// Whatever is left of the window below the app bar + tab strip. No floor: a floor
// taller than the remaining space pushes the frame (and the creator's fixed save bar)
// off a window that has scrolling disabled.
function viewportMinusChrome(){
return Math.max(0, window.innerHeight - chromeHeight());
}
// Re-measure on resize, and re-decide whether full-bleed still fits.
window.addEventListener('resize', () => {
if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true);
}, {passive:true});
// The app bar grows when wp-chrome.js injects the project switcher and search, which
// happens after the frame has already been sized. Watch the chrome instead of relying
// on someone resizing the window.
try {
const _ro = new ResizeObserver(() => {
if(document.body.classList.contains('embed-full')) sizeWPFrame(true);
});
['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); });
} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ }
// Show the gate or the embedded Work Package Creator depending on SOP status.
// wantDash=true opens the creator straight to the dashboard view.
@@ -515,12 +568,49 @@ function renderWPTab(wantDash){
if(sopComplete){
gate.style.display = 'none';
frame.style.display = 'block';
// Reload each time so the creator picks up the latest SOP from localStorage.
const sp = new URLSearchParams(window.location.search);
const dash = wantDash || sp.get('view') === 'dashboard';
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
const wantWp = _deepLinkWp; _deepLinkWp = '';
const dash = wantDash;
// The frame's identity is the PROJECT only. The view (form vs dashboard) and
// which package to open are applied by calling into the loaded document, so
// switching tabs never reloads it — reloading discarded unsaved form edits, made
// the creator unreachable offline, and stored a fresh copy in the SW cache each
// time. It's same-origin, so a direct call is fine.
const src = 'wp-creation-index.html?embedded=1'
+ (projId ? '&project=' + encodeURIComponent(projId) : '');
const applyNow = () => {
try {
const cw = frame.contentWindow;
if(!cw) return;
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
}
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
} catch(e){ /* cross-document timing; nothing useful to do */ }
};
// Wait for the creator's data, not just its document. `load` fires before
// pullProject() resolves, so opening a specific package straight after load
// silently found nothing.
const whenReady = () => {
try {
const cw = frame.contentWindow;
if(cw && cw.wpCreatorReady) { applyNow(); return; }
if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; }
} catch(e){}
applyNow();
};
if(frame.getAttribute('data-src') === src && frame.contentWindow){
whenReady();
} else {
frame.setAttribute('data-src', src);
frame.addEventListener('load', whenReady, {once:true});
frame.src = src;
}
}else{
gate.style.display = 'block';
frame.style.display = 'none';
@@ -874,7 +964,7 @@ function renderCustomConstraints(){
<strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -1021,6 +1111,13 @@ const DEFAULT_SOURCES = [
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
// so attribute values must be escaped or a re-render corrupts the field.
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in
// this order: backslash, then quote (for the JS string literal), then escAttr (for
// the attribute carrying it). Quote-escaping alone breaks on a value containing a
// backslash — the backslash escapes the backslash, the quote closes the literal, and
// the rest runs as code. Constraint names travel with the SOP to everyone on the
// project, so they are not this browser's own input.
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
function renderSources(){
const container = document.getElementById('sources-list');
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));

View File

@@ -333,6 +333,19 @@ body.embed-full { overflow: hidden; }
margin-top: 0.25rem;
}
/* Small helper text under a field. It's used on this page (step 2's CM hint, the
team-member notices) but its only rule used to live in wp-creation-styles.css,
which this page does not link — so it rendered as unstyled body text. */
.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; }
.field-hint strong { color: var(--text-light); }
/* The sign-off name pickers sit outside .field, so they got no form styling at all. */
.user-pick {
padding: 0.75rem; border: 1px solid var(--border); border-radius: 0;
font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg);
}
.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
/* ROLES */
.required-roles {
display: flex;

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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">
@@ -372,7 +375,13 @@
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
</div>
<!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none"></iframe>
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back
to the HTML default 300x150 box, and the service worker caches this page
and the stylesheet separately — so a browser can hold new HTML with old
CSS and collapse the creator to a tiny scrolling box. Inline attributes
survive any cache mismatch; the CSS below only refines them. -->
<iframe id="wp-frame" title="Work Package Creator"
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
</div>
</div>
@@ -423,7 +432,6 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="work-package-suite-app.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -35,6 +35,32 @@
--wpc-accent: #78a9ff;
}
/* ── archived-project banner ──────────────────────────────────────────────── */
/* Inserted by wp-chrome.js as the top bar's next sibling, so it sits directly
under the bar in normal flow and can never overlap it or eat its height (the
bar is sticky; this strip scrolls away under it). `flex: 0 0 auto` is for the
suite page, whose shell is a flex column — without it the strip would squash.
Amber tokens are the suite's warning set, same as the sync badge. */
.wpc-archived {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 16px;
background: #fdf6dd;
color: #8e6a00;
border-bottom: 1px solid #f1c21b;
border-radius: 0;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13.5px;
line-height: 1.35;
}
.wpc-archived-ico { flex: 0 0 auto; font-size: 14px; }
.wpc-archived-text { min-width: 0; } /* wraps instead of forcing a scrollbar */
@media (max-width: 620px) {
.wpc-archived { padding: 8px 12px; font-size: 13px; }
}
/* ── project switcher ─────────────────────────────────────────────────────── */
.wpc-proj { position: relative; flex: 0 0 auto; }
.wpc-proj-btn {

View File

@@ -167,6 +167,58 @@
.catch(function () {});
}
// ── archived-project banner ────────────────────────────────────────────────
// An archived project is still readable and still deep-linkable (?project=<id>),
// but every write now 409s. With nothing on the page to say so, that reads as a
// silent failure — so the bar, the one thing every page has, carries the warning.
//
// The state comes from GET /api/projects/<id>, never from "it's missing from the
// switcher": absence also means "you have no access to it", which is a different
// message. Fails closed and silent — an error means no banner, not a broken page.
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 ''; }
}
function buildArchivedBanner() {
var bar = el('div', 'wpc-archived');
bar.setAttribute('role', 'status');
bar.innerHTML =
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
'Unarchive it from the Admin Console to make changes.</span>';
return bar;
}
function checkArchived(host) {
var id = activeProjectId();
if (!id || !host || !host.parentNode) return;
var pinned = false;
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (p) {
if (!p || !p.archived) return;
// The home page reconciles the stored active project against the (now
// archive-filtered) list while this request is in flight, and drops it. If
// that happened, the id we asked about is nobody's context any more —
// banner-ing it would contradict the picker one line below. A ?project=
// deep link is pinned to this page and can't be cleared out from under us.
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
ProjectData.getActiveId() !== id) return;
// Also on the document element, so the two big apps can gate their own UI
// from CSS or a boot check without a second round trip. The server stays
// the real gate; this is only there so the UI can agree with it.
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
if (document.querySelector('.wpc-archived')) return;
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
})
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
@@ -325,6 +377,7 @@
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
checkArchived(m.host);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
}

View File

@@ -1378,123 +1378,231 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
// ── drawer behaviour ─────────────────────────────────────────────────────────
// Hover the edge handle to open, move the pointer away to close. Pinning keeps it
// open and shifts the form across; the pin is remembered. Touch devices have no
// hover, so tapping the handle opens it and it stays until you pick something,
// tap outside, or press Escape.
let _navCloseTimer = null;
// The panel's ESSENTIAL layout ships with this script rather than living only in
// wp-creation-styles.css. The two files are cached independently, so a browser can
// run new markup against an old stylesheet — and without these rules the panel is
// not merely unstyled, it's broken: its title, filter and buttons drop into the
// middle of the form as loose widgets (which is exactly what happened once).
// Injecting the floor here means the panel is always a positioned side panel; the
// stylesheet only refines its appearance.
const WP_NAV_CRITICAL_CSS = `
body{--nav-w:288px;}
body.wp-nav-collapsed{--nav-w:56px;}
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
border-right:1px solid #e0e0e0;}
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
.wp-nav-body{min-width:0;flex:1 1 auto;}
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.main{padding-left:calc(var(--nav-w) + 28px);}
body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group,
body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;}
/* Modals are hidden by a stylesheet rule; without it their contents render inline
in the middle of the form. Same reasoning as the panel: this is a floor. */
.modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;}
/* The jump bar's sticky offset is set inline by JS; give it a sane default so a
stale stylesheet can't park it behind the opaque header. */
.section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;}
`;
function openWpNav(){
clearTimeout(_navCloseTimer);
document.body.classList.add('wp-nav-open');
const h = document.getElementById('wp-nav-handle');
if(h) h.setAttribute('aria-expanded', 'true');
}
function closeWpNav(force){
clearTimeout(_navCloseTimer);
if(navPinned() && !force) return; // pinned stays put unless explicitly closed
if(force) setNavPinned(false);
document.body.classList.remove('wp-nav-open');
const h = document.getElementById('wp-nav-handle');
if(h) h.setAttribute('aria-expanded', 'false');
function injectWpNavCriticalCss(){
if(document.getElementById('wp-nav-critical')) return;
const st = document.createElement('style');
st.id = 'wp-nav-critical';
st.textContent = WP_NAV_CRITICAL_CSS;
// First in <head> so the real stylesheet (loaded later) still wins on every
// property it defines — this is a floor, not an override.
const head = document.head || document.documentElement;
head.insertBefore(st, head.firstChild);
}
// ── panel behaviour ────────────────────────────────────────────
// The panel is always present. The toggle collapses it to a 56px icon rail (badges
// only) and back; that choice is remembered. No hover-to-open: a list you navigate
// by shouldn't appear and disappear under the pointer.
let wpNavView = 'all'; // all | mine | open
function toggleWpNav(){
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
else openWpNav();
}
function navPinned(){ return document.body.classList.contains('wp-nav-pinned'); }
function setNavPinned(on){
document.body.classList.toggle('wp-nav-pinned', !!on);
const btn = document.getElementById('wp-nav-pin');
const collapsed = document.body.classList.toggle('wp-nav-collapsed');
try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
const btn = document.getElementById('wp-nav-toggle');
if(btn){
btn.classList.toggle('is-on', !!on);
btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
}
try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){}
positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step
closeWpNavMore();
track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
}
function toggleWpNavPin(){
const on = !navPinned();
setNavPinned(on);
if(on) openWpNav(); else closeWpNav(true);
track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
// Kept so older callers (and the suite shell) don't break.
function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
function toggleWpNavMore(ev){
if(ev) ev.stopPropagation();
const m = document.getElementById('wp-nav-more');
const b = document.getElementById('wp-nav-more-btn');
if(!m) return;
const open = m.hidden;
m.hidden = !open;
if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function closeWpNavMore(){
const m = document.getElementById('wp-nav-more');
const b = document.getElementById('wp-nav-more-btn');
if(m) m.hidden = true;
if(b) b.setAttribute('aria-expanded', 'false');
}
document.addEventListener('click', e => {
if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore();
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); });
function wpNavAction(what){
closeWpNavMore();
if(what === 'duplicate') duplicateWP();
else if(what === 'split') splitByDiscipline();
else if(what === 'export') exportPackages();
}
// The three views are filters over the same list, like Planner's My tasks / My plans.
function setWpNavView(view){
wpNavView = view;
document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => {
b.classList.toggle('is-current', b.getAttribute('data-view') === view);
});
renderWpNav();
track('wp_nav_view', {view});
}
// A stable colour per package so the same one is always the same swatch — the cue
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
// it doesn't shuffle when a package is added or deleted.
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
function wpBadgeColor(p){
const key = (p.number || p.id || '') + '';
let h = 0;
for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000;
return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length];
}
// Two characters for the badge: the package's own digits if it has them (WP-25 -> 25),
// otherwise the initials of its type, otherwise WP.
function wpBadgeText(p){
const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/);
if(digits) return digits[1];
const t = String(p.type || '').trim();
if(t){
const words = t.split(/\s+/).filter(Boolean);
return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2));
}
return 'WP';
}
function initWpNavDrawer(){
const nav = document.getElementById('wp-nav');
const handle = document.getElementById('wp-nav-handle');
if(!nav || !handle || nav.dataset.bound) return;
if(!nav || nav.dataset.bound) return;
nav.dataset.bound = '1';
// Hover in / out. A short close delay stops the drawer snapping shut while the
// pointer crosses the gap between handle and panel.
const armClose = () => {
clearTimeout(_navCloseTimer);
_navCloseTimer = setTimeout(() => closeWpNav(false), 350);
};
handle.addEventListener('mouseenter', openWpNav);
handle.addEventListener('mouseleave', armClose);
nav.addEventListener('mouseenter', () => clearTimeout(_navCloseTimer));
nav.addEventListener('mouseleave', armClose);
// Opening from the keyboard should work too.
handle.addEventListener('focus', openWpNav);
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNav(false); });
// Tap/click outside closes it (the touch path, where there's no mouseleave).
document.addEventListener('click', e => {
if(navPinned()) return;
if(!document.body.classList.contains('wp-nav-open')) return;
if(nav.contains(e.target) || handle.contains(e.target)) return;
closeWpNav(false);
});
try{ if(localStorage.getItem('wp_nav_pinned')) setNavPinned(true); }catch(e){}
injectWpNavCriticalCss();
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
const btn = document.getElementById('wp-nav-toggle');
if(btn){
const collapsed = document.body.classList.contains('wp-nav-collapsed');
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
}
}
function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return;
const cnt=document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
const badge=document.getElementById('wp-nav-handle-count');
if(badge) badge.textContent = savedPackages.length;
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
const list = document.getElementById('wp-nav-list');
if(!list) return;
const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
const me = myUserId();
// Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
if(!q) return true;
const p=r.p;
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
});
const all = savedPackages.map((p, i) => ({p, i}));
const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; };
setN('wp-nav-n-all', all.length);
setN('wp-nav-n-mine', mine.length);
setN('wp-nav-n-open', needs.length);
let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all);
const label = document.getElementById('wp-nav-sect-label');
if(label) label.textContent = wpNavView === 'mine' ? 'My packages'
: (wpNavView === 'open' ? 'Needs attention' : 'Work packages');
if(q){
rows = rows.filter(r => {
const p = r.p;
return [p.number, p.subject, p.type, p.location, p.system]
.some(v => String(v || '').toLowerCase().includes(q));
});
}
const cnt = document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : '';
if(!rows.length){
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
list.innerHTML = '<div class="wp-nav-empty">' + (
savedPackages.length
? (q ? 'Nothing matches “' + esc(q) + '”.' : 'No packages in this view.')
: 'No work packages yet. Fill the form and Save Draft.'
) + '</div>';
return;
}
const groups={};
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
const keys=Object.keys(groups).sort((a,b)=>{
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
return (ia<0?99:ia)-(ib<0?99:ib);
const groups = {};
rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
const keys = Object.keys(groups).sort((a, b) => {
const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
list.innerHTML=keys.map(k=>{
const label = k==='Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
const state = p.status==='Issue' ? 'on hold'
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
const active = (editingId && p.id===editingId) ? ' active' : '';
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
(p.type?' · '+esc(p.type):'')+'</span></button>';
}).join('');
list.innerHTML = keys.map(k => {
const heading = k === 'Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
groups[k].map(r => {
const p = r.p;
const open = (p.constraints || []).filter(c => c.status === 'open').length;
const waiting = wpWaitingOn(p).length;
const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
const state = p.status === 'Issue' ? 'on hold'
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
const active = (editingId && p.id === editingId) ? ' active' : '';
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
' title="' + esc(title) + '">' +
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
'<span class="wp-nav-body">' +
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
'</span>' +
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
'</button>';
}).join('');
}).join('');
}
// Open a package by id. The suite shell calls this instead of reloading the whole
// document with a ?wp= parameter, so unsaved edits and scroll position survive.
function openWpById(id){
const ix = savedPackages.findIndex(x => x.id === id);
if(ix < 0) return false;
wpNavOpen(ix);
return true;
}
function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return;
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav();
@@ -2007,9 +2115,21 @@ function bootData(){
renderSavedList();
positionSectionNav();
cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
// Deep-link: a specific package (?wp=<id>, from the global search), or the dashboard.
const p=new URLSearchParams(location.search);
const wantWp=p.get('wp');
if(wantWp){
const ix=savedPackages.findIndex(x=>x.id===wantWp);
if(ix>=0) wpNavOpen(ix);
else toast('That work package is not on this project (it may have been deleted).');
}
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
track('app_open');
// The embedding shell needs to know when the packages are actually in hand: the
// frame's `load` event fires long before pullProject() resolves, so anything that
// acts on a specific package has to wait for this instead.
window.wpCreatorReady = true;
try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){}
}
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
// Pull this project's shared SOP + Work Packages from the server first, then boot

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Package (IWP) — Prime Controls</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">
@@ -47,24 +50,62 @@
<div class="wp-layout">
<!-- WP NAVIGATOR — auto-hiding drawer. The handle is always visible; hover or tap
it to slide the list in over the form, or pin it to keep it open. -->
<button class="wp-nav-handle" id="wp-nav-handle" onclick="toggleWpNav()"
title="Work packages on this project" aria-label="Show work packages" aria-expanded="false">
Work Packages <span class="wp-nav-handle-count" id="wp-nav-handle-count">0</span>
</button>
<!-- WORK PACKAGE NAVIGATOR
A persistent side panel (not a hover drawer): collapse toggle, a primary action,
icon nav, then the project's packages as rows with colour-coded initial badges.
Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-head">
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div>
<button class="wp-nav-btn" id="wp-nav-pin" onclick="toggleWpNavPin()" title="Keep this list open" aria-label="Pin list open">📌</button>
<button class="wp-nav-btn" onclick="closeWpNav(true)" title="Close list" aria-label="Close list"></button>
<div class="wp-nav-top">
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="wp-nav-primary">
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
</button>
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false"></button>
<div class="wp-nav-menu" id="wp-nav-more" hidden>
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
</div>
</div>
<nav class="wp-nav-links" aria-label="Views">
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">My packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
</button>
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">All packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
</button>
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Needs attention</span>
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
</button>
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Dashboard</span>
</button>
</nav>
<div class="wp-nav-sect">
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
<span class="wp-nav-count" id="wp-nav-count"></span>
</div>
<div class="wp-nav-filter">
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
</div>
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
<div class="wp-nav-list" id="wp-nav-list"></div>
<div class="wp-nav-foot">
<button class="add-btn" onclick="newPackage()">+ New</button>
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
</div>
</aside>
<div class="main">
@@ -356,6 +397,5 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="wp-creation-app.js"></script>
<script src="wp-format.js"></script>
</body>
</html>

View File

@@ -105,7 +105,8 @@
width away from the form — which matters most when this page is embedded in the
suite's tab and every pixel is shared with the app chrome. */
.wp-layout { display: block; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px var(--gutter,34px);
.main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
transition: padding-left .18s ease; }
.section { display: none; }
@@ -438,7 +439,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px var(--gutter,34px); display:flex; align-items:center; gap:20px;
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -474,7 +475,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 var(--gutter,34px); }
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -582,7 +583,7 @@
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px 8px var(--gutter,34px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; }
@@ -649,108 +650,164 @@
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* ── WP NAVIGATOR — auto-hiding drawer ─────────────────────────────────────
Was a fixed 262px column, which (a) stole width from the form and (b) vanished
entirely under 1100px — so embedded in the suite tab it was never visible at
all. Now it slides in over the content from a slim edge handle: hover (or tap)
the handle to open, move away to close, or pin it open if you'd rather it stay.
Nothing is taken from the form unless you pin it. */
/* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
A persistent side panel in the spirit of MS Planner: collapse toggle, one
primary action, icon nav, then the packages as rows with colour-coded initial
badges and a highlighted current row. It sits IN the layout (the form shifts
across) rather than hovering over the content, and collapses to a 56px icon
rail so you can still see and switch packages with it closed. */
.wp-nav {
position: fixed;
top: var(--rail-top, 0px);
top: var(--rail-top, 48px);
left: 0;
bottom: 0;
width: 300px;
max-width: 86vw;
width: var(--nav-w, 288px);
z-index: 120;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border-strong);
box-shadow: 6px 0 22px rgba(20,30,50,.16);
transform: translateX(-100%);
transition: transform .18s ease;
background: #fbfbfc;
border-right: 1px solid var(--border);
overflow: hidden;
transition: width .16s ease;
}
body.wp-nav-open .wp-nav,
body.wp-nav-pinned .wp-nav { transform: none; }
/* Pinned: no shadow (it's part of the layout now) and the form shifts over. */
body.wp-nav-pinned .wp-nav { box-shadow: none; }
body { --nav-w: 288px; }
body.wp-nav-collapsed { --nav-w: 56px; }
/* The always-visible edge handle. Vertical so it costs ~26px of width. */
.wp-nav-handle {
position: fixed;
top: calc(var(--rail-top, 0px) + 14px);
left: 0;
z-index: 119;
display: flex;
align-items: center;
gap: 6px;
padding: 12px 5px;
writing-mode: vertical-rl;
background: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border-strong);
border-left: 0;
border-radius: 0 5px 5px 0;
box-shadow: 2px 0 8px rgba(20,30,50,.10);
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
/* -- collapse toggle -- */
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
.wp-nav-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; padding: 0;
background: transparent; border: 1px solid transparent; border-radius: 5px;
color: var(--text-muted); cursor: pointer;
}
.wp-nav-handle:hover { color: var(--accent); border-color: var(--accent); }
.wp-nav-handle .wp-nav-handle-count {
writing-mode: horizontal-tb; font-size: 10px; font-weight: 700; letter-spacing: 0;
background: var(--accent-dim); color: var(--accent); border-radius: 8px; padding: 0 5px;
.wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
/* Arrow flips to point right when the panel is closed. */
body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
/* -- primary action -- */
.wp-nav-primary { position: relative; display: flex; gap: 2px; padding: 6px 10px 12px; }
.wp-nav-cta {
flex: 1 1 auto; min-width: 0;
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
height: 40px; padding: 0 14px;
background: var(--accent); color: #fff;
border: 0; border-radius: 6px 0 0 6px;
font: inherit; font-size: 14px; font-weight: 600;
cursor: pointer; white-space: nowrap;
}
body.wp-nav-open .wp-nav-handle,
body.wp-nav-pinned .wp-nav-handle { display: none; }
.wp-nav-cta:hover { background: #0353e9; }
.wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
.wp-nav-cta-more {
flex: 0 0 auto; width: 30px; height: 40px;
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
}
.wp-nav-cta-more:hover { background: #0353e9; }
.wp-nav-menu {
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
}
.wp-nav-menu[hidden] { display: none; }
.wp-nav-menu button {
display: block; width: 100%; text-align: left; background: none; border: 0;
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer;
}
.wp-nav-menu button:hover { background: var(--surface2); }
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:12px 12px 8px; }
.wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
.wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
.wp-nav-btn { background:transparent; border:1px solid var(--border); border-radius:4px;
color:var(--text-muted); cursor:pointer; font-size:13px; line-height:1; padding:3px 7px; }
.wp-nav-btn:hover { border-color:var(--accent); color:var(--accent); }
.wp-nav-btn.is-on { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
.wp-nav-head .wp-nav-btn:first-of-type { margin-left:auto; }
.wp-nav-search { margin:0 12px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
.wp-nav-search:focus { outline:none; border-color:var(--accent); }
.wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
.wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
color:var(--text-dim); padding:10px 6px 5px; }
.wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
font-family:inherit; color:var(--text); }
.wp-nav-item:hover { background:var(--surface2); }
.wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
.wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
.wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
.wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
.wp-nav-dot.ok { background:var(--accent-green); }
.wp-nav-dot.open { background:var(--accent-amber); }
.wp-nav-dot.hold { background:var(--red); }
.wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
.wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
/* Keep the drawer's footer clear of the fixed save bar. */
body.has-sticky-save .wp-nav { bottom: 54px; }
/* Pinned: the page chrome shifts with the form so nothing hides behind the panel. */
body.wp-nav-pinned { --gutter: 328px; }
body.wp-nav-pinned .sticky-save { left: 300px; }
/* -- icon nav -- */
.wp-nav-links { padding: 2px 8px 10px; display: flex; flex-direction: column; gap: 1px; }
.wp-nav-link {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 9px 10px;
background: none; border: 0; border-radius: 6px;
font: inherit; font-size: 14px; color: var(--text);
cursor: pointer; text-align: left; white-space: nowrap;
}
.wp-nav-link:hover { background: #eef0f3; }
.wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
.wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
.wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
.wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* Narrow screens: never pin (there isn't the width) — overlay only. */
@media (max-width: 900px) {
body.wp-nav-pinned { --gutter: 34px; } /* no room to pin — overlay only */
.wp-nav { width: 290px; }
/* -- section header + filter -- */
.wp-nav-sect {
display: flex; align-items: baseline; gap: 6px;
padding: 6px 18px 4px; border-top: 1px solid var(--border);
}
.wp-nav-sect-label { font-size: 12px; color: var(--text-muted); }
.wp-nav-count { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.wp-nav-filter { padding: 4px 12px 8px; }
.wp-nav-search {
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
}
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
/* -- package rows -- */
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
.wp-nav-group {
font-size: 11px; font-weight: 600; letter-spacing: .02em;
color: var(--text-dim); padding: 12px 10px 4px;
}
.wp-nav-item {
position: relative;
display: flex; align-items: center; gap: 11px;
width: 100%; padding: 7px 10px; margin-bottom: 1px;
background: none; border: 0; border-radius: 6px;
font: inherit; color: var(--text); text-align: left; cursor: pointer;
}
.wp-nav-item:hover { background: #eef0f3; }
.wp-nav-item.active { background: #e8eaed; }
/* Left accent bar on the current package, like Planner's selected plan. */
.wp-nav-item.active::before {
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 2px; background: var(--accent);
}
.wp-nav-badge {
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
color: #fff; text-transform: uppercase;
}
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-subj { display: block; font-size: 12px; color: var(--text-muted); line-height: 1.35;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-state { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; color: var(--text-dim); white-space: nowrap; }
.wp-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); flex: 0 0 auto; }
.wp-nav-dot.ok { background: var(--accent-green); }
.wp-nav-dot.open { background: var(--accent-amber); }
.wp-nav-dot.hold { background: var(--red); }
.wp-nav-empty { padding: 14px 10px; font-size: 12.5px; color: var(--text-dim); }
/* -- collapsed rail: badges and icons only -- */
body.wp-nav-collapsed .wp-nav-cta-label,
body.wp-nav-collapsed .wp-nav-cta-more,
body.wp-nav-collapsed .wp-nav-link-label,
body.wp-nav-collapsed .wp-nav-link-n,
body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,
body.wp-nav-collapsed .wp-nav-body,
body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-group { display: none; }
body.wp-nav-collapsed .wp-nav-primary { padding: 6px 10px 10px; }
body.wp-nav-collapsed .wp-nav-cta { justify-content: center; padding: 0; border-radius: 6px; }
body.wp-nav-collapsed .wp-nav-link { justify-content: center; padding: 9px 0; }
body.wp-nav-collapsed .wp-nav-item { justify-content: center; padding: 6px 0; }
body.wp-nav-collapsed .wp-nav-list { padding: 6px 6px 14px; }
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
@media (max-width: 860px) {
body { --nav-w: 56px; }
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
}
/* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
.sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }

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

@@ -14,6 +14,22 @@
# 5. sudo nginx -t && sudo systemctl reload nginx
# ─────────────────────────────────────────────────────────────────────────────
# Cache-Control per file type. Computed in a map rather than a nested location
# because nginx's add_header is NOT inherited into a block that declares its own —
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
# empty value makes nginx omit the header, so images and fonts stay cacheable.
#
# Code must revalidate on every load: with no Cache-Control the browser applies
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
# gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index -> index.html
}
# Redirect plain HTTP to HTTPS
server {
listen 80;
@@ -40,6 +56,8 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;

View File

@@ -2,6 +2,23 @@
# This container sits behind an external reverse proxy that handles SSL.
# It listens on port 80 (plain HTTP on the internal Docker network).
# Cache-Control per file type, computed here rather than in a nested location.
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
# those files. Computing the value here keeps every header in ONE scope. An empty value
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
#
# Code assets must revalidate on every load: with no Cache-Control at all the browser
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
# file gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index → index.html
}
server {
listen 80;
server_name wp.controls.dev;
@@ -19,6 +36,8 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;

View File

@@ -1,9 +1,27 @@
#!/bin/sh
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
# cron daemon — so it works in a bare postgres:16-alpine image.
# Entry point for the `backup` sidecar container's periodic loop. Runs
# db-backup.sh on a fixed interval (default: daily) -- a sleep loop instead of
# a cron daemon, kept deliberately simple so it works in a bare
# postgres:16-alpine image.
#
# Resolves db-backup.sh the same way entrypoint.sh resolves this file: prefer
# the live bind-mounted copy at /scripts (so edits don't need a rebuild), fall
# back to the copy baked into the image at build time if the mount is
# missing, empty, or stale. Resolving fresh on every loop iteration also means
# that if the mount comes back healthy later (e.g. someone fixes the host
# directory) this container picks it up on the very next run, with no
# restart needed.
set -eu
resolve() {
# $1 = script filename, e.g. db-backup.sh
if [ -f "/scripts/$1" ]; then
echo "/scripts/$1"
else
echo "/app/scripts-default/$1"
fi
}
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
@@ -11,6 +29,7 @@ echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14},
# immediate restore point instead of waiting a whole interval.
sleep 20
while true; do
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
DB_BACKUP="$(resolve db-backup.sh)"
sh "$DB_BACKUP" || echo "[backup] run failed; will retry next interval" >&2
sleep "$INTERVAL"
done

View File

@@ -1,5 +1,19 @@
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
# at-rest encryption of dumps.
#
# The scripts are bind-mounted live at runtime (see the `backup` service in
# docker-compose.yml) so they can be edited without a rebuild -- but they're
# ALSO baked in here as a fallback default under /app/scripts-default/.
# entrypoint.sh prefers the live mount and only falls back to this baked-in
# copy if the mount is missing, empty, or stale. That fallback is what keeps
# a broken bind mount from crash-looping the container into an unreachable
# state (see entrypoint.sh for the full story).
FROM postgres:16-alpine
RUN apk add --no-cache openssl
COPY scripts/backup-cron.sh scripts/db-backup.sh scripts/db-restore.sh /app/scripts-default/
COPY scripts/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh /app/scripts-default/*.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD []

43
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
# Entrypoint for the `backup` sidecar. Prefers the live, bind-mounted copy of
# backup-cron.sh at /scripts (so it can be edited without a rebuild), and
# falls back to the copy baked into this image at build time if that bind
# mount is missing, empty, or stale.
#
# Why this exists: the previous entrypoint ran `/bin/sh /scripts/backup-cron.sh`
# directly. If that file wasn't there -- e.g. because the host directory
# backing the ./scripts bind mount hadn't been populated by whatever deploy
# process manages this stack -- the container failed instantly, and
# `restart: unless-stopped` retried in a tight crash loop forever: fast enough
# that the container was never "running" long enough for `docker exec` or
# Portainer's console to attach. That made the failure itself undiagnosable
# from inside the container -- you could only ever see it in the logs, and
# only by getting lucky with timing. This wrapper guarantees something always
# runs, and that the container always stays reachable, even in the worst case.
set -u
LIVE="/scripts/backup-cron.sh"
FALLBACK="/app/scripts-default/backup-cron.sh"
if [ -f "$LIVE" ]; then
echo "[entrypoint] using live scripts from /scripts (bind mount present)"
exec /bin/sh "$LIVE"
fi
echo "[entrypoint] WARNING: $LIVE not found." >&2
echo "[entrypoint] The ./scripts bind mount is missing, empty, or stale on the host." >&2
echo "[entrypoint] Check the directory backing that mount (see docker-compose.yml)." >&2
if [ -f "$FALLBACK" ]; then
echo "[entrypoint] Falling back to the scripts baked into this image at build time." >&2
echo "[entrypoint] Backups will still run, on whatever version was current when this" >&2
echo "[entrypoint] image was last built -- not any newer live edits to ./scripts." >&2
exec /bin/sh "$FALLBACK"
fi
echo "[entrypoint] FATAL: no backup-cron.sh in the bind mount or the image." >&2
echo "[entrypoint] Staying up (idle) instead of crash-looping, so this container" >&2
echo "[entrypoint] can still be reached via 'docker exec' / the Portainer console." >&2
while true; do
sleep 3600
done

View File

@@ -0,0 +1,48 @@
"""project archive + default members on new projects
Two changes that ship together:
• projects.archived_at — an archived project disappears from every picker,
switcher and search and is frozen read-only. Mirrors work_packages.archived_at
(47bbe76aa749): NULL means "live", which is what every existing row gets, so
nothing changes for current data.
• users.auto_add_projects / users.auto_add_role — a PM or QA lead who belongs on
every job is added to each new project automatically. auto_add_role shares
project_members.role's value space ('' = inherit the account's own role).
Revision ID: a7c31f9e5b02
Revises: d15b8c4ef207
Create Date: 2026-08-05 10:12:47.503914
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a7c31f9e5b02'
down_revision = 'd15b8c4ef207'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('projects', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_projects_archived_at'), 'projects', ['archived_at'], unique=False)
# These two are NOT NULL and land on a table that already has rows, so
# server_default is what backfills them (nobody is auto-added until an admin
# turns it on). The defaults are deliberately LEFT IN PLACE afterwards, as every
# other migration here does (18373f14809e, b41c7ae90d52, c93f2b1d7e04,
# d15b8c4ef207): dropping one needs ALTER COLUMN, which SQLite only fakes via a
# batch_alter_table table rebuild, and dev runs on SQLite (wpsuite.db). The ORM
# supplies both values on every INSERT, so the leftover default is only ever
# read by hand-written SQL — and there it is the answer we'd want anyway.
op.add_column('users', sa.Column('auto_add_projects', sa.Boolean(),
nullable=False, server_default=sa.false()))
op.add_column('users', sa.Column('auto_add_role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'auto_add_role')
op.drop_column('users', 'auto_add_projects')
op.drop_index(op.f('ix_projects_archived_at'), table_name='projects')
op.drop_column('projects', 'archived_at')

View File

@@ -163,13 +163,50 @@ 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",
)
def require_project_writable(db: Session, user_or_none, project_id: Optional[str],
what: str = "This change") -> None:
"""An archived project is frozen: everything on it stays readable, nothing on it
may be written. Every mutating path that lands on a project goes through here —
saving a SOP or a package, issuing, status changes, deletes, comments.
It is 409 and not 403 on purpose. Nobody lacks a permission here: the state of
the project is the objection, and even an admin gets refused until they unarchive
it — hence the caller identity is accepted for symmetry with the other guards but
deliberately unused. 409 also matters offline: the browser outbox in
html/project-data.js retires any 4xx op instead of retrying it forever, so an edit
queued before the archive dies quietly rather than looping against a frozen job."""
if not project_id:
return
proj = db.get(models.Project, project_id)
if proj is None:
# The project this write targets is gone — usually an outbox op queued before
# someone deleted the job. Refusing here is what keeps it a clean 409 instead
# of a foreign-key violation surfacing as a 500: the row could never be
# inserted anyway now that both engines enforce their FKs (see db.py). 409
# also matters because project-data.js retires a 4xx op and would retry a 5xx
# forever, so this is the difference between one quiet failure and a loop.
raise HTTPException(
status_code=409,
detail=f"{what} — this project no longer exists. It was deleted, so there is "
f"nothing to save it against.",
)
if proj.archived_at is not None:
raise HTTPException(
status_code=409,
detail=(f"{what} — this project is archived (read-only). An administrator "
f"can unarchive it from the admin console."),
)
def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user)
@@ -178,16 +215,207 @@ def scope_to_access(stmt, column, db: Session, user: "models.User"):
return stmt.where(column.in_(ids))
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
"""Add a (user, project) membership if it isn't already there."""
def grant_project_access(db: Session, user_id: str, project_id: str, role: str = "") -> bool:
"""Add a (user, project) membership if it isn't already there, carrying an
optional per-project role override ('' = inherit the account's own). Returns True
only when a row was actually added, so a caller can report what it did. Does NOT
commit — the caller owns the transaction."""
exists = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id)
& (models.ProjectMember.project_id == project_id)
)
)
if not exists:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
if exists:
return False
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id, role=role))
return True
def add_default_members(db: Session, project_id: str, actor) -> list[str]:
"""Put the standing default members onto a brand-new project.
Some people belong on every job the moment it exists — the PM who runs them all,
the QC lead — and flagging their account (users.auto_add_projects) is how an admin
says that once instead of remembering it at every project creation. App admins are
skipped because they already reach every project, an inactive account is not
revived by a new job, and an existing membership is never duplicated or
overwritten. Returns the usernames added; does NOT commit, matching
grant_project_access."""
rows = db.scalars(
select(models.User).where(
(models.User.auto_add_projects.is_(True)) & (models.User.is_active.is_(True))
).order_by(models.User.username)
).all()
added = []
for u in rows:
if auth.is_admin(u):
continue
if grant_project_access(db, u.id, project_id, (u.auto_add_role or "").strip()):
added.append(u.username)
if added:
# One line for the batch, not one per person — this is a single automatic act.
log_event(db, actor, "project_access_granted", "project", project_id,
project_id=project_id, summary=f"{len(added)} default member(s) added",
detail={"users": added, "reason": "auto_add_projects"})
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 ────────────────────────────────────────────────────────────────
@@ -335,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):
@@ -381,6 +613,13 @@ class ProjectAssignIn(BaseModel):
roles: dict[str, str] = Field(default_factory=dict)
class AutoAddIn(BaseModel):
auto_add: bool
# Role to give this user on the projects they're auto-added to; '' inherits the
# account's own role, same value space as ProjectMember.role.
role: str = ""
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@@ -539,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) ─────────────────────────────────────────
@@ -603,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(),
@@ -629,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(
@@ -690,78 +1053,161 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
old_role = u.role
u.role = body.role
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
detail={"from": old_role, "to": body.role})
detail = {"from": old_role, "to": body.role}
# Promoting someone to admin retires their default-member flag: an admin already
# reaches every project, so the flag would do nothing except sit there invisibly
# (the console shows admins no controls) and come back to life the day they are
# demoted. Same reasoning as clearing the role in set_user_auto_add.
if body.role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
u.auto_add_projects = False
u.auto_add_role = ""
detail["auto_add_cleared"] = True
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 = 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, actor, "project_role_changed", "user", u.id, summary=u.username,
detail={"from": old, "to": u.project_role})
db.commit()
db.refresh(u)
return directory_entry(db, u, actor)
@app.post("/api/auth/users/{user_id}/auto-add")
def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Flag a user as a default member of every project created from here on, with
an optional role on those projects. It only touches NEW projects — existing
assignments stay under the admin's hand (see set_user_projects), because
back-filling everyone onto historical jobs is never what this flag means.
App-admin only, unlike the rest of user administration: this is a standing rule
about every project that will ever exist, including the ones a super user has no
part in."""
allowed = ("",) + auth.PROJECT_SCOPED_ROLES
role = (body.role or "").strip()
if role not in allowed:
raise HTTPException(
status_code=400,
detail=f"role must be '' (inherit) or one of {', '.join(auth.PROJECT_SCOPED_ROLES)}",
)
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
old = u.project_role or ""
u.project_role = (body.project_role or "").strip()[:120]
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
detail={"from": old, "to": u.project_role})
u.auto_add_projects = bool(body.auto_add)
# A role left behind on a switched-off flag is a trap: it would quietly take
# effect the day someone switches the flag back on.
u.auto_add_role = role if u.auto_add_projects else ""
log_event(db, admin, "auto_add_changed", "user", u.id, summary=u.username,
detail={"auto_add": bool(u.auto_add_projects), "role": u.auto_add_role})
db.commit()
db.refresh(u)
return u.to_dict()
@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},
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
"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)}}
@@ -775,6 +1221,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
is_new = proj is None
if not is_new:
require_project_access(db, user, proj.id)
require_project_writable(db, user, proj.id, "Editing a project")
if proj is None:
proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj)
@@ -789,18 +1236,36 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
db.commit()
# A project created by a non-admin auto-grants its creator access.
# A project created by a non-admin auto-grants its creator access. If that
# creator is ALSO a standing default member, this is the row that sticks —
# add_default_members below never overwrites an existing membership — so it has
# to carry the role they'd have been given, or someone whose flag says
# "Project Admin on every job" would silently land as a plain member on the one
# job they started themselves.
if is_new and not auth.is_admin(user):
grant_project_access(db, user.id, proj.id)
creator_role = (user.auto_add_role or "").strip() if user.auto_add_projects else ""
grant_project_access(db, user.id, proj.id, creator_role)
db.commit()
# …and anyone flagged as a default member joins at creation time too.
if is_new:
add_default_members(db, proj.id, user)
db.commit()
db.refresh(proj)
return proj.to_dict()
@app.get("/api/projects")
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
rows = db.scalars(stmt).all()
def list_projects(
archived: str = Query("exclude", description="exclude (default) | only | all"),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
stmt = scope_to_access(select(models.Project), models.Project.id, db, user)
if archived == "only":
stmt = stmt.where(models.Project.archived_at.is_not(None))
elif archived != "all":
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
return [p.summary() for p in rows]
@@ -827,14 +1292,46 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
return {"deleted": project_id}
@app.post("/api/projects/{project_id}/archive")
def archive_project(project_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Archive (or unarchive) a whole project — it drops out of every picker,
switcher and search, and freezes read-only, without losing a thing. This is how
a finished job gets out of everyone's way while staying on the record; delete is
still there for a job that should never have existed."""
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
# Takes the whole job out of circulation for everyone on it, so it sits at the
# same bar as deleting it. Note the deliberate absence of require_project_writable
# — unarchiving is the one write an archived project must still accept.
require_project_admin(db, user, proj.id, "Archiving a project")
was_archived = proj.archived_at is not None
if body.archived and not was_archived:
proj.archived_at = models.utcnow()
log_event(db, user, "archived", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
elif not body.archived and was_archived:
proj.archived_at = None
log_event(db, user, "unarchived", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
db.commit()
db.refresh(proj)
return proj.to_dict()
# ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops")
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
require_project_access(db, user, body.project_id)
# Both ends are checked: the project the SOP is being written INTO here, and the
# one it currently sits on below — so an archived job can be neither edited nor
# used as a source to move a SOP out of.
require_project_writable(db, user, body.project_id, "Saving a SOP")
sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None:
require_project_access(db, user, sop.project_id)
require_project_writable(db, user, sop.project_id, "Saving a SOP")
# The SOP is the project's baseline: once it's been completed, changing it
# is a Project Admin action. Authoring and revising a draft is open to any
# project member, including marking it complete the first time.
@@ -899,6 +1396,7 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_admin(db, user, sop.project_id, "Deleting a SOP")
require_project_writable(db, user, sop.project_id, "Deleting a SOP")
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
summary=(sop.name or sop.number or sop.id))
db.delete(sop)
@@ -1090,9 +1588,12 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
check_id(body.parent_id)
check_id(body.assignee_id)
require_project_access(db, user, body.project_id)
# Destination and origin both have to be live — see upsert_sop.
require_project_writable(db, user, body.project_id, "Saving a work package")
wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None:
require_project_access(db, user, wp.project_id)
require_project_writable(db, user, wp.project_id, "Saving a work package")
is_new = wp is None
old_status = None if is_new else wp.status
old_assignee = None if is_new else wp.assignee_id
@@ -1276,6 +1777,7 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
# Deleting a work package is irreversible — Project Admin only. A project_user
# who wants one out of the way can archive it instead (reversible).
require_project_admin(db, user, wp.project_id, "Deleting a work package")
require_project_writable(db, user, wp.project_id, "Deleting a work package")
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
db.delete(wp)
@@ -1291,6 +1793,7 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
require_project_writable(db, user, wp.project_id, "Issuing a work package")
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
wp.status = "Issued"
wp.issued_at = models.utcnow()
@@ -1307,6 +1810,7 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
require_project_writable(db, user, wp.project_id, "Changing a work package's status")
old_status = wp.status
# Same gates as /issue — this route must not be a way around them.
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
@@ -1329,6 +1833,9 @@ def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
# An archived project is frozen whole: its packages keep the archive state they
# had, and tidying inside it waits until the job is unarchived.
require_project_writable(db, user, wp.project_id, "Archiving a work package")
was_archived = wp.archived_at is not None
if body.archived and not was_archived:
wp.archived_at = models.utcnow()
@@ -1399,12 +1906,23 @@ def global_search(
pat = _like_term(term)
esc = "\\"
# An archived project is meant to be gone from view, and search is the one place
# it would otherwise come back — as the project itself, or as one of its packages
# or SOPs. So the archived ids are read once here and filtered out of all three
# result sets. Rows with no project at all (orphan/legacy, admin-only) survive
# explicitly: SQL's NOT IN drops NULLs, and they aren't on an archived job.
archived_pids = set(db.scalars(
select(models.Project.id).where(models.Project.archived_at.is_not(None))
).all())
proj_stmt = select(models.Project).where(
func.lower(models.Project.name).like(pat, escape=esc)
| func.lower(models.Project.number).like(pat, escape=esc)
| func.lower(models.Project.client).like(pat, escape=esc)
| func.lower(models.Project.site).like(pat, escape=esc)
)
if archived_pids:
proj_stmt = proj_stmt.where(models.Project.id.not_in(archived_pids))
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
@@ -1417,6 +1935,11 @@ def global_search(
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
)
)
if archived_pids:
wp_stmt = wp_stmt.where(
models.WorkPackage.project_id.is_(None)
| models.WorkPackage.project_id.not_in(archived_pids)
)
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
@@ -1424,6 +1947,10 @@ def global_search(
func.lower(models.Sop.name).like(pat, escape=esc)
| func.lower(models.Sop.number).like(pat, escape=esc)
)
if archived_pids:
sop_stmt = sop_stmt.where(
models.Sop.project_id.is_(None) | models.Sop.project_id.not_in(archived_pids)
)
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
@@ -1535,17 +2062,28 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
# ── Comments / feedback ──────────────────────────────────────────────────────
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
# A comment tied to a WP/SOP requires access to that resource's project, so
# a user can't write into another project's review thread.
# a user can't write into another project's review thread. A review thread on an
# archived project is frozen with the rest of it; general app feedback isn't tied
# to a project at all and keeps working regardless.
#
# Both ids are checked independently — NOT if/elif. The row stores whichever ids
# the payload carried, so a body naming a WP you may touch AND a SOP you may not
# would, under an elif, be authorised on the WP alone and still land in the other
# project's SOP thread.
check_id(body.wp_id)
check_id(body.sop_id)
if body.wp_id:
wp = db.get(models.WorkPackage, body.wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
elif body.sop_id:
require_project_writable(db, user, wp.project_id, "Commenting on a work package")
if body.sop_id:
sop = db.get(models.Sop, body.sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
require_project_writable(db, user, sop.project_id, "Commenting on a SOP")
extra = body.model_extra or {}
c = models.Comment(
id=gen_id("c"),
@@ -1625,4 +2163,21 @@ def list_comments(
# Mounted LAST so the /api/* routes above always match first.
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
if os.path.isdir(_html_dir):
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
class _NoCacheCode(StaticFiles):
"""Serve code assets with Cache-Control: no-cache.
Production runs behind NGINX (which now sets this itself), but the dev server
is what people actually click around in — and with no header at all the
browser applies HEURISTIC freshness per file (~10% of the file's age), so the
least recently changed file gets the longest lifetime and HTML/CSS/JS drift
apart between reloads. ETag/Last-Modified still make revalidation a cheap 304.
"""
async def get_response(self, path, scope):
res = await super().get_response(path, scope)
if path.endswith((".html", ".css", ".js", ".webmanifest")) or path in ("", "/", "."):
res.headers["Cache-Control"] = "no-cache"
return res
app.mount("/", _NoCacheCode(directory=_html_dir, html=True), name="site")

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
@@ -33,6 +48,11 @@ class Project(Base):
division: Mapped[str] = mapped_column(String(200), default="")
site: Mapped[str] = mapped_column(String(300), default="")
sample: Mapped[bool] = mapped_column(Boolean, default=False)
# Archived projects are hidden from every picker, switcher and search but kept
# for the record — a finished job still has to be readable years later. Unlike
# an archived work package they are also FROZEN read-only: the API refuses any
# write to the project or to anything under it until an admin unarchives it.
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -42,7 +62,9 @@ class Project(Base):
return {
"id": self.id, "name": self.name, "number": self.number,
"client": self.client, "division": self.division, "site": self.site,
"sample": self.sample, "created_by": self.created_by,
"sample": self.sample,
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
@@ -125,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.
@@ -140,6 +163,13 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
# new project by hand, so flagged accounts get a ProjectMember row the moment a
# project is created. `auto_add_role` is the role they land with and shares
# ProjectMember.role's value space: '' = inherit the account's own role,
# otherwise 'project_admin' | 'project_user'.
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
auto_add_role: Mapped[str] = mapped_column(String(20), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
@@ -162,6 +192,8 @@ class User(Base):
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"auto_add_projects": bool(self.auto_add_projects),
"auto_add_role": self.auto_add_role or "",
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
}
@@ -173,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

@@ -79,8 +79,11 @@ def main():
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
st, projects = call("GET", "/api/projects")
# --clean: remove any prior demo projects (cascade removes their SOP + WPs).
# archived=all because /api/projects hides archived projects by default — an
# archived DEMO project is still a DEMO project, and --clean has to find it.
# (Deleting one is still allowed; only writes to its contents are frozen.)
st, projects = call("GET", "/api/projects?archived=all")
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
if args.clean:
for p in demos:

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,12 +264,36 @@ 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}")
# 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})
check("archive project", st == 200 and arch.get("archived") is True, f"status={st}")
st, lst = call("GET", "/api/projects")
check("archived project drops out of the default list",
st == 200 and not any(p.get("id") == project_id for p in lst), f"status={st}")
st, lst = call("GET", "/api/projects?archived=all")
check("archived project is still there with archived=all",
st == 200 and any(p.get("id") == project_id for p in lst), f"status={st}")
st, refused = call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "edited while archived", "type": "Conduit Install",
"status": "Scheduled", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing to an archived project is refused (409)", st == 409, f"status={st} body={refused}")
st, unarch = call("POST", f"/api/projects/{project_id}/archive", {"archived": False})
check("unarchive project", st == 200 and unarch.get("archived") is False, f"status={st}")
st, _ = 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",
"status": "In Progress", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing succeeds again once unarchived", st == 200, f"status={st}")
finally:
# 12) 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}")
@@ -179,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()