Compare commits

...

59 Commits

Author SHA1 Message Date
2842ec996c Add session notes for 2026-09-23 (CR-019 / D18 work) 2026-09-23 15:06:02 -07:00
b2a083ac7c T11.6: retire the per-browser Usage report (CR-019)
Removes the old "Usage logs" card from admin.html/admin.js (the
usage-admin panel + loadUsage(), D5/T7.10) now that T11.5's real,
server-side Activity & usage card exists and reads real per-user data
instead of per-browser localStorage.

wp-usage.js is deleted outright, along with its three <script> includes
(admin.html, wp-creation-index.html, work-package-suite.html) - it had
no reader left once the panel above it was removed (the "download the
full event log" button lived only in that panel), and per the original
CR-019 decision record it was never reliably tied to a real identity,
so it was never a candidate data source for the new report either.

Its two call sites (wp-creation-app.js, work-package-suite-app.js) keep
a local track() function as a documented no-op rather than having each
of their ~45 individual track('event', ...) call sites deleted one at a
time - that would be a much larger, riskier diff for the same outcome
(no data is recorded either way), and each call site still marks what
was worth recording if usage analytics are ever rebuilt server-side.
work-package-suite-app.js's dwell-timer plumbing (_stepEnter /
trackStepDwell), which only ever fed track(), was left in place for the
same reason: inert, not broken.

Also removes tests/usage_check.py, which tested exactly the retired
feature, and updates its line in docs/reference/file-map.md to point at
the 2026-09-17 decision record instead.

Verified:
  - grep across the whole repo for WPUsage / wp-usage.js / usage-admin /
    usage_check: no live references remain, only explanatory comments
    and planning docs (decisions-2026-09-17.md, wave-11.md) that
    describe the removal itself
  - node --check on all three touched .js files: no syntax errors
  - full backend smoke test (27/27) and seed_demo.py still pass
  - tests/baseline_shots.py --pages admin,creator,sop at 390px/1440px,
    run locally: all three pages render with no new JS errors (the one
    "beforeunload" log line on sop/creator at 1440px is pre-existing
    harness noise from wp-autosave.js's unsaved-work guard, unrelated
    to this change) and no horizontal overflow; refreshed baseline
    screenshots committed alongside this change
2026-09-23 14:46:32 -07:00
0b5ab59518 T11.5: verify Activity & usage card at 390px/1440px 2026-09-23 14:40:30 -07:00
a6c3fbfe50 T11.5: admin console Activity & usage card (CR-019)
New card in admin.html/admin.js, above the old per-browser Usage logs
card (which T11.6 retires next). Filters (date range, project, user,
tool) drive GET /api/usage/summary; two export buttons call
GET /api/usage/export (raw / sanitized) and save the CSV via a Blob,
same download pattern wp-usage.js already uses.

Access: the card lives inside admin.html, already gated admin-only
client-side by gateByRole() (unchanged) - a non-admin never sees the
card. The underlying API is gated server-side by require_user_manager
regardless (admin or project_super_user on >=1 project), independent
of and stricter than the client-side gate, so a non-admin request is
refused even if someone reached the endpoint directly.

Accessibility (C1): every control is a real <input>/<select>/<button>,
keyboard-operable. Status/export banners use the existing '.banner' /
'*-banner' id convention, which console-util.js's MutationObserver
already turns into aria-live (role=status, or role=alert on a '.bad'
banner) - no new announcement plumbing needed. No new CSS: reuses
.toolbar/.banner/.card/.row/.kv/.users, so nothing here adds a second
token source (the token rule).

Verified so far:
  - node --check html/admin.js: no syntax errors
  - every id admin.js's new code references exists in admin.html
    (scripted diff against the full getElementById/id= sets)
  - live-server check: GET /api/usage/summary with the exact
    (possibly-empty) query string _activityFilters() builds returns
    the {active_users, per_user_last_active, by_tool, event_count}
    shape renderActivity() expects; project_id filter narrows
    correctly; GET /api/usage/export?sanitize=true returns
    text/csv with the expected header row
  - full smoke test + seed_demo.py still pass

NOT yet verified: rendering at 390px/1440px with before/after
screenshots (CLAUDE.md verification step). This sandbox has no
headless-capable browser (no chromium/msedge on PATH) and the
playwright/chromium download is blocked by this environment's
network allowlist, so tests/cdp.py's harness can't run here. Deferred
to T11.7, same as wave 10's browser checks — flagging rather than
skipping silently.
2026-09-23 12:31:55 -07:00
2de76d52e6 T11.4: usage export endpoint (raw + sanitized CSV)
CR-019 / wave 11. Adds GET /api/usage/export, reusing _usage_query()'s
scoped filters (from/to/project_id/username/tool) and the same
require_user_manager gate as /api/usage/summary. Two modes:

- raw (default): real usernames, for internal admin use.
- sanitize=true: usernames replaced with an HMAC-SHA256 pseudonym
  (keyed with auth.SECRET_KEY, 16 hex chars, 'u_' prefix) so the file
  can be fed into PowerBI or another external reporting tool without
  carrying real identities. HMAC chosen over a plain hash since the
  username space is small enough to brute-force a bare digest.

Both modes emit at, username, project_id, tool, event as columns and
deliberately omit the detail JSON column in both modes to avoid an
identity leak riding along inside free-form detail data. Response is
returned with a Content-Disposition: attachment header and a filename
that encodes mode + date.

Verified locally against a throwaway SQLite DB with two seeded users
and four seeded UsageEvent rows:
  - raw export contains the real usernames and matches the summary
    endpoint's event_count for the same session state
  - sanitized export contains no real username or email anywhere in
    the file body, across two independently-issued export calls
  - the same real user maps to the same pseudonym both within one
    export and across the two separate export calls
  - raw and sanitized rows line up 1:1 on at/tool/event for the same
    filter set
  - the tool= filter narrows the export the same way it narrows the
    summary
  - a plain project_user is refused with 403; an unauthenticated
    request is refused with 401
  - full smoke test (27/27) and seed_demo.py both still pass
2026-09-23 12:25:05 -07:00
8e863ae7d0 T11.3: usage aggregation endpoint with filters (CR-019)
GET /api/usage/summary: active-user counts by day/week/month, per-user
last-active, per-tool breakdown. Filters (from/to/project_id/username/
tool) combine. Gated by require_user_manager - same boundary as the User
Directory. A project_super_user is scoped to events tied to projects
they manage plus their own activity (managed_project_ids), never another
user's suite-wide activity outside that; an app admin sees everything.

_usage_query() factored out so T11.4's export can never disagree with
what this endpoint counted - same filtered row set, not two derivations.

Verified: admin sees all seeded events; a project_super_user scoped to
one of two projects correctly sees only that project's events plus their
own account-wide activity, and specifically does NOT see the admin's
other-project or no-project activity; date/tool/project filters each
narrow results correctly and combine.
2026-09-23 12:19:14 -07:00
6cde6e3f60 T13.1: idle timeout + absolute ceiling (D18)
Sessions now slide on activity (AUTH_IDLE_MINUTES, default 30) capped by
a hard ceiling from original sign-in (AUTH_SESSION_HOURS, meaning changed,
default 12 -> proposed 8). login_at carried across reissues so the ceiling
survives refreshes; pre-D18 tokens with no login_at fall back to iat.
Refresh is throttled (~IDLE_MINUTES/3) so the cookie isn't rewritten on
every request. Wired into auth_gate (server/app.py) - no DB hit, reads
only the already-validated claims.

Verified: 7 unit-level checks (fresh-token expiry, past-ceiling refusal,
throttling, mid-session extension, legacy-token fallback both live and
expired, idle cutoff itself) all pass, plus the full 27-check smoke
suite still passes end to end through the new middleware path.
2026-09-23 11:40:59 -07:00
358469531c D18: revise T13.1 to idle timeout + absolute ceiling
Matt asked whether idle time would be a better fit than a flat session
length. It is, but idle-alone weakens D18's own purpose - a continuously
active session would never force a fresh Okta recheck on its own. Decided:
both. AUTH_IDLE_MINUTES (new, default 30) slides the session on activity;
AUTH_SESSION_HOURS (existing var, meaning changes to an absolute ceiling,
default 12 -> proposed 8) caps it regardless of activity.

Docs only in this commit - implementation is T13.1, next.
2026-09-23 11:38:37 -07:00
850b78972b T11.2: capture usage events (CR-019)
POST /api/usage/ping writes one page_open UsageEvent per authenticated
page load, identity from the session (get_current_user), never from the
client. Wired from exactly one place - auth-guard.js's proceed(), after
wp-auth-ready - so this can't drift into six separate per-page copies.

okta_callback now also writes one login event per sign-in.

Verified locally: unauthenticated ping -> 401; a real fake-Okta sign-in
writes exactly one login row and one page_open row, no duplicates.
2026-09-23 11:24:49 -07:00
0652fa732d T11.1: UsageEvent model + migration (CR-019)
New usage_events table, separate from audit_log - see the model docstring
for why. Verified: applies and downgrades cleanly on SQLite, and the
postgresql-dialect --sql render has no risky defaults (the BL-027 class
of defect). No app wiring yet - that's T11.2.
2026-09-23 11:23:14 -07:00
75ac930d0c waves 11-13: planning docs for CR-019, CR-020, D18
CR-019 - usage/activity metrics (admin console), wave 11
CR-020 - bulk editing of users, wave 12
D18    - Okta/AD deprovisioning detection and auto-disable, wave 13

Raised by Matt Mabrey 2026-09-17. Decision record and task breakdowns
only in this commit - no feature code yet.
2026-09-18 09:21:29 -07:00
df20b8f18d wave-10: close out claim mapping and redirect URI, live in production
Confirmed by an actual live Okta sign-in after main (cc64c88) deployed: preferred_username is the right identity claim, and the redirect URI works. Matt matched his existing pre-Okta admin account rather than getting JIT-provisioned as a duplicate. Two deploy-time snags recorded, both Case B (config, not data): OKTA_CLIENT_ID/SECRET/ISSUER left empty in Portainer at first (caught cleanly by is_configured()), then OKTA_ISSUER missing its https:// scheme (surfaced as httpx.UnsupportedProtocol, not a deliberate app error - BL-028 still stands). Neither needed the backup. BTG pilot group now includes Cody and Cameron, awaiting Adrian.
2026-09-09 13:34:32 -07:00
cc64c88c3e T10.10: audit manage_users.py promote
cmd_promote() changed a user's role with no audit trail, unlike the
identical change from the web Admin Console (set_user_role() ->
log_event(), action "role_changed"). Not a new privilege - anyone
with container-exec access already has DB access directly, D16's own
trust-tier reasoning - but there was no record of who ran it or what
changed.

Now writes an AuditLog row matching set_user_role()'s shape, tagged
via:cli (mirrors JIT provisioning's via:okta_jit) since a container
shell exec carries no signed-in identity to attribute the change to.

Verified against a scratch SQLite db: audit row lands correctly, role
change persists, the no-such-user refusal still exits 1 clean.
2026-09-03 16:27:13 -07:00
dc13f9b0e3 T10.8: verification (390px/1440px, full suite, per-task done-when)
Walked T10.1-T10.5's claims against the actual code (env vars, routes,
JIT provisioning, the password-removal sweep, login.html) rather than
re-trusting this file's own prose. No drift found.

Full suite via the Docker runner: 39/41 files clean. token_check.py's
exit 2 is a harness mismatch (needs --out/--compare, not a bare run),
not a failure. generalinfo_check.py is 48/49 - the one failure is a
pre-existing rgba() shadow literal from the D11 Micron-assets merge,
confirmed via git show HEAD to predate this wave; logged as BL-031,
not fixed here. okta_auth_check.py re-run fresh: 22/22.

390px/1440px: baseline_shots.py captured all fourteen shots. Visually
confirmed login.html and users.html show the Okta-only sign-in and the
password-free admin UI at both widths.

Wave 10 complete.
2026-09-03 15:22:03 -07:00
d6eae0d846 T10.9: rollback-aware deploy runbook for the Okta cutover
New DEPLOY-runbook-2026-09-03.md, separate from the 2026-08-04 runbook.
Names the five new OKTA_* env vars, treats the pre-deploy backup as the
only way back once 1d60a608bb51 (drop_local_password) commits since its
downgrade() restores the column but not the data, and splits Rollback
into the fixable case (Okta app integration misconfigured, fix and
redeploy api, no data at risk) versus the severe case (abandoning Okta
for local-password code, which only a destructive backup restore can
reach). States D16's no-break-glass posture plainly.

D17 records the decision and why: staged-deploy-sequence docs and
backlog-only were both considered and declined in favor of the runbook.

Logged to backlog.md rather than fixed here: okta_auth.describe() has
no caller (BL-028), users.failed_attempts/locked_until are vestigial
(BL-029), DEPLOY-login-portal.md is fully stale (BL-030).
2026-09-03 14:25:18 -07:00
d7d1e93dd8 T10.6 - record what was built in wave-10.md 2026-09-03 13:48:08 -07:00
f023192b74 T10.6 - deployment docs and env var reference describe Okta, not the never-shipped LDAP config
D13 never shipped, so DEPLOYMENT.md, server/.env.example and server/README.md
still described the original local-password system as of this task starting -
POST /api/auth/login, bcrypt password_hash, create-admin with a prompted
password, self-service reset-password email flow, AUTH_RESET_MINUTES /
AUTH_RESET_COOLDOWN_SECONDS. All of that is gone as of T10.4; these three files
now describe what actually runs.

server/.env.example and DEPLOYMENT.md's env block both gain the five OKTA_*
variables (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, IDENTITY_CLAIM),
explained the same way AUTH_SECRET_KEY already was - what it does, where to
get it, what happens if it's missing.

Also updated, not originally named in T10.6's bullet but required for the
documented vars to actually reach a running container: docker-compose.yml's
api service sets environment: as an explicit allowlist, not env_file, so the
four new OKTA_* entries had to be added there too or .env would document
something that silently does nothing. OKTA_IDENTITY_CLAIM specifically is NOT
${OKTA_IDENTITY_CLAIM:-} - compose setting an env var to an empty string is
not the same as leaving it unset, and server/okta_auth.py's own default
(preferred_username) only kicks in when the var is truly unset. Mirrored the
same default in the compose file instead, or every deployment that leaves the
optional line commented out in .env would 503 on every sign-in looking for a
claim literally named "".

server/README.md: replaced the login-portal section with the Okta flow
(access gating is Okta's job, not this app's - roles/authorization stay
local), replaced "create the first admin" with the promote-not-create
bootstrap path (D16) and its no-break-glass posture, replaced the curl-based
login example in Quick Test with a pointer to smoketest.py's own
session-minting technique (there is nothing left to curl - Okta requires a
real browser).

DEPLOYMENT.md: same treatment for its own copies of the env block, the
Portainer var list, the users table's password_hash column, the auth
endpoints summary, the smoke-test walkthrough (WP_SMOKE_USER only, must run
inside the api container or local dev sharing AUTH_SECRET_KEY/DATABASE_URL -
no longer targetable from an arbitrary remote workstation), the entire
"Self-service password reset" section (replaced with "Sign-in and admin
bootstrap (Okta)"), and the project_super_user role description / exclusive-
scope bullet, both of which named "reset passwords" as something that no
longer exists.

Left alone, logged rather than fixed here per CLAUDE.md scope discipline:
- users.failed_attempts / locked_until columns are still in the schema and
  still reset to 0/None on every Okta sign-in, but nothing increments them
  anymore since local login() is gone - vestigial, not documented as active
  lockout behavior in either doc now, but not migrated away either.
- server/README.md's "Production - Docker Compose" section (### 1-5) is a
  self-contained alternate quickstart that already duplicated and diverged
  from the real root docker-compose.yml before this task; it uses env_file
  rather than an explicit allowlist so it isn't broken by this change, but
  it's still a second source of truth nobody asked this task to reconcile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:47:48 -07:00
78d1942a0e T10.7 - add an explicit validation note (22/22, 26/26, 71/71) 2026-09-03 13:34:30 -07:00
45aff9c423 T10.7 - record the real browser-suite verification
url_state_check.py and browser_check.py both need a real headless browser via
tests/cdp.py, which the dev sandbox T10.7 was built in does not have and
cannot install (no sudo, network egress allowlisted against every route tried
- apt, a user-space Playwright Chromium download, Docker Hub). Documented as
an open gap in that commit rather than claimed as done.

Run for real since, on a machine with Docker, through a small general-purpose
tool kept deliberately outside this repo (headless-py-test-runner - it has no
idea what repo it is pointed at, so it does not belong in a Work Package Suite
PR). url_state_check.py 26/26, including scenario 2's actual click-through of
the fake-Okta round trip this task built; browser_check.py 71/71. Gap closed -
wave-10.md now says so instead of leaving the question open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:33:17 -07:00
290c9b078c T10.7 - a fake-OIDC-provider test seam, mirroring ldap_fake.py
Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().

The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.

tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.

tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).

tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.

One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).

wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:11:24 -07:00
72b10283fc T10.5: login becomes an Okta redirect, not a form
server/app.py:
- okta_login(): validates and stashes ?next= (same-site path only) in the
  OAuth-state session before redirecting to Okta, so a deep link an assignment
  email carried (X1/CR-011/CR-014) survives the round trip instead of always
  landing on /index.html.
- okta_callback(): reads that stashed next= back (re-validated on the way out
  too - belt and suspenders against a crafted value) and redirects there on
  success. The two failure paths that used to raise a raw HTTPException -
  OAuthError (sign-in cancelled/failed) and a locally-disabled account - now
  redirect to /login.html?error=... instead: this route is reached by a full
  page browser navigation from Okta, not a fetch() call, so a JSON error body
  just looks like a broken page to whoever is signing in.

html/login.html + html/login.js: rebuilt as a single "Sign in with Okta" link,
replacing the username/password form and the forgot/reset-password views
(gone entirely - no local password exists to reset, per D15/D16/T10.4). Kept
the accessible error/ok banner pattern (role=alert / role=status) byte-for-
byte, since CLAUDE.md names this file as the reference other pages copy for
that pattern. login.js reads ?next= off its own URL (auth-guard.js's
goToLogin() already builds this, unchanged) and forwards it to
/api/auth/okta/login, and shows a plain-language message for ?error=disabled
/ ?error=cancelled, clearing the code from the address bar once shown. Sign-
out (auth-guard.js's wpLogout()) already redirected to login.html - untouched,
already satisfied "lands back on the app's own login page."

Uses a real <a href> rather than a JS-driven navigation, so it's a working
link even before login.js runs, and needs no keyboard/touch handling beyond
what a link gets for free (C1 accessibility).

Also fixed in passing (not a separate commit - this is what exposed it):
_safe_next_path() on the server and safeNext() in login.js enforce the exact
same rule (same-site path only, reject '//' and scheme URLs) so a crafted
?next= can't become an open redirect through a real Okta sign-in.

Verified: a fake-Okta-client round trip against the real app (SessionMiddleware
fix from the prior commit) confirms next= is honored end to end, a malicious
next= is rejected and falls back to /index.html, OAuthError redirects to
?error=cancelled, and a disabled account redirects to ?error=disabled. The
JS-side safeNext() was checked against the same cases directly in Node and
matches the server's validation exactly. login.js passes `node --check`;
login.html parses cleanly. Live 390px/1440px screenshots were NOT captured
this session - the environment's browser pane isn't signed in to view a
published preview of it, so that check needs to happen when this branch is
actually run and opened by a signed-in browser; the layout risk is low since
.card/.brand/.error/.ok/.foot are unchanged from the already-shipped file and
the only new CSS is one simple full-width block link.

wave-10.md T10.5 / D15 / D16
2026-09-03 11:27:26 -07:00
77f8f9f800 Fix T10.2: install SessionMiddleware, required by Authlib
okta_login() and okta_callback() (T10.2) both crash with a 500
(AssertionError: SessionMiddleware must be installed to access
request.session) against a real Okta client, because Authlib's
authorize_redirect() and authorize_access_token() both store/read OIDC
state and nonce in request.session. Never caught by T10.2's or T10.3's
own verification because every prior test mocked authorize_redirect /
authorize_access_token directly, bypassing Authlib's real implementation
entirely. Found while starting T10.5 and reproducing the real flow.

Adds starlette.middleware.sessions.SessionMiddleware, on its own cookie
(wp_oauth_state, distinct from the app's real session cookie wp_session)
with a short 10-minute lifetime and same_site=lax so it survives the
top-level redirect back from Okta. This cookie carries nothing but
ephemeral per-attempt OAuth state — no identity, no long-term secret —
so it reuses auth.SECRET_KEY rather than adding a new required config
knob. Reused in T10.5 to carry the post-login redirect target across
the same round trip.

Adds itsdangerous to requirements.txt — SessionMiddleware's hard
dependency, not previously needed anywhere in this app.

Verified: reproduced the crash against server.app with a fake (network-
bypassed) Authlib client and no SessionMiddleware, confirmed the
AssertionError, then confirmed the same request succeeds (302 to the
authorize URL, wp_oauth_state cookie set) once the middleware is added.

wave-10.md T10.2 (bug fix)
2026-09-03 11:13:28 -07:00
73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00
c74289aa0d D16: Okta admin bootstrap and break-glass posture; correct T10.4 scope
Decision, raised during T10.4 hazard review:

- Admin bootstrap: manage_users.py moves from creating an admin account to
  promoting an existing one, by username, on a row Okta's JIT provisioning
  (T10.3) already created. Rejected blind account creation — the exact
  OKTA_IDENTITY_CLAIM format is still unconfirmed by security, and a
  hand-typed username that doesn't match it produces an orphaned second
  account instead of promoting the real one. Ongoing (non-bootstrap) admin
  naming needs no new work: html/users.js's existing role dropdown already
  handles it.
- Break glass: none, by design, matching the precedent already on record
  for the abandoned LDAPS design (D13/D14) rather than assumed to carry
  over untested. If Okta is unreachable, the app is unreachable for
  everyone until Okta is restored. Rejected a toggleable emergency local
  login — it would reintroduce the stored credential D15 exists to
  eliminate.

Also corrects T10.4's scope in wave-10.md: hazard review found real call
sites of hash_password/verify_password/password_problem the original
bullet didn't name (create_user(), admin_reset_password(), users.js's
admin forms, browser_check.py/launcher_check.py fixtures), plus a
verification-gate ordering problem (smoketest.py and seed_demo.py
authenticate via POST /api/auth/login, which T10.4 removes, and both are
named explicitly in CLAUDE.md's verification section). Fixed by having
T10.4 switch both scripts to mint a session with auth.create_token()
directly, the same technique browser_check.py already uses, rather than
waiting on T10.7.

D16
2026-09-03 10:35:58 -07:00
7ed3cbec4c T10.3: identity matching and JIT provisioning for Okta sign-in
okta_callback() now completes the sign-in instead of stopping at the claim:

- Matches the OKTA_IDENTITY_CLAIM value to a local account via
  auth.find_user() (username or email, case-insensitive) — the same lookup
  login() already uses, so an account whose username mirrors its AD
  identity needs no migration.
- No match: JIT-provisions a new account at the lowest-privilege role
  (project_user), no project membership, no password hash. Access beyond
  that is still granted locally by an admin/project super user, same as
  any account created by hand via create_user(). Logs a user_created audit
  event (via: okta_jit) for parity with that route.
- Match found but is_active is False: blocked with the same 403 'Account
  is disabled' login() raises today. Okta granting the challenge does not
  override an account this app has disabled locally (D15: 'roles stay
  local').
- On success: issues the same session cookie login() does (auth.create_
  token / auth.set_session_cookie), then redirects the browser to
  /index.html — this route is reached by a full-page navigation from
  Okta's redirect, not a fetch call, so a redirect is required rather than
  the JSON body login() returns.

Verified with a fake Okta client against a throwaway SQLite DB: new
identity provisions correctly (role/email/name/no-password), a repeat
sign-in matches the existing row without duplicating it or touching a
role an admin has since changed, a locally-disabled account is blocked
despite a valid Okta claim, and a missing identity claim is rejected
before touching the database.

wave-10.md T10.3 / D15
2026-09-03 10:16:50 -07:00
a9e5ee3892 T10.2: Okta login-redirect and callback routes
Adds GET /api/auth/okta/login (redirect to Okta's authorize endpoint) and
GET /api/auth/okta/callback (exchange code, validate ID token, pull the
identity claim) to server/app.py, using the oauth.okta client from
okta_auth.py (T10.1).

Access gating is Okta's job, not this route's: only accounts assigned to
the app integration in Okta ever reach the callback, so there is no
app-side group/claim check layered on top (D15, wave-10.md T10.2).

Stops at NotImplementedError once the identity claim is in hand. Matching
that claim to a local account and issuing the session cookie is T10.3, kept
separate per the one-task-per-PR rule.

wave-10.md T10.2 / D15
2026-09-03 10:11:49 -07:00
0ee35ae4ed T10.1: Okta OIDC client dependency and config (wave 10) 2026-09-03 09:57:55 -07:00
044862acba Wave 10: task breakdown for the Okta OIDC build 2026-09-02 17:38:35 -07:00
31c548318b D15: retire D13/D14 (LDAPS, never deployed), move straight to Okta OIDC 2026-09-02 17:20:01 -07:00
8f117680b0 Merge branch 'fix/alembic-transaction-per-migration': truthful migration logs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
6034c08bad Alembic runs one transaction PER MIGRATION, not one for the whole chain
The 2026-08-21 crash at material_items printed 'Running upgrade' lines for
location_nodes and wp_files and then rolled all three back together - env.py
wrapped the entire run in a single transaction. The repair that followed
trusted those lines: material_items was hand-created, the version stamped to
head, and production ran for two days missing two tables it claimed to have.
Found 2026-08-23 when the locations import 500'd on UndefinedTable.

transaction_per_migration=True makes the log truthful: a crash keeps every
step that completed, and a stamp-to-head repair after a crash repairs ONE
migration, not an unknowable prefix of the chain. Verified: the full chain
still applies on a fresh scratch SQLite; the offline --sql render is
unchanged.

The production surgery (creating the two rolled-back tables from the offline
postgres render) is recorded in the session; no version stamp is needed
there - it is already, now truthfully, at head.

Items: BL-027's class, third finding; env.py infrastructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
17cabbd032 Merge branch 'fix/import-row-hazards': imports reject rows, never 500
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:38 -07:00
222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
Nick's real location list hit the production import and got 'Internal Server
Error' with no line number - BL-027's class again, three days after the
migration outage: Postgres enforces VARCHAR lengths and refuses control
bytes, SQLite shrugs at both, and the importers were only ever rehearsed on
SQLite. Reproduced both hazards locally (an over-long value and a NUL byte
import cleanly on SQLite; either 500s Postgres wholesale).

Both importers now validate per row, before any INSERT, so every dialect
answers the same way - with the line number and a reason:
- locations: control characters; names over 200; codes over 60; combined
  paths over 200 (checked where the path exists, with read-counts taken
  before the loop so a mid-loop rejection is not counted twice)
- materials: control characters; description/unit/code over 300/20/80

And the client stops lying about it: wp-list-import.js read every response
with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not
reach the server' while the server was answering fine. One tolerant reader
(text -> parse if it parses -> keep status) now serves import, add and patch;
a real error reads 'Import refused - HTTP 500'.

Pins: materials_check +2 (over-long and control-byte rows reject at line,
20/20), locations_check +1 (over-long name rejects at line, 59/59).

Items: CR-005, D6, BL-027 (second instance of its class; the probe-side
dialect guard it proposes is still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:37 -07:00
e31234beef Merge branch 'docs/bl-026-027': the outage's two lessons, backlogged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:32 -07:00
6057d05b98 BL-026 / BL-027 - the two lessons of the Aug 21 outage, logged
BL-026: no version stamp - 'is live current?' took mid-outage fingerprinting;
the fix shape is a git SHA baked at build, served by /api/health, shown on
the admin diagnostics card (D13 candidate).

BL-027: migrations rehearsed on SQLite only - the dialect drift class behind
the outage stays unguarded beyond the one pinned instance; the fix shape is
an offline postgresql-dialect render in the runbook and/or a probe.

Items: backlog only, no code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:30 -07:00
64eac0cbbb Merge branch 'fix/material-items-boolean-default': the Postgres deploy hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
8f280d4bd1 D6 hotfix - the material_items migration crashed Postgres at deploy
server_default=sa.text('1') on a Boolean: SQLite coerces integer 1, Postgres
refuses it (DatatypeMismatch: column 'active' is of type boolean but default
expression is of type integer) - so 'verified end-to-end on a scratch DB' was
true and insufficient, because the scratch DB was SQLite. Found in production
2026-08-21: the wp.controls.dev api container crash-looped on alembic upgrade
and the site served static pages with a 502 API until the table was created
by hand from the db container (identical DDL, alembic_version stamped to
a1b8c6d4e2f9, so this fixed migration is a no-op there).

Now sa.true() - which the location-taxonomy migration next door used
correctly all along, and which is why IT applied to production without
incident. materials_check gains the static pin: every Boolean server_default
in every migration must be sa.true()/sa.false(). Verified: alembic --sql
offline render for the postgresql dialect emits DEFAULT true; the full chain
still applies on a scratch SQLite.

Items: D6 (the migration), CR-013 surface. Probe: materials_check +1 static
check (its browser half was env-blocked today - headless browser would not
start; the fix is exercised entirely by the static half and the two renders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
a8e28bf3ab Merge branch 'feat/wp-suite-r3-housekeeping': the Aug 20 decisions, built
Nick's six answers (decisions-2026-08-20.md, evening section) plus the
approved housekeeping, one commit per item:

- F6 strict 2.0: the creator fits two screens at rest (1,954 -> 1,784px);
  form_structure_check 51/51 and the suite has ZERO red checks for the
  first time. Closes BL-022.
- Hold reachable from any status: recorded as-is, question closed.
- CR-014: bodies carry customer context (number - title, location, deep
  link), never document content; canary pins split to match the rule.
- CR-008 merged-PDF: KNOWN-ISSUES 3, decided not deferred.
- D12: the productivity factor (act/est) on the dashboard, server sums.
- BL-020 closed (keep the prompt). BL-021 fixed: the critical-reopen mail
  reaches the PM and CM at last (critical_reopen_check, 11, sink-verified).
- BL-024: the last 21 native dialogs onto the shared wp-dialog.js kit;
  app-wide native count is now zero (console_dialogs_check, 17).
- BL-025: the final second-brand-blue tint rebased; check widened.
- S13: already fixed at T1.6 - stale records corrected, incl. CLAUDE.md.
- CR-011 transport: EHLO pinned; DNS trouble was stalling every send ~5s.

Battery: 16 suites re-run, all green, no deliberate exceptions remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:28:09 -07:00
29c4cd313e S13 - already fixed at T1.6; the records said otherwise, now corrected
The housekeeping list carried S13 ('seed_demo.py does not sign in') from
completion.md and CLAUDE.md. It is not true and has not been since wave 1:
T1.6 (357712e) rewired seed_demo.py onto smoketest's opener - one cookie jar,
one login flow - and the file's own docstring says so. What actually happened:
the wave-1 exit checkbox was never ticked, and every later document inherited
the unticked box as fact.

Verified live before correcting anything, per the working rules: against a
throwaway server, seed_demo.py signs in as an admin, seeds the DEMO project
(7+ packages visible via the API), and --clean removes it, exit 0 both ways.

Corrected: the wave-1 exit box (ticked, with the reason), completion.md's S13
row (open -> built at T1.6, records error named), and CLAUDE.md's
verification step 4, which taught every future session the stale claim.

Item: S13 (closed as already-built; records corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:26:40 -07:00
8fe7b25cd8 BL-025 - the last tint of the second brand blue, and the grep that missed it
help.js's search-focus ring was rgba(37,99,214,.15) - the banned #2563d6 as a
space-free rgb triple, which slid past color_check's spaced grep ('37, 99,
214') from the day BL-008 removed the colour. C4's recorded exception
legitimately allows rgba ALPHAS as opacity recipes; the defect was the base
colour under the alpha. Rebased onto THE blue: rgba(15,98,254,.15).

color_check compares space-free and case-insensitive now, in both the theme
check and the consumer sweep, so no spelling of the dead blue can return.

Items: BL-025 (closed), C4, BL-008 lineage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:43 -07:00
560f0cb3cc BL-024 - the last 21 native dialogs, onto the shared kit
S1 counted 79 native dialogs app-wide and its tasks removed 58; the audit
found the rest on surfaces no S1 task named: admin.js (6), users.js (10), the
launcher's inline script (5). All 21 now go through wp-dialog.js - the T7.9
kit extracted as a self-injecting shared component: markup and styles land on
first use, styles are theme tokens only with its own wp-dlg-* class names (the
consoles' existing .modal styles are untouched), 44px targets on coarse
pointers, and the whole file is guarded so the creator's inline copy - which
owns the same-id markup in its HTML - still wins on its own page. The kit's
toast comes along (S10 role rules), since none of the three pages had one.

Conversion follows the T7.9 precedent: confirms -> wpConfirmDialog with named
ok-labels, the password prompt -> wpPromptDialog whose validate() finally
enforces min-12 AT the input (it was label-text-only before, server-enforced),
API failures with detail -> wpAlertDialog, small info/validation messages ->
the announced toast.

New probe console_dialogs_check (17): counts pinned at 0, kit guarded and
loaded by all three pages, and the users console driven live with natives
poisoned - reset a password end to end (short refused inline, good one accepted
by the server and announced), cancel a delete and prove nothing died.

Items: BL-024 (closed), S1 completed to zero app-wide, C1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:09 -07:00
24f60151e5 BL-021 - the critical-reopen mail reaches the PM and CM, at last
project_sop_team() read sop.data['project']; pushSOP stores every row as
data={sop, state}, so the project block is one level deeper. The lookup
returned [] for every real row, silently, and the on-hold email promised to
'Owner + PM + CM + distribution' has reached only owner + distribution since
the day it shipped. One line: the same nested-first tolerant read
project_qa_group has used all along (whose docstring logged this very bug).

New probe critical_reopen_check (11): the fixture writes the PRODUCTION shape
- a hand-built flat row would have passed against the bug, which is exactly
how it went unverified this long. Sink-verified end to end: assignee + PM +
CM and nobody else; constraint name, title, location, deep link and the house
footer in the body (the footer this body alone used to lack, fixed at CR-014).

Items: BL-021 (closed), CR-011 recipients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:17:31 -07:00
031dc6b995 D12 - the productivity factor, on the dashboard (was BL-023)
Nick's decision: 'find a spot on the dashboard.' The spot: an eighth metric
card beside Est./Actual hrs - actual/estimated to two decimals, green at or
under 1.0, red over. Both hour fields are optional (CR-017), so with nothing
to divide the card shows an em dash rather than vanishing: a metric that
disappears reads as 'no such measure', not 'nothing logged yet'. Server sums
(B4), the same m.est_hours/actual_hours its neighbours already render - zero
new fetches, and the card stays inside the block the metrics-failure path
skips, so an outage still shows the error panel and no cards.

aggregates_check gains the pin (16 -> 17): the card must equal the quotient
of the SERVER's sums, or the em dash when either sum is zero - derived, not
hardcoded. Backlog entry corrected in passing where it credited
/api/projects/{id}/summary with hour sums it never carried.

Items: D12 (decisions-2026-08-20.md), CR-017 read, B4 discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:14:15 -07:00
0f28a27441 CR-014 - bodies carry customer context and the link carries the content
Nick's decision, 2026-08-20: 'email bodies provide links back to the system.
we can talk about customers we just cant exposed their confidential
documents.' The T7.6-era rule (no customer IP at all, so number + link only)
is refined: context IN, content OUT.

- wp_titled() and wp_where() compose 'number - title' and the CR-004
  location (structured paths first, legacy free text second); the where-line
  is dropped entirely when unset rather than mailing 'Where: '.
- assign, qa-ready, qa-reject and hold bodies gain title + location. The
  scope summary the original CR asked for stays OUT - scope text is document
  content; the link is its summary. Rejection comments stay on the package.
- hold_body gains the house footer it alone lacked.
- kitting and material-request bodies adopt wp_titled for the same identity
  line (their delivery-location rule is unchanged).
- notify.py's docstring states the new rule where the transport documents it.

Pins flipped WITH the rule, reasons in code: qa_gate_check's location canary
is now asserted PRESENT in QA bodies; a new DESC_CANARY (document content) is
asserted absent from every message (40 -> 41 checks). The sink also gains a
decoded-body view: the em-dash switches smtplib to quoted-printable, whose
column-76 soft breaks made raw-payload substring pins pass or fail on luck of
line position - content pins now read the decoded body, header pins still
read the wire payload.

Battery: qa_gate_check 41/41, kitting_notify_check 17/17, mreq_check 19/19.

Items: CR-014 (rule per decisions-2026-08-20.md), CR-011 pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:11:37 -07:00
16afc56c0a CR-011 transport - pin the EHLO name; DNS trouble was stalling every send 5s
smtplib calls getfqdn() on every connect when local_hostname is not given, and
that reverse-DNS lookup blocks ~5s per send whenever DNS is slow or down (found
when the office link dropped today: qa_gate_check's sink saw one mail per ~5s
and its 12s waits timed out). Sends are sequential background tasks, so the
stall compounded across a notification batch - in production a QA transition
with a 3-person group would take 15+ seconds to finish mailing.

socket.gethostname() never touches the network; the EHLO name is now computed
once. Measured against the capture sink: 5.3s -> 0.3s for a two-recipient
batch. Server mail path otherwise untouched.

Item: CR-011 (the send path's transport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:05:43 -07:00
8efe624d5d F6 - strict 2.0: the creator fits two screens at rest (closes BL-022)
Nick's answer: 'strict 2.0'. The 154px overage was chrome, and every trim
densifies rather than deletes - A2's one-warning banner and the SOP identity
strip both stay:
- collapsed section rows 46 -> 36px on fine pointers (13 rows at rest was
  ~130px of the overage); coarse pointers keep the 44px tablet row (C1)
- ctx-bar 12 -> 7px padding; banner margin 14 -> 8, padding 11 -> 8
- .main top pad 22 -> 14 (bottom stays clear of the sticky bar)
- nav-row 24/24 -> 14/14

Measured at 1440x900: 1,954 -> 1,784px = 1.98 screens. form_structure_check
is 51/51 for the first time - the check never moved, the page now fits it.
mobile_check 24/24 (the coarse-pointer targets held).

Items: F6, BL-022 (closed), C1 preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:52:28 -07:00
24476c86a6 Decisions of 2026-08-20 (evening), recorded
Six answers from Nick: F6 is a strict 2.0 screens (build task, chrome
compresses); hold stays reachable from any status (T7.3 question closed);
CR-014 bodies get deep links and may name customer context but never embed
confidential document content; CR-008 merged-PDF becomes KNOWN-ISSUES 3
(decided, not deferred by accident); BL-023 becomes D12, the productivity
factor on the dashboard; BL-020 closed as decided-keep. Housekeeping
(BL-021, BL-024, BL-025, S13) approved to build on this branch.

Items: F6, CR-008, CR-014, D12, BL-020, BL-022, BL-023.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:45:58 -07:00
cc761c8f7d Merge branch 'feat/wp-suite-r2-implementation': the R2 plan, complete
All 66 items: the 55 of IMPLEMENTATION.md section 6, D1-D10 (decisions
2026-08-18), and D11 (the Micron asset picker from origin/Micron-Assets,
merged and adapted 2026-08-20). Nine waves, one task per commit, reconciled
item by item in docs/reference/completion.md.

Also carried: the C4 transparency regression fix (undefined token names),
the S8 glossary-class leak fix (the always-lit Issue pill), and the seven
findings of the D11 adversarial integration review.

Verification: ~31 self-contained probe suites in tests/ (~950 checks), run
one at a time; all green except form_structure_check's deliberate BL-022 red
(F6's 'roughly two screens' = 2.17, held open for a product answer).

Open items for the next revision are listed in docs/waves/backlog.md
(BL-020..BL-025, S13) and completion.md section 'For the next revision'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:34:17 -07:00
fb89b1f6e1 S8 fix - help centre glossary classes leaked onto the Issue (hold) status pill
help.js injects its stylesheet on every page, and its glossary pills used bare
class selectors (.pill-draft ... .pill-hold). The creator's Issue (hold) status
radio also carries the class pill-hold, so the injected rule painted that radio
error-red at ALL times - selected or not. Reported by Nick ('why is the issues
(hold) button illuminated at all times'), 2026-08-20.

Pre-existing, not from this branch: help.js has had the bare selectors since
the login-portal commit, and the creator's pill-hold class predates the R2
branch. Every glossary rule is now scoped to .ui-help-pill.pill-*, which the
glossary markup already carries. Verified live: unselected, the hold pill's
computed style now matches its neighbours exactly; selected, it is still the
red fill; the glossary's own Hold pill keeps its tint. helptip_check gains the
pin (13 -> 14): no bare .pill-* selector in help.js, ever again.

Item: S8 (the help component's app-wide surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:59:30 -07:00
8663d81af3 C4/D11 follow-up - the integration review's seven confirmed findings
An adversarial review (four lenses, every finding independently verified by two
skeptics told to refute it) ran over 2a5f6b3 and 8cf8c0f. Seven findings
survived; all seven are fixed here.

Against the C4 fix:
- help.js: the nav hover was renamed onto its own surface token, keeping a
  no-op T9.9 had introduced (two different grays had been mapped to one name).
  Hover is now --cds-layer-hover, the token that exists for exactly this.
- wp-creation-app.js: the drawer's critical CSS pre-painted --cds-layer-accent
  while the stylesheet paints --wp-nav-bg; now both paint --wp-nav-bg.

Against D11:
- wp-sections.js: the Assets toggle note still described the pre-D11 card
  ('Asset tags and controls.dev links') with a rationale the picker inverts.
- runAssetSearch: the result cap counted contains-matches before the exact and
  prefix tiers finished, so 500 alphabetically-early substring hits could evict
  the exact match - and Enter then added the wrong asset, ID-locked. The cap
  now bounds each tier; the scan always sees the whole catalog.
- addCatalogAsset: the one mutation in the section with no announced outcome
  was the successful pick. It now toasts (role=status), matching every sibling
  path (C1).
- assets_db.py: failures are remembered for FAIL_CACHE_SECONDS (default 30s)
  and a stale catalog is served over an error, so a Micron outage costs one
  CONNECT_TIMEOUT per window instead of one per page load stacking up in the
  shared sync threadpool until login itself stalls.
- assets_db.py: MICRON_ASSETS_CACHE_SECONDS='5m' no longer crashes the boot -
  a malformed knob on an OPTIONAL feature degrades to its default, loudly.

assets_check grows four regressions for these (27 -> 31): per-tier cap against
600 decoys, the announced pick, boot with a malformed knob, and the stable
cached 503. Battery: assets_check 31/31, color_check 5/5, sections_check ALL
PASS.

Items: C4, D11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 12:09:40 -07:00
8cf8c0f882 D11 - merge origin/Micron-Assets: the Micron asset picker, adapted to R2
Integrates Cody Schaefer's 7ef1fcd (written against pre-R2 main) per Nick's
instruction of Aug 20. The catalog lookup arrives whole: read-only /api/assets
backed by server/assets_db.py (one SELECT, env-only MICRON_DB_URL, 503-not-500
when broken, driver errors logged not propagated), the searchable picker with
CSV import and Excel column paste, catalog rows badged and locked to the DB's
casing, manual rows visibly unvouched, and graceful absent/unreachable states.

Three conflicts, resolved as unions of both sides' intent; the adaptations and
their reasons are recorded in docs/waves/decisions-2026-08-20.md:
- renderPackage: Cody's two-column asset table inside T9.1's sectioned
  add('assets', ...) frame, so the CR-006 toggle keeps governing the export.
- bootData: initAssetPicker() joins the R2 loads instead of replacing them.
- The asset card: his picker UI, plus role=status on the source note (C1).
- Six imported alert() calls converted to the creator's idioms: file errors
  through toast(msg,'alert') as the drawings uploader does; the instructional
  and summary messages through the T7.9 kit, which gains the one-button
  wpAlertDialog shape (BL-024's console conversions will want it too).

New probe: assets_check (27) - read-only structurally, unconfigured/broken as
first-class states, no credential echo, search ranking, casing canonicalisation,
import fallback + dedup, kit-not-native summary. One sections_check pin
re-pointed with the reason in code: normaliseAsset now stamps legacy rows
source:'manual' on load, so the CR-016 check compares content, not bytes.

Battery after merge: assets_check 27/27, creator_dialogs_check 20/20,
sections_check ALL PASS, export_check 20/20, helptip_check 13/13,
mobile_check 24/24, icon_check 5/5, color_check 5/5, form_structure_check
50/51 (the one red is BL-022, unchanged, deliberate).

Item: D11 (new scope, new id per the working rules). Out-of-scope note in
completion.md amended - 'no integration code exists' was true when written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:45:32 -07:00
2a5f6b3549 C4 fix - five undefined token names rendered surfaces transparent
The T9.9 token sweep pointed seven files (help.js, auth-guard.js, wp-format.js,
project-data.js, index.html, field.html, wp-creation-app.js) at Carbon names
the theme never defined: --cds-layer-01/-02, --cds-border-subtle-01/-strong-01,
--cds-layer-hover-01. theme-light.css carries no -01 suffixes. An undefined
var() invalidates the whole declaration, so the help-centre modal, the
change-password and language dialogs, the print popup's inlined values, the
creator nav drawer and the sync badge all rendered TRANSPARENT backgrounds -
reported by Nick against the help menu, 2026-08-20.

Renamed every consumer to the canonical tokens (--cds-layer, --cds-layer-accent,
--cds-layer-hover, --cds-border-subtle, --cds-border-strong), matched to the
hex each replacement originally stood in for. color_check gains check 3: every
var() consumed anywhere must resolve to a definition somewhere - the class of
this bug, pinned. Verified live: the modal computes rgb(255,255,255) over an
opaque gray nav, and the language dialog is opaque too. BL-025 logged for the
one wrong-base-colour rgba tint noticed in passing.

Item: C4 (regression in its own enforcement). Probe: color_check 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:27:12 -07:00
454bfa0fe1 T9.7 + wave 9 exit - all 65 items, reconciled
docs/reference/completion.md walks every item: the 55 from IMPLEMENTATION.md
section 6 and the 10 from decisions-2026-08-18.md. For each: status, the task
that delivered it, the probe that re-verifies it on every run, and every
deviation from written acceptance criteria - B7's page-not-merge (measured),
CR-014's email body (the no-customer-IP rule won), CR-008's merge-vs-list
(recommended, not decided), F6's 2.17-vs-2.0 (BL-022), S1's residual 21
dialogs (BL-024).

The four out-of-scope items are confirmed unbuilt - two of them by probes
that grep for their fields on every run. Section 8's outstanding inputs are
restated (the material workbook and the B100 list still have not arrived;
both upload paths are ready). The follow-ups for the next revision are in one
place, including three product questions raised in commit messages along the
way and the acceptance criteria that turned out wrong, for calibration.

One item in the whole plan is knowingly open: S13 (seed_demo sign-in),
carried with a reason, and F6's last number awaits a product answer.

Wave 9 exit criteria: seven of seven, ticked with their verifying probes.

Items: all

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:29:29 -07:00
23ee0b052f T9.9 - C4 + the backlog sweep: nine entries closed, each re-measured first
The colour half (C4, approved Aug 18 - "change them"):
- BL-004: the help centre's own 52-colour palette collapsed onto theme tokens
- BL-005: the JS-built dialogs (auth-guard, wp-format) and project-data's
  badges read tokens; the creator's categorical badge palette moved to
  theme-light as --wp-chart-1..10, read by computed style at boot; the print
  popup - a document with no stylesheet - inlines live token VALUES
- BL-008: the second brand blue (#2563d6) is deleted; .sop-inherited tints
  with THE blue at the same 7% alpha
- BL-009: the ninth amber (--wp-status-warning-text-alt) is deleted
- theme-light gained the two missing feedback tokens the consoles carried as
  literals (--wp-status-success-text / -error-text)
- NEW tests/color_check.py 4/4: zero hex literals outside theme-light.css,
  comments stripped (the BL-017 lesson), with the exceptions named in full
  (meta theme-color cannot resolve a var; rgba alphas are opacity recipes)

The correctness half, each re-measured before touching, as the task ordered:
- BL-011 STILL REPRODUCED: the sync badge mounted on the first async sync
  event; its holder now mounts at DOMContentLoaded, so the three overlays land
  in script order deterministically
- BL-012 fixed and MEASURED: baseline_shots freezes Date and Math.random per
  document; two consecutive admin captures came back byte-identical
- BL-016 fixed: a step-less wizard URL is step 1; stepper_check's deliberately
  wrong pin flipped with the fix, exactly as the entry planned
- BL-018 fixed both halves: the false-complete write now requires the
  {sop,state} production shape, and browser_check.seed writes that shape -
  which un-detoured four probes' creators from the SOP gate. stepper_check
  re-pointed at projB (no SOP) because its premise is a wizard someone is
  STARTING, and projA now legitimately restores a finished one.
- BL-019 fixed: a stored cost code that left COST_CODES is kept as an option
  (the gov_wosize pattern), so opening a package no longer blanks its record
- hold_check's AST sweep refined in passing detection: it flagged T8.3's
  notification-row .status as a release transition; it now reads wp.status only

Every wave-9-pointing backlog entry is closed with its measurement recorded.

Verification (each probe run alone): color_check 4/4, stepper_check 71/71,
validation_check 77/77, url_state_check 23/23, autosave_check 34/34,
a11y_check 22/22, launcher_check 58/58, aggregates_check 16/16,
kitting_check 26/26, hold_check 50/50, mobile_check 24/24, frame_check 38/38,
sections_check 95/95, form_structure_check 50/51 (BL-022's question).

Items: C4, BL-004, BL-005, BL-008, BL-009, BL-011, BL-012, BL-016, BL-018, BL-019

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:27:26 -07:00
771672273d T9.8 - D7: archiving stops reading as deletion - for project admins
Archiving already froze a project (the server refuses every write); what did
not exist was the way back in. Now:

- GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE:
  a project admin (or super/app admin) on THAT project sees it; everyone else
  receives an empty list from the same request - archived projects appear
  nowhere for them, counts and pickers included (the default listing already
  excluded them for everyone; asking is what got gated). Admin-on-Job-A does
  not surface archived Job B.
- The launcher gains a visibly separate, labelled "Archived projects"
  section (dashed border, read-only stated in words), rendered only when the
  server returns rows. Opening one makes it active; the launcher's reconcile
  learned that an active project whose stored summary says archived:true was
  opened ON PURPOSE and keeps it, while a project archived out from under
  someone still drops with the existing explanation.
- The creator shows ARCHIVED - READ-ONLY where the project is named (both
  ctx-bar branches, from the SERVER's answer - the page's project comes from
  the URL, so a stale local summary is not trusted) and refuses saves with a
  reason before the round trip. The courtesy; the server's refusal is the
  rule, verified by calling the endpoints directly (wp upsert AND the
  material-list write both refuse with "archived" even for an admin).
- No unarchive button, no second mechanism, and it fits at 390px.

Verification (each probe run alone): NEW tests/archived_check.py 15/15.
Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20,
frame_check 38/38.

Items: D7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:58:51 -07:00
6201fcfb4a T9.6 - C2: the 390px pass, measured on all seven pages
The deliberate mobile pass the original proposal never had. Driven page by
page at 390px with mobile emulation (the media queries under test actually
fire) by NEW tests/mobile_check.py, 24/24:

- no page scrolls sideways - all seven (the creator joined at T9.5 when
  BL-001 died)
- no visible control is clipped past the viewport (the probe learned
  frame_check's two lessons: an off-canvas drawer is PARKED, not clipped,
  and a row inside an overflow-x container is scrollable)
- tap targets: the shared coarse-pointer block in wp-chrome.css puts every
  button, input, select, nav link and appbar control at a 44px minimum on
  phone widths and coarse pointers; checkboxes, radios and help-tip badges
  get the 24px WCAG floor with spacing doing the rest. Field View - the
  gloved-hands surface - measures 44px on EVERY control. Inline text links
  are exempt per WCAG 2.5.8's own exception. Even the deliberately
  unobtrusive dev toggle grew to the floor: subtle by opacity, not by size.
- CR-007 attachments offline at 390px and T8.5 requests at 390px were already
  pinned by files_check and mreq_check; this pass cites rather than repeats.

After-screenshots for all seven pages at 390px are committed in
docs/reference/baseline/after-wave9, beside the wave 0 set, captured by the
same baseline_shots.py fixture.

Verification (each probe run alone): NEW tests/mobile_check.py 24/24.
Regressions: form_structure_check 50/51 (BL-022's standing question),
files_check 36/36.

Items: C2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:47:17 -07:00
0dcea8d725 T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:37:10 -07:00
b83f2fd8d5 T9.3 - S6: one icon system - monochrome text glyphs, one meaning each
The set mixed colour emoji with dingbats, and the same glyph read as two
things partly BECAUSE emoji render as per-platform artwork. The system chosen:
monochrome text-presentation glyphs - the suite is classic-script vanilla HTML
with no bundler, so an SVG sprite or icon font is a new asset pipeline, while
text glyphs render through the same font stack as the words beside them. The
enforceable form of "renders identically on Windows, macOS and a tablet":
no emoji-range codepoint and no U+FE0F selector anywhere in UI source,
swept by the probe on every run.

Converted: green-check/red-cross emoji in the admin and users consoles to
checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the
pencil already meaning "edit with a logged reason" on sign-offs, the star to
the diamond, the folder to the reference marker, the side nav's lightning to
the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per
Unicode) to a text-presentation clock face. Dropped where the label already
carried the meaning: lightning on Save & view, the camera on Add photo, the
page/frame pictograms on file rows (the filename is the label). Stale help
copy fixed while its emoji left: it still described the pre-T9.4 "Load
sample" and the pre-T7.10 "Usage Logs" locations.

The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per
glyph, one glyph per meaning, both directions asserted from the document
itself; the probe also sweeps every page for glyphs not in the approved set,
so an unmapped icon cannot creep in.

Verification (each probe run alone): NEW tests/icon_check.py 5/5.
Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22,
cards_check 44/44.

Items: S6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:15:59 -07:00
7ef1fcdd96 Add Micron asset picker to work package creator
Adds an optional read-only Micron asset catalog lookup for the WP creator, with searchable asset IDs, CSV import, and graceful fallback to manual asset entry when the catalog is absent or unreachable. This includes the backend /api/assets endpoint, SQL Server connector configuration, Docker network changes for outbound access, and UI updates/documentation to make the catalog read-only and clearly distinguish Micron-vetted assets from manual entries.
2026-08-18 14:56:55 -05:00
113 changed files with 7336 additions and 1529 deletions

View File

@@ -93,7 +93,7 @@ checks. In addition, for any task touching the frontend:
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
gloved-hands surface and is where the worst rendering was found.
3. Capture before and after screenshots into the PR.
4. Run the existing smoke test. It signs in; `server/seed_demo.py` does not, which is S13.
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why.

View File

@@ -0,0 +1,355 @@
# Deploy runbook: WP Suite Okta cutover (wave 10)
**For:** IT / whoever administers the Docker host and Portainer
**From:** m.mabrey@prime-controls.com
**Revised:** 2026-09-03. First version of this runbook.
**Expected duration:** 20-30 minutes, including the backup and the live sign-in check
**Expected downtime:** under a minute, while containers are recreated
This is a separate runbook from `DEPLOY-runbook-2026-08-04.md`, not a revision of it.
That one still applies to its own deploy. Read this one in full before starting.
It has a step that the earlier one does not: this deploy removes the local password
system entirely, and one part of that removal cannot be undone by the usual means.
See "What this deploy changes" below.
---
## 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/`) | `________________` |
| A real Okta account assigned to the app integration, for the live check in Step 4 | `________________` |
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
Sign-in changes from a local username/password to Okta OIDC, completely. Not a
toggle, not a fallback. Three things make this more than a routine deploy:
1. **Five new environment variables are required**, and without them nobody can
sign in: `OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`,
`OKTA_REDIRECT_URI`, and optionally `OKTA_IDENTITY_CLAIM`. This is a genuine
change from how deploys usually go here. Do not skip Step 1.5.
2. **A migration drops the `password_hash` column** (`1d60a608bb51`), and it is
**one-way in practice.** Its `downgrade()` re-adds the column, but empty.
The real password hashes are gone the moment this commits, and no `alembic
downgrade` brings them back. If this deploy needs to be undone after that
point, going back to the old local-password code does not work on its own.
See the Rollback section, Case C. This is why Step 1's backup is not
optional the way it sometimes reads in other runbooks.
3. **There is no break-glass path**, by design (recorded decision D16). If Okta
is unreachable or misconfigured after this deploy, the app is unreachable for
everyone, admins included, until Okta is fixed. That is expected behavior,
not a bug to roll back from. See Rollback, Case A/B, before assuming
something is broken.
The visible change for people using the app: the login page becomes "Sign in
with Okta" instead of a username/password form. Nothing else in the app's
day-to-day behavior changes.
---
## 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.
Expected output of the first command before this deploy: `a1b8c6d4e2f9 (head)`.
If it shows anything else, stop and check with me before continuing. This
runbook assumes that starting point.
---
## Step 1: Back up the database
On the Docker host:
```bash
docker exec wp_db_backup /scripts/db-backup.sh
```
Expected output ends with a line like:
```
[db-backup] wrote 1.4M /backups/wpsuite-20260903-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.
This backup matters more than usual for this deploy. Once the migration in
Step 3 commits, this file becomes the *only* way to get local password hashes
back, for any reason. Treat it as the point you would restore to, not routine
housekeeping.
- 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 1.5: Confirm the Okta app integration is actually ready
Do this before redeploying, not after. Everything here is checked in Okta's own
admin console and in the values that will go into the stack's environment
variables. Nothing touches the WP Suite host yet.
1. The Okta app integration exists (Sign-in method: OIDC, Authorization Code,
Application type: Web Application), and the account listed in the fill-in
table above is assigned to it.
2. `OKTA_REDIRECT_URI` matches a "Sign-in redirect URI" registered on that app
integration **exactly**: scheme, host, and path, including whether it ends
in `/api/auth/okta/callback`.
3. `OKTA_ISSUER`, `OKTA_CLIENT_ID`, and `OKTA_CLIENT_SECRET` are the values from
that same app integration, not a different one.
4. If your Okta configuration puts the directory identity somewhere other than
the `preferred_username` claim, `OKTA_IDENTITY_CLAIM` is set to the right
claim name. If unsure, leave it unset; `preferred_username` is the default.
Add all five to the stack's **Environment variables** in Portainer now, before
Step 2. `OKTA_CLIENT_SECRET` should be handled the same way `AUTH_SECRET_KEY`
already is: not typed anywhere it will be logged.
If any of items 1-3 above are not yet confirmed, stop here and get them
confirmed first. A wrong redirect URI or an unassigned account will not corrupt
anything, but it does mean nobody signs in after this deploy until it is fixed.
See Rollback, Case B, which is the ordinary way that gets fixed and does not
involve the database at all.
---
## 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 pick up new code, and will not pick
up the environment variables added in Step 1.5 either.
---
## 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 migration cleanly:
```bash
docker logs wp_api --tail 40
```
You are looking for an Alembic `Running upgrade a1b8c6d4e2f9 -> 1d60a608bb51`
line followed by gunicorn starting up, and no traceback. The API refuses to
start if a migration fails, so a restarting `wp_api` container means it failed.
Go to Rollback, Case B, and read the "did the migration commit" note there
before doing anything to the database.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `1d60a608bb51 (head)`. **Once you see this, you have passed the
point of no return described above.** The backup from Step 1 is now the only
way back to a working local-password system, if that is ever needed.
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 Okta sign-in actually works, live
This is the step that matters most for this deploy. A clean container start
does not by itself prove sign-in works, and there is currently no startup log
line that confirms Okta config is good (logged separately as a follow-up, not
fixed as part of this runbook). The only real proof is a live sign-in.
1. Open the site's normal URL in a private/incognito window. It should land on
`login.html` with a "Sign in with Okta" button, not a username/password
form.
2. Click it. You should be redirected to your organization's actual Okta
sign-in page (the real Okta domain from `OKTA_ISSUER`, not this app's own
domain).
3. Sign in with the account from the fill-in table. You should land back on
the WP Suite site, signed in.
4. If this is the account's first-ever sign-in, it is now JIT-provisioned as a
regular user (`project_user`). To make it an admin, on the Docker host:
```bash
docker exec -it wp_api python -m server.manage_users promote <username> --role admin
```
This only works on an account that has already signed in once through Okta.
It promotes an existing row; it does not create one. That is deliberate
(recorded decision D16): there is no other admin-bootstrap path.
If step 2 or 3 fails (redirected to an Okta error page, redirected back to
`login.html` with an error, or nothing happens), this is almost always a
configuration problem from Step 1.5, not a code or database problem. Go to
Rollback, Case B, before considering anything more drastic.
Also confirm the API is reachable through the proxy and the redirect itself is
wired up:
```bash
curl -s https://<site-url>/api/health # -> {"ok": true}
curl -sI https://<site-url>/api/auth/okta/login | grep -i ^location # -> your Okta authorize URL
```
---
## Step 5: Hard-reload once in a browser, then sanity-check
Press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service
worker; a normal reload can serve the previous version.
1. Signed in as the account from Step 4, the home page offers to select or
create a project, same as before.
2. Open **Admin Console** as the promoted admin account. The user table shows
the account you just signed in with. There is no password column, no
"reset password" action anywhere in the UI.
3. Open a project and confirm a work package can be opened and edited
normally. Sign-in is the only thing this deploy changes, so the rest of
the app should look untouched.
**Deploy complete.** Please report back: the Step 0 output, the backup
filename from Step 1, and confirmation that Step 4's live sign-in worked.
---
## Rollback
Read this before assuming a rollback is needed. Cases A and B below do **not**
touch the database and are the far more likely outcome of something going
wrong here. Okta configuration is fiddly and easy to get slightly wrong. Case
C is the severe, destructive one, and should be a last resort, not a first
reaction.
### Case A: nginx won't start, or containers won't come up at all
Same as any other deploy: the database is untouched by container start-up
failures. In Portainer, redeploy the stack pinned to the **previous Git
commit** recorded in Step 0, then re-run Step 3.
```bash
docker logs nginx_webserver --tail 100
docker logs wp_api --tail 100
```
Send me whichever of those is relevant.
### Case B: containers are up, but Okta sign-in doesn't work
This is a configuration problem, not a data problem, and does **not** need a
code rollback or a database restore. Check, in order:
1. Is `wp_api`'s log showing anything at all when a sign-in is attempted?
`docker logs wp_api --tail 100`.
2. Do the five `OKTA_*` values in the stack's environment variables actually
match the Okta app integration (Step 1.5)? A copy-paste error in
`OKTA_CLIENT_SECRET` or a redirect URI that's off by a trailing slash are
the two most common causes.
3. Is the account assigned to the Okta app integration? An unassigned account
gets denied by Okta itself, before it ever reaches this app.
4. If a specific person can't sign in but others can, check
`OKTA_IDENTITY_CLAIM`. The claim it reads may not carry that person's
directory identity in the format expected. Confirm with security which
claim Okta is actually issuing.
Fix the environment variable(s) in Portainer, then redeploy (Pull and redeploy
is fine; the migration already applied and does not run again). No backup
restore, no code rollback.
If it's still not working after checking all four, send me the `wp_api` log
from item 1 along with which of items 2-4 you already ruled out.
### Case C: the decision is made to abandon Okta and restore local-password sign-in
This is the case the point-of-no-return warning in "What this deploy changes"
is about. Only reach for this if Case A and B do not apply. That is, Okta
itself is working correctly but a decision has been made to go back to the old
system entirely.
**This cannot be done with a code rollback alone.** The old code expects a
real `password_hash` on every user row. After Step 3 commits, that column is
either gone or (if `alembic downgrade` is run) present but empty. Either way,
nobody's stored password survived, including admins'. The only way to get a
working local-password system back is to restore the full database from the
Step 1 backup, which also rolls back every other change made since that
backup: new work packages, comments, uploaded files, everything.
**Do not do this without confirming with me first.** If it is confirmed, 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`. After restoring, redeploy pinned to the Git commit recorded in
Step 0, since the restored database matches the old schema, not this one.
Reach me at m.mabrey@prime-controls.com.
---
## Notes
- Do not run `docker compose down -v`. The `-v` flag deletes the `pgdata`
volume and with it the entire database.
- `docker exec <container-name>` is used throughout rather than
`docker compose ...`, because a Portainer-managed Git stack's compose project
lives under Portainer's own data directory and usually isn't reachable from
an ad hoc SSH session the same way.
- There is currently no startup log line confirming Okta config is valid
(`okta_auth.describe()` exists but nothing calls it yet, logged as a
follow-up). Step 4's live sign-in is the real verification until that's
wired in.
- Full background documentation: `DEPLOYMENT.md` and `server/README.md` in the
repository. `docs/waves/decisions-2026-09-03.md` (D16, D17) records why
there is no break-glass path and why this runbook exists as its own document.

View File

@@ -52,27 +52,53 @@ POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone
# is. If unset, `docker compose up` errors out and the API refuses to start.
# Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# OPTIONAL — D18 (2026-09-23): a session slides on activity (AUTH_IDLE_MINUTES,
# default 30) capped by a hard ceiling from original sign-in regardless of
# activity (AUTH_SESSION_HOURS, default 8). Both are proposed defaults, not
# confirmed against this tenant's Okta SSO session policy — if Okta's own
# session outlives either one, re-auth here is likely a fast silent redirect
# rather than a real login screen. Full explanation in server/.env.example.
# AUTH_IDLE_MINUTES=30
# AUTH_SESSION_HOURS=8
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
# path (D15/D16). There is no local password anywhere in this app to fall back
# to, so without these nobody can sign in at all. Get them from the Okta app
# integration (sign-in method OIDC - Authorization Code, Web Application):
#
# OKTA_ISSUER the authorization server, e.g.
# https://your-org.okta.com/oauth2/default
# OKTA_CLIENT_ID \_ from the app integration
# OKTA_CLIENT_SECRET /
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
# registered on the app integration, e.g.
# https://wp-suite.company.local/api/auth/okta/callback
#
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
# the API starts without these, it just refuses every sign-in and says so in
# `docker compose logs api` (server/okta_auth.py's describe()).
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
OKTA_CLIENT_ID=<from the Okta app integration>
OKTA_CLIENT_SECRET=<from the Okta app integration>
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# is OFF by default and enabled from the Admin console; the host/port/from-address
# are configured there, but the password is only ever read from this variable
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# OPTIONAL — SMTP password for WP-assignment email. Email is OFF by default and
# enabled from the Admin console; the host/port/from-address are configured
# there, but the password is only ever read from this variable (never stored
# in the DB or shown in the UI). Leave unset until you have SMTP details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
```
The API builds its own DB connection string from the `POSTGRES_*`
@@ -87,6 +113,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the
@@ -151,25 +178,34 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
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.
session — but there is no local password to sign in with (D15/D16), and Okta
requires a real browser to complete, which this script cannot do. So instead
of an HTTP login, it mints a session directly the same way `okta_callback()`
does after Okta hands back an identity, which means **it has to run somewhere
that can read the same `AUTH_SECRET_KEY` and reach the same database as the
server under test** — inside the `api` container, or local dev against your
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
workstation the way the old password-based version could.
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
through Okta once first, or pre-create it from the User Directory — and must
be an **admin**: the run creates a project and deletes it again, and 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). Pass the vars through:
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
# From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and
# hits FastAPI directly. This is the normal way to run it in production:
docker compose exec -e WP_SMOKE_USER api \
python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
python3 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.
# --user overrides $WP_SMOKE_USER if you'd rather be explicit.
```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
@@ -257,7 +293,7 @@ users on the same project see the same server-stored SOP and Work Packages.
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `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`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` |
| `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) |
@@ -275,8 +311,9 @@ 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` (including `POST /api/auth/users/{id}/auto-add`) ·
Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip),
`POST /api/auth/logout`, `GET /api/auth/me`, admin user management 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`.
@@ -350,27 +387,31 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
### Sign-in and admin bootstrap (Okta)
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
There is no local password anywhere in this app — no "Forgot password," no
reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above
is purely for WP-assignment notification emails.
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access
control is **who is assigned to the app integration in Okta** — see step 2's
`OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for
the full flow. The first admin has to sign in through Okta once (landing as an
ordinary `project_user`, auto-provisioned), then get promoted from a shell:
```bash
docker compose exec api python -m server.manage_users promote alice --role admin
```
This is deliberate, not an oversight: a hand-typed username at account-creation
time risks a second, orphaned row if it doesn't exactly match what Okta sends,
so the CLI promotes an existing Okta-provisioned row rather than creating one
blind (D16). Every admin after the first can be promoted from the User
Directory page — no shell access needed.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored — the same
posture the abandoned LDAPS design took, carried forward deliberately (D16).
## Permissions roles
@@ -382,7 +423,7 @@ which no longer manages accounts.
| Role | May do |
|---|---|
| `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, 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 |
@@ -399,9 +440,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
* **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
* **Account changes need EXCLUSIVE scope.** 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

View File

@@ -12,6 +12,7 @@ Close an entry by deleting it in the same commit that fixes it.
|---|-------|----------|--------|--------|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
| 3 | Export is not one merged PDF; drawings ride along as a list | Low | 2026-08-20 | Open — decided |
---
@@ -161,3 +162,35 @@ save/issue controls, or add a boot check in each app that disables them and show
read-only notice inline. Decide separately how the embedded creator
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
app bar — and therefore the banner — is deliberately skipped.
---
## 3. Export is not one merged PDF; drawings ride along as a list
**Files:** `html/wp-creation-app.js` (the T9.1 export walk), `CR-008`
**Decided:** 2026-08-20, by Nick — "add this to known issues."
### What is wrong
CR-008 asked for the work package "as one document." What shipped (T9.1)
renders every section inline — including images — and lists PDF drawing
attachments with links, rather than merging their pages into a single PDF.
### What it costs
A crew printing the package gets the form and the inline images in one pass,
but linked PDF drawings are separate opens/prints. For field hand-offs that
want literally one file, someone stitches it manually.
### Why it is still open
Real PDF merging needs either a server-side PDF library (a new dependency and
a render pipeline for arbitrary uploaded PDFs) or a client-side one (heavy,
and the creator is deliberately dependency-free). The recommendation made at
T9.1 — inline images + listed PDFs — was accepted as the shipped behaviour.
### What closing it takes
A server-side merge endpoint (e.g. pypdf) that concatenates the rendered
package with each attached PDF, streamed back as one download; plus a size
ceiling consistent with D8's upload limits. One task, one new dependency.

View File

@@ -27,20 +27,48 @@ services:
POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
# and the API refuses to start in production without it (see server/auth.py).
# Signs login session cookies, AFTER Okta has confirmed who someone is.
# REQUIRED — compose fails fast if it's unset, and the API refuses to
# start in production without it (see server/auth.py).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
# every sign-in and says so in the startup log (server/okta_auth.py
# describe()). See server/.env.example for what each one is and how to
# get it from the Okta app integration.
OKTA_ISSUER: ${OKTA_ISSUER:-}
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
# applies when the env var is UNSET, and compose setting it to an empty
# string here is not the same thing as leaving it unset. An empty value
# would make the API look for a claim literally named "", which is
# never present, so EVERY sign-in would 503. Mirror the same default
# here instead, so an operator who leaves .env's copy commented out gets
# the real default, not a broken one.
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
# Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
# Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and
# the picker cleanly falls back to manual entry (see server/assets_db.py).
# Use a db_datareader login: the app only ever SELECTs.
MICRON_DB_URL: ${MICRON_DB_URL:-}
restart: unless-stopped
depends_on:
db:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
# Reaching the Micron database means leaving this compose project, and
# `internal` is deliberately egress-free. `outbound` is attached to the api
# container ONLY — the database and backup containers stay sealed. Detach it
# again if you are not using the Micron asset picker.
- outbound
db:
image: postgres:16-alpine
@@ -98,4 +126,12 @@ networks:
name: proxy
external: true
internal:
internal: true # no outbound internet access from api/db
internal: true # no route off the host for anything on this network alone
outbound:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
# above removes the gateway entirely, which blocks not just the internet but
# the LAN and the VPN too — so the api container needs this second network to
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
# remain on `internal` only and still have no way off the host.
# Detach it from api if you are not using the Micron asset picker.
driver: bridge

View File

@@ -0,0 +1,73 @@
# Accessibility audit — C1 + S8 (T9.5, 2026-08-19)
Approved Aug 14 2026 (C1): any component rebuilt ships accessible or it is not
done. This document records the audit at the end of wave 9 against the wave 0
baseline, per CLAUDE.md's rules. Every number below is re-measured by a probe
on every run — the citations name which one.
## The metrics
| Metric | Wave 0 baseline | Now | Target | Verified by |
|---|---|---|---|---|
| `<div>` / `<span>` with `onclick` | 12 / 2 | **0** | 0 | `helptip_check.py` (grep, comments stripped) |
| `.help-tip` unreachable by keyboard | 15 (18 by wave 6) — re-measured at T9.5 start: **20** | **0** | 0 | `helptip_check.py` (driven with real keys and taps) |
| `aria-live` regions | 0 | ≥1 per toast system and banner (login, both toasts, release banner, autosave indicator, list-import reports, field toast) | ≥1 each | `a11y_check.py`, `warning_check.py`, `creator_dialogs_check.py` |
| Text below 4.5:1 | present | none found on the audited surfaces | 0 | `a11y_check.py` (creator sweep), `frame_check.py` BL-013 note |
| `outline: none` without replacement | present | **0** (grep with replacement detection) | 0 | `helptip_check.py` |
| Native dialogs | 79 | **21** | 0 or documented | `creator_dialogs_check.py` prints the count; see the gap below |
**The count went up before it went down, exactly as the task predicted:** the
wave 6 exit counted 18 unreachable help-tips; at the start of T9.5 there were
**20** (T6.x and wave 7/8 tasks reused the component as designed). All 20 are
buttons now — the fix is in the component (`help.js` upgrades every badge at
load and exposes `helpTipUpgrade()` for late renders), so a badge added
tomorrow is born reachable.
## The documented gap — 21 native dialogs
`admin.js` (6), `users.js` (10), `index.html` (5). These are the operator
consoles and the launcher — surfaces **no S1 task ever named** (S1's two
halves were the wizard, T5.8, and the creator, T7.9; both measure 0). They are
admin-only or low-frequency flows, every one a genuine confirm-before-destroy.
Logged as **BL-024** for conversion to the T7.9 dialog kit rather than done
here: converting three more pages inside the audit task is the drive-by
CLAUDE.md forbids.
## The help-tip component (S8)
- The badge is a `<button>` with `aria-label`, `aria-expanded`, and a
`:focus-visible` ring from the shared `--cds-focus` token.
- The tooltip is one `role="tooltip"` bubble, viewport-clamped on both axes —
which also ended BL-001: the old CSS `::after` escaping its badge was the
creator's last 390px overflow.
- Paths: keyboard (focus shows, Escape hides), touch (tap toggles, tap
elsewhere closes), pointer (hover shows). Driven at 390px by
`helptip_check.py`.
- The injected styles now use theme tokens; the block previously carried four
raw hexes of the kind S5 counted.
## Keyboard-only primary flow
Sign in → pick a project → SOP wizard → create a work package → issue it.
Covered by probes that dispatch **real CDP key events** (synthetic
`KeyboardEvent`s never reach native activation — the wave 5 lesson, recorded
in `form_structure_check.py`):
| Leg | Probe |
|---|---|
| Sign in | `server/smoketest.py` (form submit), `login.html` roles verified in `a11y_check.py` |
| Launcher → project | `launcher_check.py` (B3, keyboard section) |
| SOP wizard steps | `stepper_check.py` (A4/S9: ten real buttons, keyboard operable) |
| Creator sections + save | `form_structure_check.py` §7 (Tab/Enter/Space on rail and headings), `creator_dialogs_check.py` (validation focus order) |
| Issue | `hold_check.py` (the status control end to end) |
## Per-page results
| Page | Interactive elements | Announcements | Focus | Notes |
|---|---|---|---|---|
| login.html | native form controls | `role="alert"`/`role="status"` (the app's reference pattern) | visible | the pattern every other page copies |
| index.html (launcher) | buttons/links | status line announced | visible | 5 native dialogs → BL-024 |
| work-package-suite.html (wizard) | 0 div/span handlers; library entries are buttons (T9.5) | `wp-toast` role-differentiated | T3.4 ring | 0 native dialogs |
| wp-creation-index.html (creator) | 0 div/span handlers; chips are buttons (T9.5) | toast + release banner + field errors, all live regions | ring on all 120+ focusables (`a11y_check`) | 0 native dialogs |
| field.html | buttons throughout, 44px targets | `role="status"`/`alert` toast | visible | offline drawings reachable (files_check) |
| admin.html / users.html | buttons | banners | visible | 16 native dialogs → BL-024 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,115 @@
# Completion — the R2 plan, reconciled (T9.7, 2026-08-19)
All 65 items accounted for: the 55 in `IMPLEMENTATION.md` §6 and the 10 in
`docs/waves/decisions-2026-08-18.md`. Delivery references are task ids; the
branch is local-only by instruction (one task = one commit, ids in every
commit message), so the commit history IS the PR trail. Verification counts
name the probe that re-checks the item on every run.
## Change requests
| Item | Status | Delivered by | Notes / deviations |
|---|---|---|---|
| CR-001 | built | T6.1 (`generalinfo_check` 49) | |
| CR-002 | built | waves 1–2, field toggles T5.7; **applied to the export at T9.1** | the Micron samples now name `costCode:false, acumaticaTask:false` |
| CR-003 | built | T6.2 | three priorities, escalating order |
| CR-004 | built | T6.3 (`locations_check`, `rollup_check`) | paths, not labels |
| CR-005 | built | T5.4 | upload path; **B100 list still not supplied** (§8) |
| CR-006 | built | T5.7 (`sections_check` 95) | hidden, never deleted — pinned |
| CR-007 | built | T7.7 (`files_check` 36) | D8 numbers enforced server-side; offline verified against a killed server |
| CR-008 | built | T9.1 (`export_check` 20) | **deviation raised, not decided:** merge-vs-list — recommendation is inline images + listed PDFs; merged-PDF output needs Nick |
| CR-009 | built | T8.1 (`kitting_check`) | statuses adopted as proposed; kitting off for Micron EUV by toggle |
| CR-010 | built | T8.2 | owner on the PACKAGE (confirmed Aug 18), account-backed, orphan-safe |
| CR-011 | built | T8.3 (`kitting_notify_check` 17) | coalesced; T7.6 gate reused; sink-verified |
| CR-012 | built | T8.4 | shared location lists + detail field; prints and mails |
| CR-013 | built | T8.5 (`mreq_check` 19) | lightweight scope exactly; fences grepped |
| CR-014 | built | T7.6 (`qa_gate_check` 40) | **deviation, stated in the commit:** the email body omits location/scope — the done-when's no-customer-IP rule outranked the Do-paragraph; fuller body needs Nick |
| CR-015 | built | T7.3 (`hold_check` 50) | root cause stated (both halves); regression test specific to clear-last-constraint |
| CR-016 | built | T5.7 | |
| CR-017 | guarded | T9.2 | present, optional, rolls up; BL-023 logs the productivity factor |
| CR-018 | built | T6.4 (`rollup_check` 63) | |
## Findings and structural items
| Item | Status | Delivered by | Notes |
|---|---|---|---|
| F1–F5 | built | waves 1–3 | |
| F6 | built, one number open | T7.2 (5,399 → 1,954px) | **BL-022:** 2.17 screens vs the strict 2.0 encoding of “roughly two” — product call, check stays red |
| S1 | built | T5.8 wizard, T7.9 creator (`creator_dialogs_check` 20) | 79 → 21 dialogs; the 21 live on surfaces no S1 task named — **BL-024** |
| S2–S5, S9–S12 | built | waves 2–5 | |
| S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` |
| S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project |
| S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** |
| S13 | built | T1.6 (re-verified 2026-08-20) | this row said "open / does not sign in" until Aug 20 - a records error: T1.6 fixed it in wave 1 (it reuses smoketest's login) and the wave-1 exit box was simply never ticked. Verified live: sign-in, seed, `--clean` |
| A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract |
| A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail |
| A3–A5, A7 | built | waves 1–6 | localization re-verified through T7.10's admin edits (`cards_check`) |
| A6 | built | T7.5 (`triage_check` 16) | |
| B1–B5 | built | waves 2–5 | |
| B6 | built | T7.8 (`sticky_bar_check` 12) | |
| B7 | built | T7.1 (`frame_check` 38) | **deviation, stated in the commit:** the creator became its own page rather than merging into the parent — measured trade (0 collisions vs 21+9) |
| C1 | audited | T9.5 (`accessibility-audit.md`) | every metric probe-backed |
| C2 | audited | T9.6 (`mobile_check` 24) | screenshots committed beside the wave 0 baseline |
| C3 | built | wave 3 | |
| C4 | built | wave 4 interim, T9.9 full (`color_check` 4) | zero literals outside `theme-light.css` |
## The August 18 decisions
| Item | Status | Delivered by |
|---|---|---|
| D1 | built | T7.1 (sample controls visible; consolidated at T9.4 per the S7 reconciliation) |
| D2 | built | T7.6 (QA group on the SOP wizard) |
| D3 | built | T7.2 (rail + collapse; the "at rest" amendment recorded) |
| D4 | built | T7.3 (Urgent surfaces the audited path; the override names what it crosses) |
| D5 | built | T7.10 (one analytics core; admin report) |
| D6 | built | T8.6 (material list, the CR-005 pattern, one shared component) |
| D7 | built | T9.8 (`archived_check` 15) |
| D8 | built | T7.7 (5MB / PDF+image / 2GB, 80% warning) |
| D9 | built | T7.6 (Field View text pill at 390px) |
| D10 | built | T7.6 / reused T8.3 (stored setting, admin-only, audited, sink-verified) |
| D11 | built | merge of `origin/Micron-Assets` + integration, Aug 20 (`assets_check`); see `decisions-2026-08-20.md` |
## Out of scope, confirmed unbuilt
- **Parts catalog / live inventory / warehouse integration** — `mreq_check` and
`materials_check` grep the model and the diff for stock/inventory/price
fields on every run; none exist. D6's uploaded list is project-scoped data
entry, not a catalog.
- **Asset database integration** — SUPERSEDED by D11 on Aug 20: Cody Schaefer's
Micron asset picker (read-only catalog lookup, `origin/Micron-Assets`) merged
and adapted to the R2 creator. The assets section stays a CR-006 toggle
(off on the Micron sample). This line was true when written.
- **CxAlloy integration** — CR-014 is notification-only, as the task footnote
ordered; the platforms block stores names and URLs, nothing calls them.
- **P6 activity import** — CR-001 renders the two fields; nothing imports.
## Outstanding inputs (IMPLEMENTATION.md §8, restated)
- Nate's spreadsheet and the master material workbook: **still not supplied.**
D6 built the upload path so their arrival is a paste, not a build.
- The real B100 floor/area list: **still not supplied.** CR-005's upload is
ready for it; every seeded value remains obviously fake.
- SMTP host/credentials for production mail: the gate ships off; the password
is env-only. Nothing on this branch has sent a real email.
## For the next revision
- **BL-020** — the wizard→creator navigation prompts to leave (T4.3's guard
doing its job on what is now a page exit); product call on suppression.
- **BL-021** — `project_sop_team()` reads a path `pushSOP` never writes; the
critical-reopen mail has never reached the PM/CM. One line, needs its own
sink verification.
- **BL-022** — F6's "roughly two screens": 2.17 vs the strict 2.0. Bless it or
name the chrome to trim.
- **BL-023** — the productivity factor (actual ÷ estimated); data already
aggregated, placement needs Nick.
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
ready for them.
- Product questions raised in commit messages, awaiting answers: hold
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
merged-PDF export (T9.1).
- Acceptance criteria that turned out wrong, for the next plan's calibration:
F6's height bar collided with D3's own chosen design (amended once, then
left red rather than moved again); A2's "tab count badge" predated D3
removing tabs (the rail carried it); S1's "0 dialogs" never named the
console pages that held a quarter of them.

View File

@@ -247,7 +247,7 @@ Wave 4 added three more, each written because its task's done-when could not be
anything that already existed:
```bash
python tests/aggregates_check.py # B4 — do the counts come from the server? 16 checks
python tests/aggregates_check.py # B4 — do the counts come from the server? 17 checks
python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks
python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
@@ -280,10 +280,12 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
# tests/usage_check.py (D5) removed at T11.6 - it tested the per-browser
# analytics core and admin report, both retired in favour of CR-019's
# server-side Activity & usage card (see docs/waves/decisions-2026-09-17.md).
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
```
@@ -292,7 +294,7 @@ Wave 8 adds these:
```bash
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
```
@@ -301,6 +303,19 @@ Wave 9 adds these:
```bash
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 14 checks
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
python tests/color_check.py # C4 - zero literals outside theme-light 5 checks
```
The August 20 integration adds:
```bash
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -961,3 +961,51 @@ Column `#` is the line number in `html/theme-light.css`. Read down each column p
**120 declarations.** Lines 3–116 are the Carbon g10 set; lines 170–175 are the app-shell
group added by the suite. Many share a literal by design — that is Carbon's v10→v11 alias
layer, not the `S5` defect. See the note at the end of §3.
## Icons (S6 / T9.3)
One system: **monochrome text-presentation glyphs**, chosen because the suite
is classic-script vanilla HTML with no bundler — an SVG sprite or icon font is
a new asset pipeline, while text glyphs render through the same font stack as
the words beside them. The trade accepted: text glyphs vary slightly by font,
but never flip into per-platform colour artwork the way emoji do, which is the
failure S6 names. **No emoji anywhere in the UI**; `tests/icon_check.py`
sweeps every page for emoji-range codepoints and the U+FE0F emoji-presentation
selector on every run.
One meaning per glyph, one glyph per meaning:
| Meaning | Glyph | Codepoint | Notes |
|---|---|---|---|
| done / ok / success | ✓ | U+2713 | replaced emoji ✅ |
| close / remove / failure | ✕ | U+2715 | replaced emoji ❌ |
| needs attention / warning | ⚠ | U+26A0 | text presentation |
| blocked / on hold | ⊘ | U+2298 | replaced emoji ⛔ |
| edit with a logged reason | ✎ | U+270E | replaced emoji 🔒; same meaning on sign-off overrides |
| revert / refresh | ↺ / ↻ | U+21BA / U+21BB | direction distinguishes undo from reload |
| settings / admin | ⚙ | U+2699 | replaced ⚡ on the side nav |
| export / download | ⤓ | U+2913 | |
| import / upload | ⤒ | U+2912 | |
| key point (help copy) | ◆ | U+25C6 | replaced emoji ⭐ |
| reference link | ▸ | U+25B8 | replaced emoji 📁 |
| info tooltip badge | ⓘ | U+24D8 | the `.help-tip` component (S8/T9.5) |
| back / navigation | ← ‹ › → | U+2190 U+2039 U+203A U+2192 | |
| views (nav) | ◔ ▤ ▦ ▧ | U+25D4 U+25A4 U+25A6 U+25A7 | one per view, never reused |
| drag handle | ⣿ | U+283F | sequence reordering |
| home | ⌂ | U+2302 | side nav |
| search | ⌕ | U+2315 | app bar |
| print / save PDF | ⎙ | U+2399 | |
| power / sign out | ⏻ | U+23FB | side nav |
| sort direction | ▲ ▼ | U+25B2 U+25BC | column headers, paired |
| in the QA queue | ◉ | U+25C9 | the CR-014 banner |
| clock / language & time | ◷ | U+25F7 | replaced ⌚ U+231A, which is emoji-presentation by default |
| menu (off-canvas) | ☰ | U+2630 | app bar |
| people / directory | ☺ | U+263A | side nav; text presentation |
| field view | ⚒ | U+2692 | side nav; text presentation |
| password / key | ⚿ | U+26BF | side nav |
Dropped rather than mapped: ⚡ on action buttons (the label carries the
action), 📷 on “Add photo” (labelled), 📄/🖼 on file rows (the filename is the
label; a pictogram repeating “this is a file” said nothing). Every remaining
glyph sits beside visible text or carries an accessible name — none is the
only carrier of meaning.

View File

@@ -54,7 +54,7 @@ deliberately deferred.
## Found during implementation
### BL-001 — The creator overflows horizontally at 1440px
### BL-001 — CLOSED at T9.5 — The creator overflows horizontally (1440px, then 390px)
- **Found during:** T0.2
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
@@ -123,6 +123,13 @@ deliberately deferred.
owns this entry now. `tests/form_structure_check.py` reports the measurement on
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
cannot be closed by silence.
- **CLOSED, T9.5.** The `S8` rebuild replaced the escaping CSS `::after` tooltip
with a viewport-clamped bubble element, and the creator measures
**scrollWidth 390 vs clientWidth 390** at a 390px viewport. `frame_check.py`'s
pin flipped: it now asserts the ABSENCE of overflow, so a regression reopens
this entry loudly. Three causes in this entry's lifetime - the user-menu run
(fixed by `T1.2`), the injected `--nav-w` (spent by `T7.1`), the tooltip
(fixed here) - each found only because the measurement kept running.
### BL-002 — `outline: none` appears three times in the wizard sheet, not once
@@ -150,7 +157,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets;
`C1`'s audit at `T9.5` confirms it app-wide.
### BL-004 — `help.js` ships a 52-colour palette in a different design language
### BL-004 — CLOSED at T9.9 — `help.js` ships a 52-colour palette in a different design language
- **Found during:** T3.1
- **Where:** `html/help.js:79` (the injected `<style>`)
@@ -165,8 +172,9 @@ deliberately deferred.
work, not a non-issue.
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
`docs/reference/tokens.md` §1.
- **CLOSED, T9.9:** the help centre's palette collapsed onto theme-light tokens; color_check.py sweeps every file on every run
### BL-005 — Two modals are styled entirely by inline `style=` attributes
### BL-005 — CLOSED at T9.9 — Two modals are styled entirely by inline `style=` attributes
- **Found during:** T3.1
- **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150`
@@ -177,6 +185,7 @@ deliberately deferred.
- **Why not now:** they are markup built by JS, not a stylesheet, so they are outside `T3.2`'s
four-sheet surface. Both dialogs are rebuilt as accessible components under `C1`.
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
- **CLOSED, T9.9:** both JS-built dialog kits (auth-guard, wp-format) and project-data's badges read tokens; zero literals remain
### BL-006 — Seventeen half-pixel font sizes
@@ -212,7 +221,7 @@ deliberately deferred.
it in four waves, and it is measured every run now instead of once. Carried to
`T7.2`.
### BL-008 — There is a second brand blue: `#2563d6`
### BL-008 — CLOSED at T9.9 — There is a second brand blue: `#2563d6`
- **Found during:** T3.1
- **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257`
@@ -224,8 +233,9 @@ deliberately deferred.
- **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same
conversation as the green action buttons.
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a field fill. See `docs/reference/tokens.md` §8-E.
- **CLOSED, T9.9:** the second brand blue is deleted - .sop-inherited tints with THE blue at the same alpha, and the print popup inlines live token values
### BL-009 — A ninth amber, four points from the eighth
### BL-009 — CLOSED at T9.9 — A ninth amber, four points from the eighth
- **Found during:** T3.2
- **Where:** `html/field.html:35` (`.pill.warn`)
@@ -236,6 +246,7 @@ deliberately deferred.
- **Why not now:** merging it moves a rendered colour, which `T3.2` forbids. `T3.2` named it
`--wp-status-warning-text-alt` so it is visible rather than hidden in a hex.
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a status pill. See `docs/reference/tokens.md` §8-K.
- **CLOSED, T9.9:** --wp-status-warning-text-alt is deleted; its one consumer (field.html warn pill) uses the real amber
### BL-010 — 829 raw spacing, type and radius values remain inside rules
@@ -253,7 +264,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T5.x` and `T7.1`, where these pages are re-laid-out and the
values are being chosen again anyway. See `docs/reference/tokens.md` §6b and §11.
### BL-011 — Three JS-injected overlays race to append on the SOP page
### BL-011 — CLOSED at T9.9 — Three JS-injected overlays race to append on the SOP page
- **Found during:** T3.2
- **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav`
@@ -266,8 +277,9 @@ deliberately deferred.
- **Why not now:** invisible to users, and the fix is ordering in three separate scripts, which
is a change with no observable benefit while `T7.1` is still going to move this code.
- **Suggested wave or follow-up:** wave 9, if it is still true after `T7.1`.
- **CLOSED, T9.9:** the sync badge's holder mounts at DOMContentLoaded, so the three overlays land in script order deterministically
### BL-012 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
### BL-012 — CLOSED at T9.9 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
- **Found during:** T3.2
- **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440`
@@ -280,6 +292,7 @@ deliberately deferred.
covers what the diff was being asked to prove, and covers it better.
- **Suggested wave or follow-up:** wave 9, alongside `C2`. Either freeze the clock in the
fixture or exclude the live regions from capture — otherwise every later wave re-learns this.
- **CLOSED, T9.9:** baseline_shots.py freezes Date and Math.random per document; two consecutive admin captures measured byte-identical
### BL-013 — The creator's inputs have no visible focus ring at all
@@ -323,7 +336,7 @@ deliberately deferred.
- **Why not now:** out of `A5`'s stated scope, and `A4`/`S9` rebuild the stepper.
- **Suggested wave or follow-up:** `T7.x`, with the stepper rebuild.
### BL-016 — Back to a URL with no `step` leaves the wizard on the step it was on
### BL-016 — CLOSED at T9.9 — Back to a URL with no `step` leaves the wizard on the step it was on
- **Found during:** T5.1
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
@@ -340,6 +353,7 @@ deliberately deferred.
unreviewable.
- **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the
current behaviour with a named check so the fix has a test waiting for it.
- **CLOSED, T9.9:** a step-less wizard URL is step 1 (parseInt || 1); stepper_check's pin flipped with the fix, as the entry planned
### BL-017 — The native-dialog baseline metric counts prose
@@ -357,7 +371,7 @@ deliberately deferred.
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
target against the stripped figure.
### BL-018 — The Work Package tab's gate is the last localStorage-derived status
### BL-018 — CLOSED at T9.9 — The Work Package tab's gate is the last localStorage-derived status
- **Found during:** T5.3
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
@@ -389,8 +403,9 @@ deliberately deferred.
**imports `set_sop` from `sections_check.py`** rather than writing a fifth
copy, so the workaround is in one place and disappears when the fixture is
fixed. Four probes is enough evidence: `T9.9` owns it.
- **CLOSED, T9.9:** the false-complete write requires the {sop,state} shape, and browser_check.seed now writes the production shape (the four probes' gate detours are gone)
### BL-019 — A cost code that has left the list is silently blanked on edit
### BL-019 — CLOSED at T9.9 — A cost code that has left the list is silently blanked on edit
- **Found during:** T5.6
- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by
@@ -411,6 +426,7 @@ deliberately deferred.
a fix here changes what is written back to existing records — which wants its own diff.
- **Suggested wave or follow-up:** wave 9. The fix is the four lines already written for
`gov_wosize`.
- **CLOSED, T9.9:** a stored cost code with no matching option is kept as an option (the gov_wosize pattern), so the round-trip preserves it
### BL-014 — Four controls fall back to the browser's default focus ring
@@ -431,7 +447,7 @@ deliberately deferred.
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
same way rather than inheriting this entry's wording.
### BL-020 — Switching from the SOP wizard to the creator can now prompt to leave
### BL-020 — CLOSED (decided 2026-08-20: keep it) — the wizard-exit prompt stays
- **Found during:** T7.1
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
@@ -458,7 +474,7 @@ deliberately deferred.
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
the prompt stops being a surprise.
### BL-021 — `project_sop_team()` reads a path `pushSOP` never writes
### BL-021 — CLOSED 2026-08-20 (`project_sop_team()` reads nested-first; `critical_reopen_check` 11, sink-verified)
- **Found during:** T7.6
- **Where:** `server/app.py`, `project_sop_team()`
@@ -475,7 +491,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
the `tests/qa_gate_check.py` sink pattern.
### BL-022 — F6's "roughly two screen heights": 2.17 against a strict 2.0
### BL-022 — CLOSED 2026-08-20 (strict 2.0; the chrome compressed to 1,784px = 1.98 screens; form_structure_check 51/51 for the first time)
- **Found during:** T7.2, re-measured at the wave 7 exit
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
@@ -494,7 +510,7 @@ deliberately deferred.
becomes a small T9 task. The strict check stays red so the question cannot be
forgotten.
### BL-023 — Productivity factor: actual against estimated hours
### BL-023 — CLOSED into D12 (decided 2026-08-20: the dashboard) — see decisions-2026-08-20.md
- **Found during:** T9.2 (logged as that task's done-when requires)
- **Where:** future — dashboard / rollups
@@ -502,8 +518,147 @@ deliberately deferred.
hours exist on every package; nothing yet compares them. A productivity
factor (actual ÷ estimated, rolled up by discipline / building / type the way
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`)
already carry both sums, so this is a presentation task, not a data one.
`/api/wps/metrics` already carries both sums, so this is a presentation
task, not a data one. (Corrected at D12: the entry originally credited
`/api/projects/{id}/summary` too, which carries no hours at all.)
- **Why not now:** new scope — needs its own item id per the working rules, and
a product conversation about where it displays and who reads it.
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
- **Found during:** T9.5 (the audit's dialog count)
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
- **What:** the app-wide native dialog count fell 79 → 21 across `S1`'s two
tasks (`T5.8` wizard, `T7.9` creator). The remainder sit on surfaces no `S1`
task ever named — admin-only or low-frequency flows, every one a genuine
confirm-before-destroy. The T7.9 dialog kit (`wpConfirmDialog`/
`wpPromptDialog`) is built and proven; conversion is mechanical.
- **Why not now:** converting three more pages inside the audit task is the
drive-by CLAUDE.md forbids; the audit's job was to measure and document.
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
### BL-025 — CLOSED 2026-08-20 (tint rebased onto THE blue; color_check greps space-free spellings)
- **Found during:** the 2026-08-20 transparency fix (undefined-token sweep)
- **Where:** `help.js`, the help-centre search input's `:focus` rule:
`box-shadow:0 0 0 2px rgba(37,99,214,.15)`
- **What:** BL-008 removed the second brand blue (#2563d6 = rgb 37,99,214) and
`color_check` greps both spellings — but only inside `theme-light.css`, and
only with spaces (`37, 99, 214`). This space-free rgba consumer slid past
both nets. C4's recorded exception legitimately allows rgba **alphas** as
opacity recipes, so this is not a token-rule defect; it is the wrong BASE
colour under the alpha. The correct tint is THE blue: `rgba(15,98,254,.15)`.
- **Why not now:** noticed in passing during an unrelated fix; one-line change
plus widening `color_check`'s grep to space-free spellings deserves its own
entry rather than a drive-by.
- **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur.
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
- **Found during:** the 2026-08-21 outage triage (the question that started it)
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
- **What:** the app carries no record of what code it is running. `/api/health`
returns `{"ok": true}` and nothing identifies the deployed commit, so
answering "is the live site on the latest code?" took fingerprinting
(probing for files/routes that only exist after certain merges) in the
middle of an outage. The fix: bake the git SHA into the image at build time
(`ARG GIT_SHA`), return it from `/api/health`
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
diagnostics card. Then currency is one glance against `git log -1`.
- **Why not now:** new scope — needs its own item id per the working rules
(D13 is the natural next), and it touches the image build, which deserves a
deploy alongside someone with host access.
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
probe check that /api/health carries a version field.
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
migration crash-looped the api container)
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
production runs Postgres, and the dialects disagree exactly where it hurts:
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
until the table was created by hand. The hotfix (64eac0c) fixed that one
instance and pinned the Boolean-default class in `materials_check`; the
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
step — render `alembic upgrade --sql` for the postgresql dialect and read it
before restarting (offline, needs no live DB; this render would have shown
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
for the postgresql dialect on each run and fails on anything the dialect
rejects or on known-bad patterns.
- **Why not now:** the outage is resolved and the one known instance is fixed
and pinned; the systematic guard is its own small task, not a hotfix rider.
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
(both are "deploys should be boring" work).
### BL-028 — `okta_auth.describe()` is never called
- **Found during:** T10.9 (writing the deploy runbook's live-verification step)
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere)
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER
ACTIVE..."` or report missing config at a glance, and both
`server/.env.example` and `server/README.md` tell an operator to "check the
startup log line" for it. Nothing prints it. `app.py` never imports or calls
`describe()` at process start, so that log line does not exist and an operator
following the docs will not find it.
- **Why not now:** a runbook is documentation, not server code; wiring a
startup log call is a real (if small) change to `app.py` and wants its own
diff and its own verification, not a rider on T10.9.
- **Suggested wave or follow-up:** next housekeeping pass. One call
(`logger.info(okta_auth.describe())` near startup) plus updating
`DEPLOY-runbook-2026-09-03.md` Step 4 to check the log line once it exists.
### BL-029 — `users.failed_attempts` / `users.locked_until` are vestigial
- **Found during:** T10.6
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`)
- **What:** both columns exist to support local-password lockout, which T10.4
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing
increments them anymore — dead columns, not a bug, but schema drift from the
D15 cutover.
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration
and belongs with the rest of the local-password cleanup, not folded into a
docs task.
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other
post-cutover schema tidy-up.
### BL-031 — A raw `rgba()` shadow survives in the creator's stylesheet
- **Found during:** T10.8 (the full suite could finally run through Docker; the
dev sandbox never had a headless browser to run it in before)
- **Where:** `html/wp-creation-styles.css:929`, `.asset-results { ... box-shadow:0
8px 24px rgba(20,30,50,.18); }`
- **What:** `generalinfo_check.py` asserts the whole file carries no raw colour
literal (comments excluded) as part of its CR-003 priority-colour check, and
this one line fails it: 48/49. Confirmed via `git show HEAD` that the literal
is already committed, byte-identical, unrelated to wave 10 — it is the Micron
asset picker's dropdown shadow, which arrived with the `origin/Micron-Assets`
merge (D11, 2026-08-20) and was never swept by `C4`'s token pass (`T9.9`
closed before D11 merged). The fix is a straight swap:
`theme-light.css:217` already declares `--wp-shadow-menu: 0 8px 24px
rgba(20, 30, 50, .18)`, the identical value — this line should read
`box-shadow:var(--wp-shadow-menu);`.
- **Why not now:** unrelated to the Okta wave; fixing a D11-era CSS literal
inside T10.8 (auth verification) is exactly the drive-by `CLAUDE.md` forbids.
- **Suggested wave or follow-up:** next housekeeping pass, with `C4`'s other
leftovers. One-line fix, `generalinfo_check.py` already pins it (49/49 once
fixed).
### BL-030 — `DEPLOY-login-portal.md` is fully stale
- **Found during:** T10.9
- **Where:** `DEPLOY-login-portal.md` (repo root)
- **What:** the original username/password login rollout doc. References
`bcrypt`, `create-admin --password`, `AUTH_SECRET_KEY` as the only secret,
and a login form — none of which describe the app since T10.4/T10.5. Someone
handed this to IT today would be told to do things that no longer work.
- **Why not now:** no task currently owns deploy-doc cleanup as a category;
deleting or archiving a doc is a product/records call (`CLAUDE.md`'s "removed
fields are hidden, not deleted" spirit likely applies to docs too, but that is
worth confirming rather than assuming).
- **Suggested wave or follow-up:** next housekeeping pass — needs Nick on
whether to delete, archive, or rewrite it as historical record.

View File

@@ -0,0 +1,102 @@
# Decisions — August 20, 2026
One item. Like the August 18 set, it is a **new item** with its own `D` id, not a
reinterpretation of an existing one.
---
## D11 — The Micron asset picker merges into the R2 creator
- **Arrived as:** `origin/Micron-Assets` (`7ef1fcd`, Cody Schaefer, Aug 18) — written
against pre-R2 `main`, integrated here by Nick's instruction on Aug 20.
- **Amends:** the R2 completion record's "Asset database integration — out of scope,
confirmed unbuilt" line, which was true when written and stops being true here.
- **Surface:** `html/` (creator), `server/` (`assets_db.py`, `/api/assets`),
`docker-compose.yml`, `requirements.txt`.
### What the branch brought
A read-only lookup onto the Micron asset catalog (a SQL Server instance outside this
repo): the whole catalog is fetched once per creator page load through `/api/assets`
and searched in memory; picked assets are stored on the package tagged
`source:'catalog'` with the DB's own casing; anything not in the catalog is added by
hand and visibly tagged manual. CSV import and Excel column paste bulk-add with the
same matching. Unconfigured (`MICRON_DB_URL` unset) and unreachable are first-class
states that degrade to manual entry — the suite runs without Micron wired up.
### What integration changed (and why)
The branch predates waves 5–9, so it used surfaces R2 replaced. Each adaptation keeps
Cody's behaviour and moves it onto the R2 idiom:
1. **Six `alert()` calls → the T7.9 dialog kit and toast.** The creator ships zero
native dialogs (`creator_dialogs_check` pins the count). File-handling errors use
`toast(msg,'alert')` exactly as the drawings uploader and comment import do;
the instructional message and the import summary use the kit, which gained the
one-button `wpAlertDialog` shape it was always going to need (BL-024 wants it too).
2. **The export block** moved inside T9.1's sectioned `add('assets', …)` frame, so the
CR-006 assets toggle keeps governing it. Content is Cody's: two columns, Asset ID +
Note, no controls.dev link column.
3. **`initAssetPicker()`** joined the R2 `bootData()` loads rather than replacing them.
4. **`role="status"`** on the picker's source note, so loading → ready/absent/error
announces (C1, the login.html pattern).
5. Everything else landed as written: his `⤒` import glyph is already the S6-mapped
U+2912, `.material-actions` is the creator's own class, and the styles block
declares no colour literal (`color_check` re-verifies).
### Recorded properties, restated as constraints
- **Read-only, structurally.** `assets_db.py` contains one SELECT and no other
statement; there is no POST route. `assets_check` greps this on every run.
- **Credentials are env-only** (`MICRON_DB_URL`), matching the SMTP password rule.
Driver errors are logged server-side and never propagated to the browser, because
a malformed URL's error text can quote password fragments.
- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
manual mode; nothing in the suite requires the catalog to exist.
---
# The evening decisions (same day)
Six answers from Nick, given in one message. Recorded verbatim in intent; each
names the item it settles. One new item id is assigned (D12); everything else
amends or closes an existing question.
## The answers
1. **BL-022 — "strict 2.0."** F6/D3's "roughly two screen heights" means
**2.0**, not 2.17. The overage is chrome (~154px: the context bar, the
release banner's spacing, header/toolbar padding), so this becomes a build
task: compress the chrome without deleting what other items placed
deliberately (A2's one-warning banner and the SOP identity strip STAY —
they get denser, not removed). `form_structure_check`'s red check flips
green by the page actually fitting, not by moving the bar.
2. **Hold from Draft/Scheduled — "no, leave as is."** The hold branch stays
reachable from any status. T7.3's raised question is closed; the shipped
behaviour is the decided behaviour.
3. **CR-014 email bodies — links back to the system; customer context is
allowed, confidential documents are not.** The T7.6-era rule ("no customer
IP in emails") is refined: naming the customer, the project, the package
and where the work happens is fine; what must never be embedded is
confidential document CONTENT (drawings, attachments, scope text). Every
work-package email carries a deep link back to the package in the system.
Build task, sink-verified.
4. **CR-008 merged-PDF export — known issue, not a build.** The export keeps
inline images + listed PDF attachments. Recorded as KNOWN-ISSUES.md §3 so
the limitation is a commitment, not a surprise.
5. **BL-023 → D12 — the productivity factor gets a spot on the dashboard.**
Placement delegated ("find a spot on the dashboard"). New item id **D12**:
actual ÷ estimated hours, from data the rollup endpoints already carry.
6. **BL-020 — "keep it."** The unsaved-work prompt on leaving the wizard
stays. Closed as decided-keep; no build.
Plus: **"do what's left on the housekeeping"** — BL-021 (the
critical-reopen recipient bug), BL-024 (the 21 console/launcher dialogs onto
the shared kit), BL-025 (the last second-blue tint + the widened check), and
S13 (seed_demo sign-in) are approved to build now, one commit each, on
`feat/wp-suite-r3-housekeeping`.

View File

@@ -0,0 +1,75 @@
# Decisions — September 2, 2026
One item, and it retires two decided-and-built items rather than amending them.
---
## D15 — Authentication moves to Okta OIDC; D13 and D14 are retired before deployment
- **Amends:** retires `D13` (LDAPS simple bind against `prime.local`) and `D14` (the CLI
authenticates against the domain). Both were decided and reaffirmed August 21 2026,
built across nine tasks (`T10.1`–`T10.9`), and verified against the live domain. Neither
reached production. Approved by Nick Siegfried.
- **Surface:** `server/auth.py`, `server/app.py` (`login()`), `html/login.html`,
`html/login.js`, `html/auth-guard.js`. `server/ldap_auth.py` does not carry forward —
there is no LDAPS bind in the new design, not even as a fallback.
- **Wave:** 10. The label is reused fresh: the LDAPS work that previously answered to
"wave 10" was built on `feat/ldaps-directory-auth`, which is deleted rather than merged,
and never appeared in `IMPLEMENTATION.md`'s wave table. It carries no claim on the
number.
### What D13/D14 were, for the record
The branch carrying them is deleted, not merged, so their decision record
(`docs/waves/decisions-2026-08-21.md`) no longer exists on any branch. Preserved here so
the reasoning isn't lost along with it:
D13 chose a direct LDAPS simple bind to `ldaps://prime.local:636` as the sign-in
mechanism: no password stored, a successful bind was the authentication, accounts were
provisioned just-in-time from the directory, and roles stayed local. D14 moved the CLI
onto the same bind, removing `create-admin`/`create`. Both were built, tested
(1284/1288 checks, Aug 24), and screenshotted at 390px and 1440px. Neither ever deployed —
`wp.controls.dev` still runs the pre-D13 local-password login as of this decision.
### The decision
Skip LDAPS entirely. Authentication becomes an Okta OIDC authorization-code flow,
replacing local passwords directly — the same full replacement D13 intended, just via
Okta instead of a domain bind. No LDAPS bind exists in this design at any point.
Four things carry forward from D13 unchanged, because they were never LDAPS-specific to
begin with:
1. **No password is stored.** The app never sees a credential of any kind; Okta owns
authentication entirely.
2. **Accounts are provisioned just-in-time.** A first successful Okta sign-in with no
matching local `users` row creates one, at the default role. The matching logic that
was going to key off a directory search instead keys off an OIDC identity claim.
3. **Roles stay local.** Okta, and AD behind it, supplies identity only. This app decides
what an identity may do. Restated because it is the one rule the whole access-control
design depends on — see `BL-029` and the governance discussion that followed it.
4. **Existing accounts keep their roles** on first Okta login, exactly as D13's criterion
4 read for LDAPS.
### Why this, and not LDAPS first and Okta second
`BL-029` (recorded on this branch as `BL-027` before the renumbering forced by main's
independent use of that number) already laid out why OIDC beats the LDAPS bind on three
counts: this app never sees a password, MFA comes from Okta rather than needing to be
built, and the domain-lockout hazard that forced `AUTH_MAX_ATTEMPTS` down to 2 disappears,
because failed attempts land on Okta rather than on a bind this app makes. D13 was decided
before it was known the company already runs an Okta tenant. Once that was confirmed
(security's scoping reply, September 2026), shipping LDAPS first and replacing it with
Okta days later would mean building and deploying the weaker mechanism on purpose. Going
straight to Okta avoids that.
### What still needs answering before this is buildable
Open from the security scoping thread, not yet closed:
- Which OIDC claim carries the AD `sAMAccountName` equivalent (`preferred_username`,
`upn`, or a custom claim) — asked of security, answer pending.
- The exact redirect/callback URI once the hostname situation is reconfirmed
(`https://wp.controls.dev/api/auth/okta/callback` proposed).
- The `Business Technology Group` pilot in Okta, requested for initial testing, with
normal Okta session/MFA behavior rather than a stricter per-app rule.

View File

@@ -0,0 +1,122 @@
# Decisions — 2026-09-03
## D16: Okta admin bootstrap, break-glass posture, and the real scope of T10.4
Raised during hazard review for T10.4 (remove the local password path). D15 settled
*that* local passwords go away and Okta OIDC is the sole replacement; it did not settle
how an admin account gets named once there is no password to set, or what happens if
Okta itself is unreachable. Both are decided here.
### Admin bootstrap
`server/manage_users.py` stays the bootstrap tool — its own docstring already says so
("the `/api/auth/users` endpoint needs an existing admin, so you have to bootstrap one
here") — but it changes from *creating* an account to *promoting* one:
- An operator with shell/DB access on the server runs it against an account that
already signed in through Okta once and was JIT-provisioned by T10.3 (landing at
`project_user`, per that task). The command sets `role = admin` on that existing row
by username.
- It does **not** create a `User` row from scratch and does not touch `password_hash`
(the column is gone after T10.4's migration).
Rejected: minting a brand-new admin row by hand-typed username. `OKTA_IDENTITY_CLAIM`'s
exact format is still unconfirmed by security (open item carried from D15/wave-10.md).
A hand-typed username that doesn't exactly match what Okta actually sends produces a
second, orphaned account instead of promoting the real one. Promoting an
already-JIT-provisioned row sidesteps that entirely — it never has to guess the future
claim value.
Ongoing (non-bootstrap) admin naming needs no new work: `html/users.js` already has a
live role dropdown (`roleSelect`, gated by server-supplied `grantable_roles`) that lets
an existing admin promote any other account, including one JIT-provisioned via Okta.
That path is unrelated to the password removal and keeps working unchanged.
### Break glass
No break-glass path, by design. If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored.
This matches the precedent already on record for the abandoned LDAPS design (D13/D14):
"LDAPS is the *only* path, no local fallback, no break-glass." Carried forward
deliberately rather than assumed to still apply, given Okta's failure modes differ from
an internal LDAP bind — considered and confirmed, not defaulted into.
Rejected: a toggleable emergency local login gated behind an env flag. It would
reintroduce a stored local credential, exactly what D15 exists to eliminate, for a
scenario (Okta down) judged less likely and less costly than the standing risk of a
forgotten emergency backdoor.
The server-shell CLI (`manage_users.py`, promoting an existing row) is not a formal
break-glass mechanism — it cannot help if no account has ever signed in through Okta —
but it is the same trust tier as "someone with SSH/container access to prod could
already edit the database directly," and it costs no new engineering.
### T10.4 scope correction
Hazard review found real call sites of `hash_password` / `verify_password` /
`password_problem` that the original T10.4 bullet in `wave-10.md` didn't name and that
break the moment those functions are deleted:
- `create_user()` and `admin_reset_password()` in `server/app.py` (the admin console's
"add user" and "reset password" routes).
- `html/users.js`'s "add user" form (`nu-password` field) and "Reset password" button.
- `server/manage_users.py`'s `create`, `create-admin`, and `reset-password` subcommands
(see bootstrap section above for its replacement).
- `tests/browser_check.py` and `tests/launcher_check.py`, which call
`auth.hash_password()` to seed fixture rows.
Also found: `server/smoketest.py` and `server/seed_demo.py` authenticate via
`POST /api/auth/login`, which T10.4 removes, and CLAUDE.md's own verification section
names both scripts as required checks. Fix folded into T10.4 rather than deferred to
T10.7: both scripts switch to minting a session with `auth.create_token()` and setting
the cookie directly, the same technique `tests/browser_check.py` already uses instead
of scripting a login form. No live Okta tenant needed, and T10.4 no longer depends on
T10.7's timing.
`wave-10.md`'s T10.4 bullet is updated to reflect this full scope.
## D17: A dedicated rollback-aware runbook for the Okta cutover deploy
Raised while preparing to close out wave 10: T10.4's migration
(`server/alembic/versions/1d60a608bb51_drop_local_password.py`) drops the
`password_hash` column, and its `downgrade()` re-adds the column with
`server_default=''`. That restores the schema, not the data — the real bcrypt hashes
are destroyed the moment `upgrade()`'s `op.drop_column` commits, and no amount of
`alembic downgrade` brings them back. `DEPLOY-runbook-2026-08-04.md`, the existing
precedent for how a deploy of this repo is handed to IT, has no equivalent case in its
own Rollback section — its migrations are additive or reversible, so nothing there
warns an operator that this one is different.
Three options were on the table: write this runbook now; also document a staged
deploy sequence (ship T10.5's Okta-live-alongside-password state first, verify real
sign-ins, then ship T10.4's column drop as a separate follow-up deploy); or log the
hazard to `backlog.md` and stop there. Decided: write the runbook only. Staged
sequencing is real risk reduction but is a second deploy plan on top of a wave that is
otherwise a single cutover (D15's "full replacement," not a toggle) — worth
proposing on its own if IT wants it, not worth building unasked. Backlog-only was
rejected because the hazard is concrete and dated (this wave, this migration), not a
someday item.
### What the runbook has to do differently from the 2026-08-04 precedent
- Name the five `OKTA_*` environment variables as newly required for this deploy —
the precedent's own "no new environment variables" note does not apply here and
restating it unchanged would be actively wrong.
- Treat the pre-deploy backup (`docker exec wp_db_backup /scripts/db-backup.sh`) as
the only way back once `1d60a608bb51` commits, not as routine due diligence.
- Separate two failure modes that look similar but are not: an Okta app integration
that is misconfigured after a clean deploy (redirect URI, client secret, an
unassigned test account) is fixable in place — fix the config, redeploy the `api`
service, no data at risk, migration already applied and stays applied. Deciding to
abandon Okta and restore local-password code is the severe case — the empty
`password_hash` column means the old code has nothing to check a password against,
so the only way back is the destructive backup restore (`DEPLOY-runbook-2026-08-04.md`
Case C's own procedure, reused here). Conflating these two would send an operator
straight to a destructive restore for a problem that a config fix would have solved.
- Fold in D16's no-break-glass posture: if Okta is down or misconfigured, the app is
down for everyone, including admins, by design — not a defect to roll back from.
Filed as **T10.9** in `wave-10.md` rather than folded into T10.6 (already merged and
verified) or T10.8 (UI/test verification, a different kind of check). New scope found
after the task that raised it closed gets a new ID, per `CLAUDE.md`.

View File

@@ -0,0 +1,340 @@
# Decisions — September 17, 2026
Three items. Like `D11`, `D15`, `D16` and `D17`, these are new scope raised after
R2 closed out (see `docs/reference/completion.md`, T9.7), not a reopening of
anything already decided there.
`CR-019` and `CR-020` get `CR` ids because they are field/product-facing feature
requests — the same kind of thing `CR-001`-`CR-018` were — not internal
engineering calls made mid-build. `D18` gets a `D` id because it is exactly that:
an internal security/architecture call, the same category as `D15` (Okta vs.
LDAPS), not a feature a user asked for.
Requested/raised by Matt Mabrey, 2026-09-17.
---
## CR-019 — Usage and activity metrics (admin console)
- **Area:** Admin Console / Monitoring
- **Priority:** proposed High — the current admin console has no reliable way to
answer "who is using this and how much," which is the same visibility gap the
original UX review found in `B4` (numbers that look authoritative but are not).
- **Source:** Matt Mabrey, 2026-09-17.
### Why this is not already built
`D5`/`T7.10` already shipped a usage-analytics feature — `html/wp-usage.js`, with
a report at `admin.js:666-699` — but it reads `localStorage`, which is scoped to
one browser. The report's own empty state says exactly this: *"No usage recorded
in this browser yet."* It cannot show who across the team is active or what they
use, because each person's activity exists only on their own machine. This is the
same class of defect `B4` named for the pipeline strip — a number that looks
authoritative but is not — just never generalized to this feature. `CR-019` is
therefore new server-side work, not a UI addition on top of what exists.
Two things already in the schema are relevant and reused rather than duplicated:
`User.last_login_at` (one timestamp, no history) and `AuditLog` (append-only,
already records business mutations — WP created, status changed, role changed —
per user, with a timestamp). Neither captures navigation or feature-open events,
which is the actual gap.
### Intent
Give admins a real, server-backed picture of who is using the suite, what parts
of it they use, and how active they are — replacing the per-browser report as
the thing anyone actually looks at.
### Decisions, made 2026-09-17
1. **Retention: indefinite.** No automatic purge of usage-event data. (Separate
from `AuditLog`'s own retention, which this does not change.)
2. **Scope: suite-wide, filterable.** Not per-project by default; the console
provides filters (date range, project, user, tool/page) rather than scoping
the data itself.
3. **Export: raw view shows real identities; export supports sanitization.**
The admin console's own tables and the CSV export both default to real
usernames — this is an internal audit tool, not a public one. But the export
also offers a "sanitize" toggle that replaces the actor with a **stable
pseudonymous id** (a per-user hash, consistent across rows and across
export runs) rather than dropping the identity field outright — so an
external system (Power BI or similar) can still group and trend "by user"
without ever receiving a real name. Flagged here as the recommended
approach rather than the only one Matt confirmed in so many words: if a
fully-anonymous (no stable id at all) export turns out to be what's
actually wanted, that is a one-line change to the same feature, raise it
in the PR rather than treating it as blocking.
4. **The old per-browser report is retired**, not kept alongside the new one.
Once `CR-019` ships, `admin.js`'s existing `usage-admin` panel (reading
`WPUsage.load(...)` per browser) is removed rather than left next to the
real report, where it would show a smaller, misleading number for whoever
happens to have it open. `html/wp-usage.js` and its two call sites
(`html/work-package-suite-app.js`'s wizard dwell-tracking, and the
creator's equivalent) are a separate question — the *recording* code can
stay or go independent of the *admin report* being retired, since dwell
events were never reliably tied to a real identity anyway. Default to
removing both unless a task finds a reason to keep the recorder; log that
reason rather than deciding it here.
### Acceptance criteria
- A new admin-only tab in `admin.html` (same role gate as the User Directory)
shows: active users over a selectable date range (day/week/month), each
user's last-active timestamp, and a breakdown of which tools/pages get
opened and how often.
- Server-side event capture, keyed to the authenticated session (real identity,
not a browser-local guess) — a new table, not an extension of `AuditLog`,
since page-open/navigation events are not business mutations and mixing them
in would make `AuditLog` noisy for its existing, narrower purpose.
- The console's filters cover date range, project, user, and tool/page, and
combine (e.g., "user X, last 30 days, field view only").
- CSV export from the console, in both raw (real usernames) and sanitized
(stable pseudonymous id per user) modes.
- Retention is indefinite; nothing in this item purges data.
- View-only, refresh-on-load. No alerting — matches how the rest of the admin
console works today; if that changes later it is new scope, not a rider on
this item.
- The old per-browser "Usage" report and its admin-console panel are removed
in the same wave, not left running alongside the new one.
- Accessible per `CLAUDE.md`'s standing `C1` rules (this is a new component,
not a legacy one — it ships accessible or it is not done, same as
everything else built since wave 7).
### Frontend/backend boundary
This needs server work, the same way `CR-004`/`CR-018`/`B4` did: a real table,
a capture endpoint, an aggregation endpoint, and an export endpoint. If a task
under this item is being built by writing to `localStorage`, it is rebuilding
the exact defect this item exists to replace — stop and say so, per
`CLAUDE.md`.
### Scheduling
New wave. Wave 10 (Okta) is merged, so this does not wait on anything.
Task breakdown: `docs/waves/wave-11.md`.
---
## CR-020 — Bulk editing of users in the admin console
- **Area:** Admin Console / User Directory
- **Priority:** proposed High — three of the four actions below touch access
control (role, project assignment, active/disabled) and the fourth is a hard
delete; getting the guardrails right matters more than getting it built fast.
- **Source:** Matt Mabrey, 2026-09-17.
### Why this is not already built
Every user-editing action in `html/users.js` today is one row, one action:
a role `<select>` per row, an activate/disable button per row, a
per-user project-membership checklist opened one user at a time, and a
per-row delete. There is no row selection in the User Directory table at all.
Server-side, every corresponding endpoint
(`/api/auth/users/{id}/active`, `/role`, `/project-role`, `/projects`, and
`DELETE /api/auth/users/{id}`) takes exactly one `user_id`. Bulk editing is new
UI (selection) and, for most actions, either a loop over the existing
single-user endpoints or new endpoints that accept a list — the task decides
which, per user count and transaction-safety needs.
**Deletion is already a hard delete today** (`delete_user`, `server/app.py:1121`
— `db.delete(u)`, not a deactivate). Bulk delete inherits that: it is not this
item's job to invent a soft-delete pattern that does not exist for the single
case, but the confirmation step around it has to be sized for the fact that a
bad multi-select now removes more than one account at once, permanently.
### Decisions, made 2026-09-17
1. **All four bulk actions are in scope:** role change, activate/disable,
project assignment (add to / remove from a project, including the
project-role), and delete.
2. **Selection works two ways:** checkboxes (select-all and individual) in the
existing User Directory table, respecting whatever filter is already
applied (role, active/disabled, project) — and a CSV upload, for a one-off
bulk operation against an external list (e.g., an offboarding list that
didn't originate in this app). Both are in scope, not a choice between them.
### Acceptance criteria
- The User Directory table gains row checkboxes and a select-all that respects
the current filter; a bulk-action toolbar appears once at least one row is
selected.
- CSV upload as an alternative to checkbox selection: a list of usernames plus
the action to apply. Validates every row, reports rejected ones by row (bad
username, user not found, actor lacks permission over that user) rather than
silently skipping them — the same validate-and-report pattern `CR-005`
established for list uploads.
- Every existing single-user guardrail carries forward unchanged: an actor
cannot include their own account in a bulk action that would disable, demote,
or delete it; a super user's bulk action is scoped to only the users and
projects `require_see_user`/`require_manage_user` already let them touch
today (a super user cannot use a bulk action to reach a user or project
outside what they manage, even via CSV); `grantable_roles` still gates which
roles an actor may assign in bulk, the same as one at a time.
- Every affected row is written to `AuditLog` individually, exactly as the
single-user endpoints do today (one `role_changed` / `user_deleted` / etc.
row per user) — a bulk action is many audited changes, not one opaque batch
entry, so per-user history stays intact and readable in isolation.
- Confirmation before applying, using the `wp-dialog` kit (`T7.9`), not a
native `confirm()`. The dialog names exactly how many users are affected and,
for delete specifically, lists the affected usernames before committing.
- Partial failure is reported, not hidden: if some rows in a batch fail (scope,
already-deleted, bad CSV row), the action applies to what it can and states
exactly which rows failed and why. It never reports success on a batch that
partly failed.
- Accessible per `C1`: real controls, keyboard-operable selection and bulk-
action toolbar, `aria-live` announcing the result.
### Recommended, not yet confirmed — raise in the PR if this is wrong
- **Bulk delete gets an extra confirmation step beyond naming the count** —
proposed as typing a confirmation phrase (e.g. the word `DELETE`) regardless
of how many rows are selected, since one bad multi-select now removes more
than one account, permanently, with no soft-delete to fall back on. This is
a recommendation, not a confirmed requirement — Matt has not signed off on
the exact mechanism.
- **Project assignment is add/remove, not replace-the-whole-list** — a bulk
"add these users to project X" or "remove these users from project X"
action, rather than a bulk action that overwrites a user's entire project
list. Proposed because add/remove is less likely to clobber project
memberships the actor didn't intend to touch; a replace-the-whole-list
version is a materially different (and riskier) feature if that turns out to
be what's actually wanted.
### Frontend/backend boundary
Selection state (which rows are checked) is fine as client-side UI state — it
is not persisted data. Everything the bulk action actually does (role,
active/disabled, project membership, delete) already requires server work
today for the single-user case, and bulk does not change that: no new
localStorage-derived state, no client-side aggregation of what changed.
### Scheduling
New wave. Independent of wave 11 (`CR-019`) — the two do not touch the same
code and can build in either order or in parallel. Task breakdown:
`docs/waves/wave-12.md`.
---
## D18 — Detecting and acting on Okta/AD deprovisioning
- **Raised by:** Matt Mabrey, 2026-09-17, in response to a direct question about
what happens when someone's AD account is removed and Okta subsequently drops
them.
- **Amends:** nothing decided, closes a gap `D15`/`D16` left unaddressed. Those
items designed how identity flows *into* this app (Okta authenticates, this
app JIT-provisions and owns roles); neither addressed what happens when an
identity is withdrawn. This app has never had any deprovisioning signal —
push or pull — since Okta went live.
### What was found
Traced through `server/auth.py` and `server/okta_auth.py`: this app has no
connection back to Okta after initial sign-in. Consequences, confirmed against
the actual code:
1. A local `users` row is never touched by anything Okta-side. `is_active`
stays `True` indefinitely unless an admin manually disables or deletes the
account through the User Directory. There is no flag distinguishing a
terminated employee's account from a current one.
2. `get_current_user` (`server/auth.py:237`) re-checks `is_active` and
`token_version` on **every request** — so a manual disable takes effect
immediately, on the very next request. The gap is not enforcement, it's
detection: nothing tells an admin to go flip that switch.
3. A session already live when someone is deprovisioned keeps working, fully,
for up to `AUTH_SESSION_HOURS` (12 hours today, `server/auth.py:63`),
because validity is checked against this app's own JWT and DB state only,
never against Okta.
4. A NEW session can't be established once Okta drops the account —
`okta_login`/`okta_callback` requires completing Okta's own sign-in, which
Okta itself refuses. The front door closes on its own; the back door (an
already-open session, and the stale local record) does not.
### Decisions, made 2026-09-17
1. **Build a scheduled sync against Okta's Management API**, not an Event Hook.
This app has, since `D15`, only ever reached out to Okta — never received
anything from it — and a polling design keeps that shape rather than
introducing a new inbound, internet-reachable endpoint with its own
signature-verification surface. Traded deliberately: this is
poll-interval-late rather than real-time, which is judged acceptable for an
HR/offboarding-driven event, not a to-the-second requirement.
2. **Revised 2026-09-23, in response to Matt's question about idle time
instead of a flat session length: sessions now slide on activity, with a
hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check
with Okta on a fixed schedule regardless of activity; a pure idle timer
with no ceiling does the opposite — a continuously-active session would
never force a fresh Okta check on its own, which is a worse fit for the
exact threat this item exists to address (someone still clicking around
after being deprovisioned). Decided: **both**.
- `AUTH_IDLE_MINUTES` (new, default **30**): a session with no request
for this long stops being valid. Implemented as a sliding JWT expiry —
the token is reissued with a fresh `exp` on activity, throttled so the
cookie isn't rewritten on literally every request.
- `AUTH_SESSION_HOURS` (existing var, meaning changes to an **absolute
ceiling**): no session survives past this many hours from the original
sign-in, no matter how continuously active it is. Default changing from
12 to a proposed **8** — flagged as a recommendation, not confirmed.
- Both defaults, and the mechanism itself, should be sanity-checked
against the tenant's actual Okta SSO session policy — if Okta's own
session silently outlives either number, re-authentication here is
likely a fast redirect, not a real re-login screen, so these numbers
cost less than they look like they do. Confirm before treating either
as final.
3. **The sync only ever disables an account — it never re-enables one.** A
rehire showing active in Okta again does not automatically restore access;
an admin re-enabling the account is a deliberate act, consistent with
`D16`'s posture that this app never auto-grants access on its own initiative.
4. **Fail closed on the side of INACTION, not disablement.** This is the
opposite failure direction from `D16`'s login-time posture ("if Okta is
unreachable, the app is unreachable for everyone"). Here, an Okta API
error, timeout, empty response, or anything the sync can't confidently
parse must result in **no change to any account** that cycle, plus a
logged failure. A sync job that treats "couldn't reach Okta" as "nobody is
active" is a far worse outcome than a missed cycle — it would lock out the
entire org on an Okta API hiccup. This is the single most important
acceptance criterion in this item.
5. **Every auto-disable is audited individually**, same as every other
account-state change in this app: an `AuditLog` row per user, with an actor
value that's clearly the sync job and not a person (e.g.
`system:okta_sync`), so it reads correctly in the User Directory's history
and is never confused with an admin's own action.
6. **New credential required:** a read-scoped Okta API token (or an Okta
service-app OAuth2 client), separate from the `OKTA_CLIENT_ID`/
`OKTA_CLIENT_SECRET` pair used for sign-in. This needs provisioning by
whoever administers the Okta tenant — the same dependency that gated the
original OIDC rollout (D15's "security scoping reply").
### Recommended, not yet confirmed — raise in the PR if this is wrong
- **Sync interval:** proposed every 15 minutes. Frequent enough that the
detection gap is small, infrequent enough not to hammer Okta's API or need
special rate-limit handling. Not confirmed with IT/security.
- **Where the job runs:** proposed as an in-process background task inside the
existing `api` container (it already has `outbound` network access to reach
Okta, and already holds the Okta client config) rather than a new sidecar
container. The `backup` container (`docker-compose.yml`) is the existing
precedent for a scheduled-interval container in this stack, if isolation
from the `api` process is preferred instead — a reasonable alternative, not
the recommendation.
- **Admin visibility:** at minimum, an auto-disable is a normal, readable
`AuditLog` entry (visible whever admin already reviews audit history, and
naturally covered once `CR-019`'s activity view exists). Whether it should
also trigger an email/notification to admins is a genuine open question —
proposed as a fast-follow rather than blocking this item, since `D10`
already established the pattern for admin-controlled email toggles this
could reuse.
### Frontend/backend boundary
Entirely server-side and infrastructure. No new `localStorage` state, no
frontend surface beyond what already reads `is_active` and `AuditLog` today
(the User Directory, and eventually `CR-019`'s activity view).
### Scheduling
New wave, independent of wave 11 and wave 12 in the sense that nothing here is
blocked by them — but note it touches the same `is_active`/account-state
surface `CR-020`'s bulk actions touch in `server/app.py`. Not a hard
dependency; sequence commits to avoid an avoidable merge conflict, per
`CLAUDE.md`'s "one task per PR" spirit. Task breakdown: `docs/waves/wave-13.md`.

View File

@@ -0,0 +1,85 @@
# Session notes — 2026-09-23
Working notes for the `feat/waves-11-13` line (CR-019, CR-020 reserved, D18),
written up before merge. Not a spec document — `wave-11.md`, `wave-13.md` and
`decisions-2026-09-17.md` are the source of truth for scope and acceptance
criteria. This is the "what actually happened building it" record.
## What shipped today
**CR-019 (wave 11) — usage/activity metrics, T11.1 through T11.6 complete:**
- `UsageEvent` table + migration (T11.1), a capture endpoint wired into
`auth-guard.js` so every protected page pings it once per load, plus a
`login` event at Okta sign-in (T11.2).
- `GET /api/usage/summary` (T11.3) — active users by day/week/month,
per-user last-active, per-tool breakdown, filterable by date/project/
user/tool, all combinable.
- `GET /api/usage/export` (T11.4) — raw and sanitized CSV. Sanitized mode
replaces the username with an HMAC-SHA256 pseudonym (keyed with
`auth.SECRET_KEY`), stable per user across rows and across separate
export calls, so an external tool (Power BI etc.) can still group by
user without ever seeing a real name.
- A new "Activity & usage" card in the admin console (T11.5): real filter
controls, the summary tables, both export buttons. Client-side gated
admin-only same as the rest of the console; the API underneath is
independently gated server-side regardless.
- Retired the old per-browser "Usage logs" panel and `wp-usage.js`
entirely (T11.6) — it had no reader left and was never a real data
source for the new report anyway. The scattered `track()` call sites in
the creator and wizard were left in place calling a documented no-op,
rather than deleting ~45 individual call sites for the same effect.
Only **T11.7 (final wave verification)** is left before wave 11 is fully
closed out — everything under it has already been verified per-task, so
this is a consolidation pass, not new work.
**D18 (wave 13) — Okta/AD deprovisioning, T13.1 only:**
- Session lifetime changed from one flat `AUTH_SESSION_HOURS` to a sliding
idle timeout (`AUTH_IDLE_MINUTES`, default 30) capped by a hard ceiling
from original sign-in (`AUTH_SESSION_HOURS`, default 8, meaning changed
from "session length" to "absolute ceiling"). Both defaults are flagged
in `.env.example` and `DEPLOYMENT.md` as proposed, not confirmed against
the tenant's actual Okta SSO policy.
- **T13.2 onward (the actual Okta Management API sync job) is paused** —
explicit call from Matt: no Okta API credential yet, come back to it
later. Not started, not blocked on anything code-side.
**CR-020 (wave 12, bulk user editing):** not started. Reserved, scoped in
`wave-12.md`, no code touched.
## Environment work (not itself a task, but load-bearing)
- Fixed a CRLF/LF mismatch that was making every tracked file look modified
to this session's git client (`core.autocrlf true`, repo-local, no file
content changed).
- This machine had no Python. Installed it via `winget` (`Python.Python.3.12`)
and set up a `.venv` in the repo with `server/requirements.txt` installed,
specifically so `tests/baseline_shots.py` could run locally — this
sandbox has no headless-capable browser and can't download one (network
allowlist), so the 390px/1440px screenshot verification CLAUDE.md asks
for had to run on Matt's own machine instead, using the browser already
installed there (Edge).
- Established a repeatable local verification loop for every task: throwaway
SQLite, fake-Okta sign-in, promote to admin, `seed_demo.py` +
`smoketest.py` (27/27 passing throughout), plus `baseline_shots.py` for
anything touching `html/`.
## Standing constraints, still in effect
- Everything stays local on this branch line. No `git push` at any point
today.
- One task per PR discipline was kept even though all of it landed on one
branch — each commit corresponds to exactly one task ID, in wave order,
each individually verified before the next started.
## Before merging
- Run T11.7 (full wave-11 verification pass) and record it in `wave-11.md`.
- Decide where this branch actually merges to — `feat/waves-11-13` has all
of today's commits already; this notes branch was cut from it so it can
fast-forward back in, or merge as its own PR if the notes should be
reviewed separately from the code.
- T13.2+ and all of wave 12 remain explicitly out of scope until Matt says
otherwise.

View File

@@ -180,6 +180,6 @@ The script authenticates like a client; the server does not get weaker.
- [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots
- [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`)
- [ ] `S13` fixed and seeding works
- [x] `S13` fixed and seeding works (ticked 2026-08-20: the box was missed at the wave exit; re-verified end to end - sign-in, seed, --clean)
- [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`

233
docs/waves/wave-10.md Normal file
View File

@@ -0,0 +1,233 @@
# Wave 10 — Okta OIDC authentication
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
and Okta chosen instead.
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
## Tasks
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and
validates the ID token. Access gating is Okta's job, not this app's: only accounts
assigned to the app integration in Okta can reach it at all, so there is no app-side
required-group or claim check layered on top. This is a deliberate difference from D13,
which had to gate on a required AD group itself because an LDAPS bind alone could not
distinguish an assigned user from any other domain account.
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
yet answered. Build with a configurable claim name and a documented default, not a
hardcoded one, so the answer can drop in without a code change.
- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
review turned up more call sites than the original bullet named:
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
"add user" and "reset password" routes) — rework to drop the password field
entirely rather than break.
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
matching frontend change.
- `server/manage_users.py` — reworked per `D16` from account *creation* to
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
replaced by a promote-by-username command that operates on a row Okta's JIT
provisioning (T10.3) already created, never a hand-typed new one. This is now
the documented admin-bootstrap path — see `D16`.
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
`auth.hash_password()` to seed fixture rows.
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
directly, the same technique `browser_check.py` already uses, so both scripts
(named explicitly in `CLAUDE.md`'s verification section) keep working without
depending on `T10.7`'s timing.
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js`
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page.
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
config they never ended up describing (D13 never shipped, so these still describe the
original local-password system today).
Built: all three rewritten — the five `OKTA_*` vars documented the same way
`AUTH_SECRET_KEY` already was, the login-portal/self-service-reset sections replaced
with the Okta flow and the promote-not-create admin bootstrap (D16), the smoke-test
walkthrough updated for `WP_SMOKE_USER`-only / must-share-`AUTH_SECRET_KEY`-and-DB
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
`env_file`, so the documented vars would silently never reach the container without
adding them there too — found and fixed in the same commit rather than shipping docs
for a config path that doesn't work. `OKTA_IDENTITY_CLAIM` mirrors
`okta_auth.py`'s own default (`preferred_username`) in the compose file rather than
defaulting to an empty string, which would 503 every sign-in.
Logged, not fixed here (out of scope): `users.failed_attempts`/`locked_until` are
vestigial (still reset on every Okta sign-in, nothing increments them since local
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section
is a self-contained alternate quickstart that already diverged from the real root
`docker-compose.yml` before this task and still does.
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable.
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
the real Authlib client is considered. Only the two Authlib calls that touch the
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
provisioning, the identity-claim lookup) run unmodified against the fake, same
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
screen and are registered in `app.py` only when the fake is active at import time —
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
`start_server()` now takes an optional `extra_env` and sets
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
used: almost nothing signs in, but the one check that does should not fail
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
real round trip — login.html's button, the fake picker page, the fake consent
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
an unknown identity, a disabled account, JIT provisioning, an existing admin
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
genuinely working under a non-default claim name — 22/22.
The dev sandbox this was built in has no headless browser and no way to install
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
`tests/cdp.py`'s real headless Chromium) could not be run there — only
`okta_auth_check.py`'s HTTP-level coverage of the same mechanism. Run for real
afterward on a machine with Docker, via a separate general-purpose tool
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
clean, the last two against real headless Chromium via Docker.
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
matching the rigor D13 was held to.
Done-when checks per task, verified against the actual code rather than
re-reading this file's own claims: T10.1 (Authlib pinned, the four env vars,
`is_configured()`/`describe()`), T10.2 (the login/callback routes, no
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
matching, JIT at the lowest role, local deprovisioning still enforced after
Okta approves), T10.4 (zero remaining references to `bcrypt` /
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
T10.5 (`login.html` is one Okta link, no password field). All matched what
this file already claimed — no drift found.
Full suite, run through the Docker test runner (all 41 files in `tests/`,
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
but that is a harness mismatch, not a check failure — it is a two-step
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
usage + exits 2 when run with no arguments, which is what a bare full-suite
pass does to every file. `tests/generalinfo_check.py` scored 48/49 — the one
failure is a raw `rgba()` shadow literal in `wp-creation-styles.css`,
confirmed via `git show HEAD` to already be committed and unrelated to this
wave (it is the Micron asset picker's dropdown shadow from the `D11` merge,
2026-08-20, predating this wave by two weeks). Logged as `BL-031` rather than
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots
(login, launcher, sop, creator, admin, users, field × two widths) into
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and
`users-390.png`/`users-1440.png` visually confirmed: the login page is a
single "Sign in with Okta" button with no username/password form at either
width, and the User Directory's table and "Add a user" form both carry no
password column and no reset-password action anywhere, at either width.
Wave 10 is complete. The three items in "Still open" below are external
(security team / Okta admin), not blocked on any task in this wave.
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8:
`cmd_promote()` changed a user's role with no audit trail at all, unlike the
identical role change from the web Admin Console (`app.py`'s
`set_user_role()` → `log_event()`, action `"role_changed"`). Not a new
privilege — anyone with Portainer/container-exec access to `wp_api` already
has shell access to the database directly, same trust tier D16 already named
for this command — but there was no record of who ran it or what changed.
Built: `cmd_promote()` now writes an `AuditLog` row with the same
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to":
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via":
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec
carries no signed-in identity to attribute the change to a real person, so
it names the tool rather than guessing one. Verified end to end against a
scratch SQLite database: the audit row lands with the exact expected shape,
the role change persists, and the existing "no such user" refusal still
exits 1 with no partial write.
- **T10.9 — Rollback-aware deploy runbook.** Raised after hazard review found
`DEPLOY-runbook-2026-08-04.md`'s Rollback section has no case for a migration whose
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
bcrypt hashes do not.
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names
the five `OKTA_*` vars as newly required (the precedent's "no new environment
variables" note does not carry over), treats the pre-deploy backup as the only way
back once the migration commits, and splits Rollback into the fixable case (Okta app
integration misconfigured — fix and redeploy `api`, no data at risk, migration stays
applied) versus the severe case (abandoning Okta for local-password code — only the
destructive backup restore gets there, reusing the 2026-08-04 runbook's own Case C
procedure). D16's no-break-glass posture is stated plainly rather than left implicit.
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line
(referenced by `DEPLOYMENT.md`/`server/README.md`) has no caller anywhere in
`server/app.py` — nothing actually prints it at process start. The runbook's Step 4
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
flagged in `docs/waves/backlog.md` as a candidate fix (wiring `describe()` into
startup) since it directly bears on deploy verifiability. `DEPLOY-login-portal.md`
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
not touched, no task claims it.
## Still open
- The `Business Technology Group` pilot assignment in Okta. Originally six names
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
deliberately, pending the live sign-in confirmation below — awaiting his response to
add the rest of the group now that it has.
Closed since first written: admin bootstrap and break-glass posture, previously open
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
**Closed 2026-09-09, live in production:** the redirect/callback URI
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
never actually confirmed by name in Request 50649's thread) is correct, no
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
users, not the seeded test fixture), landed on `index.html` signed in, admin role and
project access untouched. One real deploy-time snag on the way, worth recording since
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in
Portainer (env var names added, values never filled in) — caught via
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
temporarily unavailable"), not silently. A second snag after filling those in:
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
neither needed the backup or the database.

257
docs/waves/wave-11.md Normal file
View File

@@ -0,0 +1,257 @@
# Wave 11 — Usage and activity metrics
**Items:** `CR-019`
**Depends on:** wave 10 merged (it is; this wave does not wait on anything else)
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Seven tasks, one concern each, in build order. Do not start a task whose
dependency is not merged. `CR-020` (bulk user editing) is reserved but not
scoped — it does not belong in this wave.
---
### T11.1 — CR-019: `usage_events` table + migration
- **Items:** `CR-019`
- **Depends on:** nothing (first task)
- **Blocks:** T11.2
- **Surface:** `server/`
- **Files:** `server/models.py`, `server/alembic/versions/`
**Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for
navigation/feature-open events rather than business mutations. Suggested shape:
`id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor`
does today for consistency), `project_id` (nullable — not every event is
project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`,
`wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g.
`page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not
touch `AuditLog` — this is a new table, not an extension of it (see the
decision record's reasoning).
**Do not:** fold this into `AuditLog`. They serve different questions and mixing
them makes the existing audit trail noisier for its existing readers.
**Done when:**
- [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned
by date range constantly)
- [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) —
render `alembic upgrade --sql` for postgresql and read it before calling
this done, per the class of defect `BL-027` logged
- [ ] no change to `AuditLog`'s shape or behavior
---
### T11.2 — CR-019: capture the events
- **Items:** `CR-019`
- **Depends on:** T11.1
- **Blocks:** T11.3
- **Surface:** `server/` + `html/`
- **Files:** `server/app.py` (new endpoint), `html/auth-guard.js`
**Problem:** Six pages need this and none should implement it separately — that
is exactly how `S4`'s "no global nav on two pages" and the four parallel token
systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `<head>`,
on all six protected pages (`index.html`, `field.html`, `users.html`,
`wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already
knows the verified user once the `wp-auth-ready` event fires. That is the one
place this belongs.
**Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one
`UsageEvent` row per call, keyed to the session (server trusts the session, not
anything the client claims about identity). Call it once from `auth-guard.js`
after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a
`login` event at the point a session is actually established (reuse whatever
`okta_callback` already does at sign-in — do not add a second source of truth
for "did this person log in").
**Do not:** build a per-page capture call. If a page needs this and
`auth-guard.js` does not cover it, fix `auth-guard.js`, not the page.
**Done when:**
- [ ] one `page_open` event is recorded for a real sign-in on each of the six
pages, verified per page
- [ ] exactly one `login` event per Okta sign-in, not one per page load after
it
- [ ] the endpoint rejects a request with no valid session (this is server-
enforced identity, not client-reported)
- [ ] no page other than `auth-guard.js` calls this endpoint directly
---
### T11.3 — CR-019: aggregation endpoint with filters
- **Items:** `CR-019`
- **Depends on:** T11.2
- **Blocks:** T11.4, T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** Build the read side: active-user counts by day/week/month, per-user
last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`,
which only ever holds one value), and a per-tool usage breakdown. Accept query
filters: date range, project, user, tool — combinable, per the decision record.
This is server aggregation, the same principle `B4` established for the
pipeline strip: the browser asks for a number, the server computes it from real
rows, nothing is derived client-side from a partial cache.
**Done when:**
- [ ] active-user counts are correct against a seeded fixture with known dates
- [ ] filters combine correctly (verified: user + date range + tool together
narrows correctly, not just each alone)
- [ ] a project filter that matches nothing returns an empty result, not an
error or the unfiltered total
---
### T11.4 — CR-019: export, raw and sanitized
- **Items:** `CR-019`
- **Depends on:** T11.3
- **Blocks:** T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** A CSV export endpoint over the same filtered query T11.3 exposes.
Two modes: raw (real usernames, the console's default) and sanitized. Sanitized
mode replaces the actor field with a stable pseudonymous id — a per-user hash,
consistent across rows in the same export and across separate export runs —
so an external system (Power BI or similar) can still group and trend "by
user" without ever receiving a real name. Do not simply drop the identity
column; that breaks per-user grouping downstream, which defeats the point of
an activity export.
**Done when:**
- [ ] raw export contains real usernames
- [ ] sanitized export never contains a real username or email anywhere in the
file, including in a `detail` blob if one is included
- [ ] the same real user maps to the same pseudonymous id within one export AND
across two separate export runs (a hash of something stable, not a
per-request random id)
- [ ] both modes otherwise contain identical rows for the same filter
---
### T11.5 — CR-019: admin console Activity tab
- **Items:** `CR-019`
- **Depends on:** T11.3, T11.4
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.html`, `html/admin.js`
**Do:** New tab, same role gate as the User Directory. Filters (date range,
project, user, tool) driving the tables from T11.3; export buttons (raw and
sanitized) calling T11.4. Build accessible from the start per `C1` — this is a
new component, not a legacy one carrying an old defect forward: real
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
count that updates without a page reload, focus visible throughout.
**Done when:**
- [x] the tab is reachable only by an admin (the card lives inside admin.html,
already gated client-side by gateByRole(); the API underneath it is
independently gated server-side by require_user_manager regardless)
- [x] every filter is a real form control, keyboard-operable (date/select/text
inputs and a `<button>`, no click-div)
- [x] both export buttons produce the files T11.4 defines (verified against
the live endpoint in T11.4's own checks, and present/wired here)
- [x] works at 390px and 1440px — verified 2026-09-23 via
`tests/baseline_shots.py --pages admin` run locally on Windows (this
sandbox has no headless browser available; the script was run on the
user's machine instead, after installing Python via winget since it
wasn't present). Screenshots in `docs/reference/baseline/admin-390.png`
/ `admin-1440.png`. No JS errors, no horizontal overflow at either
width; the card rendered with real seeded data (events, by-tool,
per-user-last-active tables) confirming the filters and summary read
correctly, not just that the markup exists.
---
### T11.6 — CR-019: retire the per-browser Usage report
- **Items:** `CR-019`
- **Depends on:** T11.5
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
`html/wp-usage.js` and its two call sites
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
the real one exists, per the 2026-09-17 decision. Decide what happens to
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
equivalent): they were never reliably tied to a real identity, so they are not
a data source the new report can adopt. Default to removing the recorder too
unless it is still doing something useful on its own (re-read what it actually
records before deciding — do not assume from this file alone).
**Do not:** leave the old panel in place "just in case." Two activity reports
showing two different numbers is worse than one.
**Decision (2026-09-23):** `wp-usage.js` is removed, not kept — it had no
reader left once the admin panel above it was removed (the "download the
full event log" button lived only in that panel), and per the original
decision record it was never reliably tied to a real identity, so it was
never a candidate source for the new report either. The file itself and its
three `<script>` includes (`admin.html`, `wp-creation-index.html`,
`work-package-suite.html`) are gone. Its two call sites
(`wp-creation-app.js`, `work-package-suite-app.js`) keep a local `track()`
function as a documented no-op rather than having each of their ~45
individual `track('event', …)` call sites deleted one at a time — that
would be a far larger, riskier diff for the same outcome (no more data is
recorded either way), and it keeps each call site as a marker of what was
worth recording if usage analytics are ever rebuilt server-side. The
dwell-timer plumbing that only ever fed `track()` (`work-package-suite-app.js`'s
`_stepEnter`/`trackStepDwell`) was left in place for the same reason — it is
inert now, not broken, and touching it buys nothing.
Also removed: `tests/usage_check.py` (tested exactly the retired feature —
D5/T7.10's per-browser analytics core and admin report) and its line in
`docs/reference/file-map.md`, replaced with a note pointing at this decision.
**Done when:**
- [x] the old `usage-admin` panel and its markup are gone from `admin.html`/
`admin.js`
- [x] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
stated reason) — not left ambiguous — see above
- [x] nothing else in the app references the removed code; grep confirms
(only remaining hits are this file, the 2026-09-17 decision record, and
the two explanatory code comments left at the retired call sites — all
prose, not live references)
---
### T11.7 — CR-019: verification
- **Items:** `CR-019`
- **Depends on:** T11.6
- **Blocks:** nothing
- **Surface:** `html/` + `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
test and `seed_demo.py`, run the full suite.
**Done when:**
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
a failure is stated with a reason
- [ ] screenshots committed
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 11 exit criteria
- [ ] real, server-side activity data exists per user, indefinitely retained
- [ ] the admin console shows it, filterable by date/project/user/tool
- [ ] export works in both raw and sanitized form
- [ ] the old per-browser report is gone, not duplicated
- [ ] `CR-019` fully accounted for, no open acceptance criteria

193
docs/waves/wave-12.md Normal file
View File

@@ -0,0 +1,193 @@
# Wave 12 — Bulk editing of users
**Items:** `CR-020`
**Depends on:** wave 10 merged (it is). Independent of wave 11 (`CR-019`) — no
shared files, may build in either order or in parallel.
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Six tasks, in build order.
---
### T12.1 — CR-020: bulk endpoints
- **Items:** `CR-020`
- **Depends on:** nothing (first task)
- **Blocks:** T12.2, T12.4
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** Add bulk variants of the four existing single-user actions — role
change, active/disabled, project assignment (add/remove + project-role), and
delete. Each takes a list of `user_id`s plus the action's parameters and
applies `require_see_user`/`require_manage_user`/`grantable_roles` **per row**,
exactly as the single-user endpoint does today — a super user's bulk request
cannot reach further than their existing single-user requests can. Do not skip
the self-action guard: an actor's own account is rejected out of any batch
that would disable, demote, or delete it, same as today.
Each successful row writes its own `AuditLog` entry via `log_event`, same
action names the single endpoints already use. A row that fails is reported in
the response (user id, reason) and does not stop the rest of the batch from
being attempted.
**Do not:** invent a single opaque "bulk_action" audit entry in place of the
per-row entries. Do not build a soft-delete path for bulk delete that doesn't
exist for the single case — bulk delete stays a hard delete, matching
`delete_user` today.
**Done when:**
- [ ] each of the four bulk actions is callable with a list of user ids and a
single set of parameters
- [ ] a batch containing the actor's own account rejects only that row, not
the whole batch — verified for disable, demote, and delete
- [ ] a super user's batch that includes a user/project outside what they
manage rejects only that row, with a stated reason
- [ ] every successful row produces its own `AuditLog` entry, identical in
shape to what the single-user endpoint would have written
- [ ] a batch with some failing rows still applies to the rows that succeed,
and the response lists exactly which rows failed and why
---
### T12.2 — CR-020: row selection in the User Directory table
- **Items:** `CR-020`
- **Depends on:** T12.1
- **Blocks:** T12.3
- **Surface:** `html/`
- **Files:** `html/users.js`, `html/users.html`
**Do:** Add a checkbox per row and a select-all control, respecting whatever
filter (`role`, `active`/`disabled`, project) is currently applied — select-all
selects the filtered set, not every user in the system regardless of what's
shown. A bulk-action toolbar appears once at least one row is checked and
disappears at zero.
**Done when:**
- [ ] select-all selects exactly the rows currently visible under the active
filter, not the full unfiltered table
- [ ] changing the filter while rows are selected does something sane and
visible (either clears the selection or keeps it explicit which rows are
still selected) — pick one and state it, don't leave it undefined
- [ ] the toolbar is keyboard-reachable and only present when >=1 row is
selected
---
### T12.3 — CR-020: bulk-action toolbar
- **Items:** `CR-020`
- **Depends on:** T12.2
- **Blocks:** T12.5
- **Surface:** `html/`
- **Files:** `html/users.js`
**Do:** Wire the toolbar to T12.1's endpoints for role change, activate/
disable, and project assignment (add to / remove from a project + project-
role). Confirmation before applying uses the `wp-dialog` kit (`T7.9`) —
`wpConfirmDialog`, not a native `confirm()` — naming exactly how many users are
affected. On completion, report per-row results if anything failed (T12.1
already returns this) rather than a single success/failure toast that hides a
partial failure.
**Delete is built separately, in T12.5** — do not wire delete here.
**Done when:**
- [ ] role change, activate/disable, and project assignment each work end to
end against a multi-row selection
- [ ] the confirmation dialog names the exact affected count before anything is
sent
- [ ] a batch with a partial failure shows which rows failed, not just an
undifferentiated error
- [ ] `aria-live` announces the outcome
---
### T12.4 — CR-020: CSV upload path
- **Items:** `CR-020`
- **Depends on:** T12.1
- **Blocks:** T12.6
- **Surface:** `html/` + `server/`
- **Files:** `html/users.js`, `server/app.py`
**Do:** An upload accepting a list of usernames plus the action to apply,
following the validate-and-report pattern `CR-005` established: reject and
report bad rows (username not found, actor lacks permission over that user)
rather than silently dropping them. This is a second entry point onto the same
T12.1 endpoints, not a third implementation of the bulk logic.
**Done when:**
- [ ] a CSV with a mix of valid and invalid usernames applies to the valid
rows and reports the invalid ones by row, with a reason
- [ ] the same permission/self-action guards from T12.1 apply here — a CSV
cannot reach a user a checkbox-driven batch couldn't
- [ ] duplicate usernames in one CSV are handled without double-applying or
erroring confusingly
---
### T12.5 — CR-020: bulk delete confirmation
- **Items:** `CR-020`
- **Depends on:** T12.3
- **Blocks:** T12.6
- **Surface:** `html/`
- **Files:** `html/users.js`
**Do:** Wire delete into the toolbar with a heavier confirmation than the other
three actions, per the decision record's recommendation: list the affected
usernames and require typing a confirmation phrase (e.g. `DELETE`) before the
request is sent, regardless of how many rows are selected. This is flagged in
the decision record as a recommendation Matt has not explicitly signed off on
— if the PR reviewer wants a lighter or heavier mechanism, that's the moment to
change it, not a reason to skip building a real confirmation now.
**Done when:**
- [ ] the affected usernames are listed in the confirmation dialog before
delete is sent
- [ ] the request is not sent until the confirmation phrase is typed correctly
- [ ] the actor's own account, if somehow selected, is rejected with a clear
reason rather than silently included or silently dropped
---
### T12.6 — CR-020: verification
- **Items:** `CR-020`
- **Depends on:** T12.4, T12.5
- **Blocks:** nothing
- **Surface:** `html/` + `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`: run locally, exercise bulk role
change, activate/disable, project assignment, CSV upload, and bulk delete at
390px and 1440px, before/after screenshots, smoke test, `seed_demo.py`, full
suite.
**Done when:**
- [ ] all `CR-020` acceptance criteria in `decisions-2026-09-17.md` are met or
a failure is stated with a reason
- [ ] screenshots committed
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 12 exit criteria
- [ ] all four bulk actions work against both a checkbox selection and a CSV
upload
- [ ] every existing single-user guardrail (self-action, scope, grantable
roles) holds under bulk use
- [ ] bulk delete requires typed confirmation and lists affected usernames
- [ ] partial failures are always reported, never hidden behind a blanket
success
- [ ] `CR-020` fully accounted for, no open acceptance criteria

242
docs/waves/wave-13.md Normal file
View File

@@ -0,0 +1,242 @@
# Wave 13 — Okta/AD deprovisioning sync
**Items:** `D18`
**Depends on:** wave 10 merged (it is). Not blocked by wave 11 or wave 12, but
shares `server/app.py` account-state surface with wave 12 (`CR-020`) — sequence
commits to avoid an avoidable conflict.
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Six tasks, in build order. **T13.3's fail-closed behavior is the most
important done-when list in this wave — do not relax it to ship faster.**
---
### T13.1 — D18: idle timeout with an absolute ceiling
- **Items:** `D18`
- **Depends on:** nothing (first task, independent of the rest)
- **Blocks:** nothing
- **Surface:** `server/`
- **Files:** `server/auth.py`, `server/app.py` (`auth_gate` middleware),
`server/.env.example`, `DEPLOYMENT.md`
**Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
asked whether an idle timeout would be a better fit than a flat session
length. It is, but not by itself — see the decision record's reasoning on why
idle-with-no-ceiling is actually a worse fit for this item's own threat model
than a flat expiry would have been. Build both.
**Do:**
- Add `login_at` to the JWT payload in `create_token()` — the original
sign-in time, distinct from `iat`, which becomes "when THIS token was
issued" once tokens start getting reissued. `login_at` never changes across
reissues; it's what the absolute ceiling is measured from.
- Add `AUTH_IDLE_MINUTES` (default 30). `AUTH_SESSION_HOURS` stays the name
for the absolute ceiling, default changing from 12 to a proposed 8 — update
its docstring/comment in `auth.py` and `.env.example`, since its MEANING is
changing (session length -> hard ceiling on top of a sliding idle window),
not just its value.
- In `auth_gate` (`server/app.py`), after confirming a request is
authenticated: if `now < login_at + AUTH_SESSION_HOURS` (the ceiling hasn't
passed), compute `new_exp = min(now + AUTH_IDLE_MINUTES, login_at +
AUTH_SESSION_HOURS)`. If `new_exp` is meaningfully later than the current
token's `exp` (throttle this — do not reissue on every single request, only
when enough time has passed to be worth a new cookie; a few minutes of
slack is fine), mint a refreshed token carrying forward `sub`/`username`/
`role`/`ver`/`login_at` unchanged, and set it on the response.
- If the ceiling HAS passed, do not refresh — let the existing token expire
on its own terms (it may already be invalid, or may tick over within the
idle window; either way, no new one is issued past the ceiling).
- The middleware should not need a DB round trip to do this — everything
needed (`sub`, `username`, `role`, `ver`, `login_at`) is already in the
validated claims. `get_current_user`'s existing per-request `is_active`/
`token_version` check is unaffected and still runs separately.
**Do not:** reissue the cookie on every request unconditionally — that's a
`Set-Cookie` header on every API call for no benefit over a coarser refresh.
Do not let the ceiling check silently vanish for tokens issued before this
change ships — a token with no `login_at` claim should fall back to treating
its own `iat` as `login_at`, not bypass the ceiling entirely.
**Done when:**
- [ ] a session with continuous activity stays alive past 30 minutes but is
cut off at the `AUTH_SESSION_HOURS` ceiling regardless
- [ ] a session with no activity for 30+ minutes is rejected on its next
request
- [ ] the cookie is not rewritten on every single request — verify the
refresh is throttled, not unconditional
- [ ] a pre-existing token with no `login_at` claim (simulating a session
issued before this shipped) still gets a hard ceiling, via the `iat`
fallback
- [ ] `.env.example` and `DEPLOYMENT.md` explain both variables and flag both
defaults as proposed, not confirmed against Okta's own session policy
- [ ] existing session/auth tests updated for the new mechanism, not just a
new number
---
### T13.2 — D18: Okta Management API client
- **Items:** `D18`
- **Depends on:** nothing (independent of T13.1)
- **Blocks:** T13.3
- **Surface:** `server/`
- **Files:** new `server/okta_sync.py` (or extend `server/okta_auth.py` —
task's call), `server/.env.example`
**Do:** A small client for Okta's user-list endpoint, authenticated with a new,
separate credential (e.g. `OKTA_API_TOKEN`) — not the OIDC client secret used
for sign-in. Fetch the full user list (paginated per Okta's API) rather than
one-by-one lookups per local user; this app has ~20-odd accounts today, and a
list-and-diff is simpler and cheaper than N calls. Return each Okta user's
identity-claim value (matching `OKTA_IDENTITY_CLAIM`, already confirmed live
per wave 10) and status.
Follow the existing pattern for external credentials in this repo (`MICRON_DB_URL`,
`SMTP_PASSWORD`): env-only, never logged, never returned to the browser in an
error message.
**Do not:** reuse `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET` for this. Sign-in and
the management API are different trust boundaries with different scopes;
conflating them means a compromise or rotation of one affects the other
unnecessarily.
**Done when:**
- [ ] the client authenticates with its own credential, distinct from the OIDC
client
- [ ] it fetches the complete Okta user list, handling pagination
- [ ] an auth failure or malformed response raises a clear, specific error
rather than returning an empty list indistinguishable from "everyone was
deprovisioned" — this distinction is what T13.3 depends on
- [ ] the credential is never logged or surfaced in any API response
---
### T13.3 — D18: the sync job
- **Items:** `D18`
- **Depends on:** T13.2
- **Blocks:** T13.4
- **Surface:** `server/`
- **Files:** `server/okta_sync.py`, `server/app.py` (or wherever `log_event`
lives)
**Do:** Compare the Okta user list (T13.2) against local `users` rows. For any
local `is_active=True` user whose Okta identity is missing from the list, or
present with a non-active status, set `is_active=False` and write an
`AuditLog` row (`actor="system:okta_sync"`, action e.g.
`user_deprovisioned_by_sync`, detail naming the Okta status found). Never
touch a user already `is_active=False`. Never re-enable anyone.
**The fail-closed rule, non-negotiable:** if T13.2's client raises an error of
any kind (network, auth, malformed response, timeout), this task takes **no
action on any account** for that run and logs the failure clearly (server log,
at minimum). An error is never treated as "Okta returned zero active users."
Write a test that asserts this directly: feed the sync a failing client and
assert zero rows changed and zero `AuditLog` entries written.
**Done when:**
- [ ] a user missing from Okta's list, or present but not active, is disabled
with a correctly-detailed audit row
- [ ] a user already disabled is left alone (no duplicate audit row each run)
- [ ] an active Okta user already active locally produces no audit row (only
changes are logged, not a clean bill of health every cycle)
- [ ] **a simulated Okta API failure results in zero account changes and zero
audit rows** — this is the one check that must never be skipped or
weakened
- [ ] the job never re-enables an account under any input
---
### T13.4 — D18: run it on a schedule
- **Items:** `D18`
- **Depends on:** T13.3
- **Blocks:** T13.6
- **Surface:** `server/`, `docker-compose.yml`
- **Files:** `server/app.py` (startup hook) or a new sidecar per the `backup`
container's pattern — task's call, per the decision record's noted
alternative
**Do:** Wire T13.3 to run on an interval (proposed 15 minutes, env-overridable
— e.g. `OKTA_SYNC_INTERVAL_SECONDS`, matching `BACKUP_INTERVAL_SECONDS`'s
naming). Default choice is an in-process background task in the `api`
container; if a separate container is chosen instead, follow the `backup`
service's shape (own Dockerfile or script, `internal` network plus whatever
egress reaching Okta requires — check whether `outbound` as currently defined
is sufficient or Okta needs a distinct allowance).
**Done when:**
- [ ] the job runs automatically on the configured interval without manual
invocation
- [ ] interval is env-configurable with a sane default
- [ ] a container restart does not produce a duplicate/overlapping run, and a
slow cycle does not stack with the next one
---
### T13.5 — D18: admin visibility
- **Items:** `D18`
- **Depends on:** T13.3
- **Blocks:** T13.6
- **Surface:** `html/`
- **Files:** `html/users.js` / `html/admin.js` (wherever audit history is
already surfaced)
**Do:** Confirm an auto-disable reads clearly wherever admins already look at
account history — the actor string (`system:okta_sync`) should be
self-explanatory in context, not require reading server logs to understand.
Do not build new UI beyond making sure the existing audit surface renders this
actor sensibly. Email/notification to admins on auto-disable is a noted
fast-follow (the decision record flags it as open, not required here) — do not
build it in this task; log it instead if it's tempting to add.
**Done when:**
- [ ] an auto-disabled account's audit entry is visible and legible in the
existing admin UI without special-casing
- [ ] nothing here silently assumes `CR-019`'s activity view exists yet — this
must work standalone
---
### T13.6 — D18: verification
- **Items:** `D18`
- **Depends on:** T13.4, T13.5
- **Blocks:** nothing
- **Surface:** `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`. Beyond the usual run: specifically
re-run T13.3's fail-closed test in isolation and confirm it still passes after
T13.4's scheduling wrapper is in place — the scheduling layer must not
introduce a path that swallows the client's error and proceeds anyway.
**Done when:**
- [ ] all `D18` acceptance criteria in `decisions-2026-09-17.md` are met or a
failure is stated with a reason
- [ ] the fail-closed behavior is verified end to end through the scheduled
wrapper, not just the bare sync function
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 13 exit criteria
- [ ] `AUTH_SESSION_HOURS` defaults to 2, documented
- [ ] the sync job runs on a schedule and correctly disables accounts Okta no
longer shows as active
- [ ] every auto-disable is individually audited with a clearly non-human actor
- [ ] an Okta API failure of any kind changes zero accounts — verified, not
assumed
- [ ] the sync never re-enables an account
- [ ] `D18` fully accounted for, no open acceptance criteria

View File

@@ -307,10 +307,10 @@ criteria turned out wrong, inputs still outstanding, and follow-ups logged along
## Wave 9 exit criteria
- [ ] the export matches the final structure
- [ ] one icon system, one sample-data affordance
- [ ] accessibility metrics hit target or are documented
- [ ] the primary flow works at 390px
- [ ] archived projects are readable by project admins and invisible to everyone else (`D7`)
- [ ] the backlog has no entry still pointing at wave 9
- [ ] every item is reconciled - all 65
- [x] the export matches the final structure (`export_check.py`, 20 checks — required fields present, CR-002 removals absent, CR-006 suppression honoured, tablet-legible)
- [x] one icon system, one sample-data affordance (`icon_check.py`, `sample_check.py`)
- [x] accessibility metrics hit target or are documented (`docs/reference/accessibility-audit.md`; the one gap — 21 dialogs on surfaces no S1 task named — is BL-024)
- [x] the primary flow works at 390px (`mobile_check.py`, 24 checks, all seven pages; screenshots committed)
- [x] archived projects are readable by project admins and invisible to everyone else (`archived_check.py`, 15 checks)
- [x] the backlog has no entry still pointing at wave 9 (nine closed at T9.9, each with its measurement)
- [x] every item is reconciled — all 65 (`docs/reference/completion.md`)

View File

@@ -34,12 +34,12 @@
/* 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{
#comments-admin, #audit-admin, #notif-box, #activity-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){
#activity-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
@@ -53,7 +53,7 @@
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{
#comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
max-height:none; }
}
</style>
@@ -175,16 +175,38 @@
<div id="audit-admin" class="note">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<!-- ACTIVITY &amp; USAGE (CR-019) -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard —
sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser,
so this reflects activity on <strong>this</strong> machine.</div>
<h2>Activity &amp; usage</h2>
<div class="sub">Who is using the suite, which tools, and how often — recorded server-side on every
sign-in and page open, kept indefinitely. Filter below, or export a CSV: raw (real usernames) for
internal use, or sanitized (each user replaced with a stable, non-reversible id) for feeding into
Power BI or another external reporting tool without carrying real identities.</div>
<div class="toolbar">
<button onclick="loadUsage()">Refresh</button>
<label for="act-from">From</label>
<input type="date" id="act-from" onchange="loadActivity()">
<label for="act-to">To</label>
<input type="date" id="act-to" onchange="loadActivity()">
<select id="act-project" onchange="loadActivity()"><option value="">All projects</option></select>
<input id="act-user" placeholder="Username…" oninput="loadActivity()">
<select id="act-tool" onchange="loadActivity()">
<option value="">All tools</option>
<option value="launcher">Launcher</option>
<option value="wizard">SOP wizard</option>
<option value="creator">Work package creator</option>
<option value="field_view">Field view</option>
<option value="admin">Admin console</option>
<option value="directory">User directory</option>
</select>
<button onclick="loadActivity()">Refresh</button>
</div>
<div id="usage-admin" class="note">Click refresh to load.</div>
<div id="activity-banner" class="banner" style="display:none"></div>
<div id="activity-admin" class="note">Loading…</div>
<div class="toolbar" style="margin-top:var(--s3)">
<button onclick="exportActivity(false)">Download CSV (raw)</button>
<button onclick="exportActivity(true)">Download CSV (sanitized)</button>
</div>
<div id="activity-export-banner" class="banner" style="display:none"></div>
</div>
<!-- DB SNAPSHOT -->
@@ -215,9 +237,9 @@
</div>
</div>
<script src="wp-usage.js"></script>
<script src="console-util.js"></script>
<script src="admin.js"></script>
<script src="wp-dialog.js"></script>
<script src="admin.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
page could never show a project and always read "Select a project" (F1). Must
parse before wp-chrome.js, which reads it as it mounts. -->

View File

@@ -24,7 +24,7 @@ function reveal(){
loadNotifications();
loadComments();
loadAudit();
loadUsage();
loadActivity();
}
function showDenied(){
document.getElementById('admin-denied').style.display='';
@@ -36,13 +36,13 @@ async function checkHealth(){
b.className='banner'; b.textContent='Checking…';
const { status, json } = await api('GET','/api/health');
if(status===200 && json && json.ok){
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
b.className='banner ok'; b.textContent='✓ API reachable — /api/health returned ok.';
} else if(status===404){
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
b.className='banner bad'; b.textContent='✕ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
} else if(status===0){
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
b.className='banner bad'; b.textContent='✕ Could not reach the server: '+json;
} else {
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
b.className='banner bad'; b.textContent='✕ Unexpected response: HTTP '+status;
}
}
@@ -121,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
async function seedDemo(){
const o=document.getElementById('demo-out'); o.innerHTML='';
let r = await api('GET','/api/health');
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('✕ API unreachable — fix /api/ routing first.'); return; }
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
if(r.status!==200){ demoLog('✕ create project failed (HTTP '+r.status+')'); return; }
const pid=r.json.id; demoLog('Project created: '+r.json.name);
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
@@ -142,20 +142,22 @@ async function seedDemo(){
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
r = await api('GET','/api/wps/metrics?project_id='+pid);
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
demoLog('\n✓ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
snapshot();
}
async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
if(!(await wpConfirmDialog({title:'Delete demo data',
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
okLabel:'Delete them'}))) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
// 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; }
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; }
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
demoLog('\n✅ Removed '+targets.length+' project(s).');
demoLog('\n✓ Removed '+targets.length+' project(s).');
snapshot();
}
@@ -175,18 +177,31 @@ async function loadProjects(){
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 can’t archive or delete projects here.';
banner.textContent='✕ Your account is not an admin, so you can’t archive or delete projects here.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent='✕ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent='✕ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_adminProjects = json;
renderProjects();
populateActivityProjectFilter();
}
// The activity project filter reuses the same project list the Projects card
// already fetched — no second /api/projects call just to fill a <select>.
function populateActivityProjectFilter(){
const sel = document.getElementById('act-project');
if(!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All projects</option>' +
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
sel.value = cur;
}
function renderProjects(){
@@ -250,10 +265,12 @@ async function archiveProject(id, name, archived){
'• 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;
if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
}
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
@@ -261,13 +278,16 @@ async function archiveProject(id, name, archived){
// 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;
if(!(await wpConfirmDialog({title:'Delete project permanently',
message:'DELETE “'+name+'” permanently?\n\n'+
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'(database cascade). This cannot be undone.\n\n'+
'If you only want it out of the way, cancel and use Archive instead.',
okLabel:'Delete permanently'}))) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
else wpAlertDialog({title:'Delete failed',
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
}
// ── default members on new projects ─────────────────────────────────────────────
@@ -284,14 +304,14 @@ async function loadDefaultMembers(){
const { status, json } = await api('GET','/api/auth/users');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you can’t change who is added to new projects.';
banner.textContent='✕ Your account is not an admin, so you can’t change who is added to new projects.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent='✕ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
banner.className='banner bad'; banner.textContent='✕ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_defMemUsers = json;
@@ -373,7 +393,8 @@ async function setAutoAdd(id, username){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
renderDefaultMembers();
} else {
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
wpAlertDialog({title:'Change failed',
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
loadDefaultMembers();
}
}
@@ -457,6 +478,90 @@ function renderAudit(){
'</tr>').join('')+'</tbody></table>';
}
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
// Server-side, per-user activity — distinct from the "Activity log" card above
// (that's AuditLog: business mutations) and from the "Usage logs" card below
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
// filterable by date/project/user/tool, and exportable raw or sanitized.
function _activityFilters(){
const q = new URLSearchParams();
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
return q;
}
async function loadActivity(){
const banner = document.getElementById('activity-banner');
const box = document.getElementById('activity-admin');
if(!box) return;
banner.style.display='none';
box.textContent = 'Loading…';
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
if(status===403){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Your account can’t see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
box.innerHTML=''; return;
}
if(status!==200 || !json){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
box.innerHTML=''; return;
}
renderActivity(json);
}
function renderActivity(sum){
const box = document.getElementById('activity-admin');
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const bucket = (label, obj, take) => {
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
if(!entries.length) return '';
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
'</table>';
};
const perTool = Object.entries(sum.by_tool||{});
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
'<div class="note">No per-tool data.</div>';
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
'</tbody></table>' : '<div class="note">No per-user data.</div>';
box.innerHTML =
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
'</div>'+
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
}
async function exportActivity(sanitize){
const banner = document.getElementById('activity-export-banner');
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
const q = _activityFilters();
if(sanitize) q.set('sanitize', 'true');
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
if(status!==200 || typeof json !== 'string'){
banner.className='banner bad';
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
return;
}
const blob = new Blob([json], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
banner.className='banner ok';
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
}
// ── notifications / email settings ──────────────────────────────────────────────
let _settings = {};
async function loadSettings(){
@@ -560,7 +665,7 @@ async function saveLocalization(){
const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
msg.textContent = '✕ '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
@@ -627,8 +732,8 @@ async function saveSettings(){
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
if(status===200) { msg.textContent = '✓ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '✕ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;
@@ -648,48 +753,6 @@ async function loadNotifications(){
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
// operator-facing readout belongs - behind the same admin gate as this whole
// page (gateByRole() below shows nothing else either). Data comes from
// wp-usage.js, the single implementation; the keys predate the move, so
// everything recorded before it is still here.
function loadUsage(){
const box = document.getElementById('usage-admin');
if(!box) return;
const tools = [
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
];
let html = '';
tools.forEach(([label, key, prefix]) => {
const evs = (WPUsage.load(key).events) || [];
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
if(!evs.length){
html += '<div class="note">No usage recorded in this browser yet.</div>';
return;
}
const byEvent = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = v => v ? wpFormatDateTime(v) : '—';
html += '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
});
box.innerHTML = html;
}
// ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
// Show the console for admins; otherwise show the "Admins only" notice.

View File

@@ -60,62 +60,10 @@
.then(function () { window.location.replace('login.html'); });
};
// Change-password dialog (uses POST /api/auth/password, which requires the
// current password). Available from the top-right pill on any page.
window.wpChangePassword = function () {
if (document.getElementById('wp-pw-modal')) return;
var ov = document.createElement('div');
ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) {
var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
document.getElementById('wp-pw-cancel').onclick = close;
document.getElementById('wp-pw-cur').focus();
document.getElementById('wp-pw-save').onclick = function () {
var cur = document.getElementById('wp-pw-cur').value;
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: n1 })
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
// window.wpChangePassword used to open a change-password dialog here. Removed in
// T10.4 (D15/D16): there is no local password to change anymore — identity is
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
// too.
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
@@ -171,17 +119,46 @@
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
// the two items that were only here: Language & time, and Password.
// the item that was only here: Language & time. (Password was the other one; T10.4
// removed it along with the rest of local auth — D15/D16.)
//
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill.
// ── CR-019: usage ping ───────────────────────────────────────────────────
// One page_open event per authenticated load, sent from exactly ONE place
// (here) rather than from each page's own script - the shared-chrome lesson
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
// error to the person using the app - a missed usage ping is not something
// anyone here should notice happening.
var TOOL_BY_PAGE = {
'index.html': 'launcher',
'work-package-suite.html': 'wizard',
'wp-creation-index.html': 'creator',
'field.html': 'field_view',
'admin.html': 'admin',
'users.html': 'directory'
};
function pingUsage() {
var page = (location.pathname.split('/').pop() || 'index.html');
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
try {
fetch('/api/usage/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: tool })
}).catch(function () {});
} catch (e) {}
}
function proceed(user) {
clearTimeout(safety);
window.WP_USER = user;
reveal();
if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
pingUsage();
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
}
}

View File

@@ -213,3 +213,9 @@ select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--
@media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; }
}
/* C2 / T9.6: the console header links are standalone targets, not inline text,
so they meet the touch floor at coarse pointers / phone widths. */
@media (max-width: 500px), (pointer: coarse) {
a.home { min-height: 44px; display: inline-flex; align-items: center; }
}

View File

@@ -35,7 +35,7 @@
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: var(--wp-status-success-bg); color: var(--wp-hover-success); }
.pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text-alt); }
.pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text); }
.pill.bad { background: var(--wp-status-error-bg); color: var(--cds-support-error); }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: var(--cds-layer); }
.fld-empty a { color: var(--cds-link-primary); }
@@ -66,9 +66,9 @@
.fld-toast.show { opacity: 1; }
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
.fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box;
border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px;
border:1px solid var(--cds-border-subtle); border-radius:6px; margin-bottom:8px;
color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
.fld-drawing:active { background: var(--cds-layer-hover-01); }
.fld-drawing:active { background: var(--cds-layer-hover); }
</style>
</head>
<body>

View File

@@ -149,11 +149,11 @@ function renderDetail() {
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
p.files.map(function (f) {
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
'' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
}).join('') + '</div>' : '') +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<div class="fld-photo-row"><label class="fld-btn">Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +

View File

@@ -12,67 +12,174 @@
(function (global) {
'use strict';
// ── the help-tip component (S8 / T9.5) ─────────────────────────────────────
// Markup writes <span class="help-tip" data-tip="…">i</span>; this upgrades
// every one to a real <button> at load (and via global.helpTipUpgrade(root)
// for anything rendered later). One bubble serves all badges: focus and hover
// show it, click/tap toggles it (the touch path tablets need), Escape and
// leaving close it. The bubble is clamped to the viewport on both axes.
var _tipOpenFor = null;
function tipBubble() {
var b = document.getElementById('wp-tip-bubble');
if (!b) {
b = document.createElement('div');
b.id = 'wp-tip-bubble';
b.setAttribute('role', 'tooltip');
b.hidden = true;
document.body.appendChild(b);
}
return b;
}
function tipShow(btn) {
var b = tipBubble();
b.textContent = btn.getAttribute('data-tip') || '';
b.hidden = false;
var r = btn.getBoundingClientRect();
b.style.left = '0px'; b.style.top = '0px'; // measure at origin
var bw = b.offsetWidth, bh = b.offsetHeight;
var left = Math.min(Math.max(12, r.left + r.width / 2 - bw / 2),
window.innerWidth - bw - 12);
var top = r.top - bh - 8;
if (top < 8) top = r.bottom + 8;
b.style.left = left + 'px';
b.style.top = top + 'px';
btn.setAttribute('aria-describedby', 'wp-tip-bubble');
}
function tipHide(btn) {
var b = document.getElementById('wp-tip-bubble');
if (b) b.hidden = true;
if (btn) { btn.removeAttribute('aria-describedby'); btn.setAttribute('aria-expanded', 'false'); }
if (_tipOpenFor === btn) _tipOpenFor = null;
}
function upgradeTip(el) {
if (el.tagName === 'BUTTON') return el;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = el.className;
btn.setAttribute('data-tip', el.getAttribute('data-tip') || '');
btn.setAttribute('aria-label', 'More information');
btn.setAttribute('aria-expanded', 'false');
btn.textContent = el.textContent || 'i';
el.parentNode.replaceChild(btn, el);
return btn;
}
function helpTipUpgrade(root) {
(root || document).querySelectorAll('span.help-tip').forEach(upgradeTip);
}
global.helpTipUpgrade = helpTipUpgrade;
document.addEventListener('DOMContentLoaded', function () {
helpTipUpgrade(document);
// Delegated, so badges rendered later work without re-wiring.
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
if (btn && btn.tagName !== 'BUTTON') btn = upgradeTip(btn);
if (btn) {
e.preventDefault();
if (_tipOpenFor === btn) { tipHide(btn); return; }
if (_tipOpenFor) tipHide(_tipOpenFor);
_tipOpenFor = btn;
btn.setAttribute('aria-expanded', 'true');
tipShow(btn);
return;
}
if (_tipOpenFor) tipHide(_tipOpenFor); // tap elsewhere closes
});
document.addEventListener('focusin', function (e) {
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
if (btn) tipShow(btn);
else if (_tipOpenFor) tipHide(_tipOpenFor);
});
document.addEventListener('focusout', function (e) {
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
if (btn && _tipOpenFor !== btn) tipHide(btn);
});
document.addEventListener('mouseover', function (e) {
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
if (btn) { if (btn.tagName !== 'BUTTON') btn = upgradeTip(btn); tipShow(btn); }
else if (!_tipOpenFor) tipHide(null);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _tipOpenFor) tipHide(_tipOpenFor);
});
});
// ── styles ────────────────────────────────────────────────────────────────
var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
/* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
the old span never was (its :focus rule was dead code: no tabindex). The
tooltip itself is #wp-tip-bubble below, a positioned element CLAMPED to the
viewport - the old ::after escaped its badge to the right and was the last
cause of the creator's 390px overflow (BL-001). Colours come from the
theme's tokens; this block owned four of the raw hexes S5 counted. */
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
margin-left:5px; padding:0; border:0; border-radius:50%;
background:var(--cds-icon-secondary); color:var(--cds-text-inverse); font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.help-tip:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
.help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
#wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
background:var(--cds-background-inverse); color:var(--cds-text-inverse);
padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
box-shadow:0 4px 14px rgba(20,30,50,.22); }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
.ui-help-modal{ background:var(--cds-layer); color:var(--cds-text-primary); max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid var(--cds-border-subtle); flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
font-size:13px; outline:none; background:var(--cds-layer-accent); }
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(15,98,254,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
.ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:var(--cds-text-primary); text-decoration:none; font-size:13px;
cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
.ui-help-nav a:hover{ background:var(--cds-layer-hover); }
.ui-help-nav a.active{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); font-weight:600; }
.ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:var(--cds-text-primary); scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--cds-link-primary); }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:var(--cds-text-primary); }
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
.ui-help-content li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content code{ background:var(--cds-layer-accent); padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
.ui-help-content th, .ui-help-content td{ border:1px solid var(--cds-border-subtle); padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:var(--cds-layer-accent); font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
.pill-hold{ background:#fde8e8; color:#c0392b; }
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
/* Scoped to .ui-help-pill: this block is injected on EVERY page, and the
creator's Issue (hold) status radio also carries the class pill-hold - the
bare selector painted that radio error-red at all times, selected or not
(found by Nick 2026-08-20; the collision dates to the login-portal era). */
.ui-help-pill.pill-draft{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); } .ui-help-pill.pill-sched{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); }
.ui-help-pill.pill-prog{ background:var(--wp-status-warning-bg); color:var(--wp-status-warning-text); } .ui-help-pill.pill-issued{ background:var(--wp-status-success-bg); color:var(--wp-status-success-text); }
.ui-help-pill.pill-qc{ background:var(--cds-highlight); color:var(--cds-link-primary); } .ui-help-pill.pill-closed{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); }
.ui-help-pill.pill-hold{ background:var(--wp-status-error-bg); color:var(--wp-status-error-text); }
.ui-help-callout{ background:var(--cds-highlight); border-left:3px solid var(--cds-link-primary); padding:10px 14px; border-radius:0;
font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-noresult{ display:none; color:var(--cds-text-secondary); font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:var(--wp-status-warning-border-a); color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
border:none; background:var(--cds-interactive-01); color:var(--cds-text-on-color); font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#0353e9; }
.ui-help-fab:hover{ background:var(--cds-hover-primary); }
@media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid var(--cds-border-subtle); }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; }
}`;
@@ -93,7 +200,7 @@
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
</ol>
<h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙SOP Configuration</strong>, <strong>Work Package Creation</strong>, and <strong>Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
@@ -101,7 +208,7 @@
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>Load sample data</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: `
<h3>Projects</h3>
@@ -133,7 +240,7 @@
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
<li><strong>Engineering Sources &amp; References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings &amp; Attachments</em>.</li>
</ol>
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>✎ Edit</strong>, which requires a logged reason.</div>` },
{ id: 'wps', title: 'Work Packages', body: `
<h3>Creating Work Packages</h3>
@@ -141,7 +248,7 @@
<h4>Key fields</h4>
<ul>
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
<li><strong>Assets</strong> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</li>
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li>
<li><strong>Scope &amp; Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
@@ -152,7 +259,7 @@
<li><strong>Quality / Hold Points</strong>, <strong>Approvals &amp; Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
</ul>
<h4>Saving</h4>
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
<p><strong>Save Draft</strong> stores the package; <strong>Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
{ id: 'statuses', title: 'Statuses', body: `
<h3>Work Package statuses</h3>
@@ -223,7 +330,7 @@
{ id: 'dashboard', title: 'Dashboard', body: `
<h3>Dashboard &amp; metrics</h3>
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>Dashboard</strong> tab, or the Creator header.</p>
<h4>Metric cards (click to filter)</h4>
<ul>
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
@@ -232,7 +339,7 @@
<h4>Breakdowns &amp; gates</h4>
<ul>
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
<li><strong>⊘ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
</ul>
<h4>The table</h4>
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
@@ -241,7 +348,7 @@
{ id: 'data', title: 'Samples, sharing & comments', body: `
<h3>Samples, import / export &amp; comments</h3>
<h4>Load sample</h4>
<p><strong>⭐ Load sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
<p><strong>Load sample data</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
<h4>Import / Export</h4>
<ul>
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
@@ -249,9 +356,9 @@
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
</ul>
<h4>Comments &amp; feedback</h4>
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>Step Comments</strong>), or package comments in the Creator's <strong>Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
<h4>Usage logs</h4>
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
<p><strong>Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
<h3>Tips &amp; keyboard shortcuts</h3>
@@ -281,7 +388,7 @@
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
<tr><td><strong>Bagged &amp; tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
<tr><td><strong>Asset</strong></td><td>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</td></tr>
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
</table>` },

View File

@@ -427,6 +427,19 @@
══════════════════════════════════════════════════════════════════════ -->
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
<!-- D7 / T9.8: the way back into an archived project - PROJECT ADMINS ONLY
(the server filters; everyone else gets an empty list and this section
never renders). Visually its own thing, so nobody opens one thinking
it is live: the server refuses every write regardless. -->
<section class="section" id="archived-projects" hidden
style="border:1px dashed var(--cds-border-subtle); background:var(--cds-layer-accent); opacity:.92">
<h2>Archived projects</h2>
<p style="font-size:13px; color:var(--cds-text-secondary)">Read-only. Visible to project
admins only. Opening one lets you read everything; nothing on it can be changed while
it stays archived.</p>
<div id="archived-projects-list"></div>
</section>
<!-- (a) no projects at all -->
<section class="section first-run" id="first-run" hidden>
<h2>No projects yet</h2>
@@ -580,12 +593,37 @@
<script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="wp-dialog.js"></script>
<script>
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
const esc = ProjectData.esc;
let _projects = [];
// D7: render the archived list for whoever the server says may see one.
function renderArchivedProjects(){
ProjectData.listArchivedProjects().then(rows => {
const sec = $('archived-projects');
const list = $('archived-projects-list');
if(!sec || !list) return;
if(!rows.length){ sec.hidden = true; return; }
sec.hidden = false;
list.innerHTML = rows.map(p =>
`<button type="button" class="card-button" style="display:block; width:100%; text-align:left; margin-bottom:8px"
data-open-archived="${esc(p.id)}">
${esc(p.name || p.id)} ${p.number ? '· ' + esc(p.number) : ''}
<span style="font-size:11px; color:var(--cds-text-secondary)"> — archived, read-only</span>
</button>`).join('');
list.querySelectorAll('[data-open-archived]').forEach(b => {
b.addEventListener('click', () => {
const p = rows.find(x => x.id === b.dataset.openArchived);
if(p){ ProjectData.setActive(p); location.reload(); }
});
});
});
}
function initProjects(){
renderArchivedProjects();
ProjectData.list().then(list => {
_projects = list || [];
// A deep link names the project explicitly, and every other page in the
@@ -606,7 +644,12 @@
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
WPUrl.replace({ project: active.id });
}
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
// D7: a project OPENED FROM THE ARCHIVED LIST is active on purpose - its
// stored summary says archived:true, and only someone the server let see
// that list could have stored it. A project archived out from under
// someone still drops and gets explained, exactly as before.
const dropped = (active && !_projects.some(p => p.id === active.id)
&& !active.archived) ? active : null;
if(dropped) ProjectData.setActive(null);
_listLoaded = true;
renderProjectEntry();
@@ -1004,7 +1047,7 @@
const text = document.getElementById('comment-text').value.trim();
if (!text) {
alert('Please enter feedback.');
toast('Please enter feedback.', 'alert');
return;
}
@@ -1025,7 +1068,7 @@
function exportFeedback() {
const saved = localStorage.getItem('wp_suite_index_comments');
const data = saved ? JSON.parse(saved) : [];
if (!data.length) { alert('No feedback to export yet.'); return; }
if (!data.length) { toast('No feedback to export yet.', 'alert'); return; }
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
@@ -1043,7 +1086,7 @@
try {
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if (!incoming.length) { alert('No feedback found in that file.'); return; }
if (!incoming.length) { toast('No feedback found in that file.', 'alert'); return; }
const saved = localStorage.getItem('wp_suite_index_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
@@ -1051,8 +1094,8 @@
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
loadComments();
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
} catch (e) { alert('Could not read that file.'); }
toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
} catch (e) { toast('Could not read that file.', 'alert'); }
ev.target.value = '';
};
r.readAsText(f);

View File

@@ -31,34 +31,23 @@
margin-bottom: 1.5rem;
}
.brand img { height: 36px; width: auto; }
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
.field { margin-bottom: 1.25rem; }
input[type=text], input[type=password] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
background: var(--cds-field);
border: none;
border-bottom: 1px solid var(--cds-border-strong);
outline: 2px solid transparent;
outline-offset: -2px;
}
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
button {
.btn {
display: block;
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-weight: 600;
text-align: center;
text-decoration: none;
color: var(--cds-text-on-color);
background: var(--cds-button-primary);
border: none;
transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
.btn:hover { background: var(--cds-hover-primary); }
.btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; }
.error {
display: none;
background: var(--wp-status-error-bg);
@@ -69,7 +58,6 @@
margin-bottom: 1.25rem;
}
.error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok {
display: none;
background: var(--wp-status-success-bg);
@@ -80,15 +68,7 @@
margin-bottom: 1.25rem;
}
.ok.show { display: block; }
.note {
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
</style>
</head>
<body>
@@ -99,61 +79,10 @@
<div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div>
<!-- SIGN IN -->
<section id="view-login">
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<h1>Sign in</h1>
<p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the
button below and follow the prompts there.</p>
<a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a>
<p class="foot">Authorized use only · BTG / Pilot</p>
</main>

View File

@@ -1,26 +1,16 @@
/* Login page logic for the Work Package Suite.
Three views on one page:
• sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
One action: sign in with Okta. There is no local password anymore (D15/D16,
T10.4) — this page's only job is building the link to /api/auth/okta/login
(carrying ?next=, if there was one) and showing a plain-language message for
the failure states server/app.py's okta_callback() sends back here (T10.5). */
(function () {
'use strict';
var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok');
var signinLink = document.getElementById('okta-signin');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
@@ -28,188 +18,38 @@
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Where to go after signing in: the ?next= param if it's a safe same-site
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
// open-redirect.)
function nextTarget() {
// Same-site path only — mirrors the check server/app.py's _safe_next_path()
// makes again on the way back, so a crafted ?next= can't become an open
// redirect even if this client-side check were somehow bypassed.
function safeNext() {
try {
var next = new URLSearchParams(location.search).get('next') || '';
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
} catch (e) {}
return 'index.html';
return '';
}
function resetToken() {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
if (signinLink) {
var next = safeNext();
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next);
}
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
});
}
var ERROR_MESSAGES = {
disabled: 'Your account has been disabled. Contact an administrator.',
cancelled: 'Sign-in was not completed. Select the button below to try again.'
};
function detail(res, fallback) {
var d = res && res.json && res.json.detail;
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
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();
var username = byId('username').value.trim();
var password = byId('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) {
if (res.ok) { location.replace(nextTarget()); return; }
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
(function showErrorFromQuery() {
try {
var code = new URLSearchParams(location.search).get('error') || '';
if (!code) return;
showError(ERROR_MESSAGES[code] || 'Sign-in was not completed. Select the button below to try again.');
// Out of the address bar once shown — an error code has no reason to
// survive a refresh or get copied along with the link.
var url = new URL(location.href);
url.searchParams.delete('error');
history.replaceState(null, '', url.pathname + url.search);
} catch (e) {}
})();
})();

View File

@@ -52,6 +52,15 @@
.catch(function () { return readLocal(); });
},
// D7 / T9.8: the way back in, for project admins. The server filters the
// answer by per-project role; everyone else simply receives [].
listArchivedProjects: function () {
return fetch(API + '/projects?archived=only', { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (rows) { return (rows || []).filter(function (p) { return p.archived; }); })
.catch(function () { return []; });
},
get: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id))
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
@@ -207,7 +216,11 @@
var d = sopRow.data; // { sop, state } as written by pushSOP
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
// BL-018: only a row in the shape pushSOP writes counts as complete.
// Marking '1' for ANY row meant a malformed record opened the gate.
if (d.sop && d.state) {
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
}
}
}).catch(function () {})
);
@@ -345,6 +358,12 @@
// and one badge. The 'storage' listener below stays: it is what keeps two
// TABS in step, which is a different thing and still happens.
var _badgeHideTimer = null;
// BL-011 (fixed at T9.9): the badge used to mount on the FIRST SYNC EVENT,
// which is async, so the three fixed overlays on the SOP page landed in a
// different DOM order run to run and every index-keyed comparison saw
// phantom diffs. Mounting the (hidden) holder at DOMContentLoaded puts the
// three in script order, deterministically.
document.addEventListener('DOMContentLoaded', function () { renderSyncBadge(null); });
function renderSyncBadge(c) {
if (!document.body) return;
var el = document.getElementById('wp-sync-badge');
@@ -356,9 +375,10 @@
el.setAttribute('role', 'status');
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
'padding:6px 12px;border:1px solid var(--cds-border-subtle);background:var(--cds-layer);color:var(--cds-text-secondary);box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (!c) return; // the eager DOMContentLoaded mount: holder only, no state yet
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
// 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
@@ -378,16 +398,16 @@
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
el.style.color = 'var(--wp-status-error-text)'; el.style.borderColor = 'var(--wp-status-error-border-a)'; el.style.background = 'var(--wp-status-error-bg)'; el.style.display = 'inline-flex';
} else if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
el.style.color = 'var(--wp-status-warning-text)'; el.style.borderColor = 'var(--cds-support-warning)'; el.style.background = 'var(--wp-status-warning-bg)'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
el.style.color = 'var(--cds-text-secondary)'; el.style.borderColor = 'var(--cds-border-subtle)'; el.style.background = 'var(--cds-layer)'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ Everything sent to the project';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
el.style.color = 'var(--wp-status-success-text)'; el.style.borderColor = 'var(--wp-status-success-border-a)'; el.style.background = 'var(--wp-status-success-bg)'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}

View File

@@ -143,11 +143,8 @@
--wp-status-error-bg: #fff1f1;
--wp-status-warning-bg: #fdf6dd;
--wp-status-warning-text: #8e6a00;
/* Four points from --wp-status-warning-text and doing the same job, on the
field view's warn pill. Almost certainly a typo rather than a decision, but
merging it moves a rendered colour, so T3.2 names it and T3.5 merges it.
BL-009 / docs/reference/tokens.md section 8-K. */
--wp-status-warning-text-alt: #8a6d00;
/* BL-009, CLOSED at T9.9 (C4): the ninth amber (--wp-status-warning-text-alt,
#8a6d00, four points from this one) is deleted; its consumers use this. */
/* Carbon green-70. The value is Carbon, the role is not — Carbon has no
"hover for a green fill", because green is not one of its action colours.
Declared in no sheet today; written raw in five places. */
@@ -321,11 +318,22 @@
--wp-btn-danger-fill-bg: var(--cds-support-error);
--wp-btn-danger-fill-fg: var(--cds-text-on-color);
/* -- the second blue -------------------------------------------------------
#2563d6, not #0f62fe. Fills .sop-inherited — every field a work package
inherited from its SOP — at 7% alpha, which is why nobody has noticed a
second brand blue. Named here so it is visible; swapped at T3.5 (BL-008). */
--wp-sop-inherited-bg: rgba(37, 99, 214, .07);
/* BL-008, CLOSED at T9.9 (C4, approved Aug 18): the second brand blue is
gone. .sop-inherited now tints with THE blue at the same 7% alpha. */
--wp-sop-inherited-bg: rgba(15, 98, 254, .07);
/* The console feedback trio's success text (auth-guard / project-data /
wp-format carried it as a literal until C4). */
--wp-status-success-text: #0e6027;
--wp-status-error-text: #a2191f;
/* The categorical badge palette (the creator's navigator). Data-vis colours,
not UI states - named here because here is the only place a colour value
may exist (C4); the app reads them by computed style at boot. */
--wp-chart-1: #0f62fe; --wp-chart-2: #8a3ffc; --wp-chart-3: #007d79;
--wp-chart-4: #d02670; --wp-chart-5: #ba4e00; --wp-chart-6: #1192e8;
--wp-chart-7: #198038; --wp-chart-8: #a56eff; --wp-chart-9: #9f1853;
--wp-chart-10: #005d5d;
}
/* Typography */

View File

@@ -26,9 +26,6 @@
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
@@ -83,12 +80,12 @@
<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-username" placeholder="Username *" autocomplete="off"
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
<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>
@@ -102,7 +99,8 @@
</div>
<script src="console-util.js"></script>
<script src="users.js"></script>
<script src="wp-dialog.js"></script>
<script src="users.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
page could never show a project and always read "Select a project" (F1). Must
parse before wp-chrome.js, which reads it as it mounts. -->

View File

@@ -34,7 +34,7 @@ async function boot(){
_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')+
banner('scope-banner','bad','✕ '+apiError(status, json, 'Could not work out what you may do here')+
' Showing the directory read-only.');
} else {
renderScope();
@@ -85,7 +85,7 @@ 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'));
banner('users-banner','bad','✕ '+apiError(status, json, 'Could not load the directory'));
wrap.innerHTML = ''; return;
}
banner('users-banner','', '');
@@ -183,11 +183,9 @@ function managerRow(u){
: 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"')+'>'+
@@ -254,38 +252,32 @@ function projAccessCell(u){
// ── 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(); }
else { wpAlertDialog({title:'Change failed', message:'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));
if(status !== 200) wpAlertDialog({title:'Change failed', message:'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));
if(status !== 200) wpAlertDialog({title:'Change failed', message:'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;
if(!(await wpConfirmDialog({title:'Delete user',
message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
'This cannot be undone — disable the account instead if you only want to block sign-in.',
okLabel:'Delete user'}))) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers();
else alert('Could not delete '+username+': '+apiError(status, json));
else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
}
// ── create ────────────────────────────────────────────────────────────────────
@@ -337,28 +329,30 @@ function renderCreateForm(){
async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
// The username entered here MUST match what Okta's identity claim will send for
// this person exactly — this creates the account ahead of their first sign-in,
// and that's how a later Okta sign-in finds this row instead of provisioning a
// second one. See create_user()'s docstring in server/app.py.
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,
username, 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 = '');
say('var(--green)','✓ Created '+username+'.');
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','❌ '+apiError(status, json, 'Could not create the account'));
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
}
}
@@ -368,7 +362,7 @@ async function createUser(){
// 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; }
if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
openProjectModal(id, username, json);
}
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
@@ -453,7 +447,7 @@ function openProjectModal(userId, username, data){
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));
else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
};
}

View File

@@ -655,7 +655,7 @@ function renderWPTypes(){
const nameCell = t.custom
? `<div style="display:flex; gap:6px; align-items:center;">
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:var(--cds-text-on-color); border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
</div>`
: `<div style="font-weight:600;">${t.name}</div>`;
row.innerHTML = `
@@ -669,7 +669,7 @@ function renderWPTypes(){
});
const addRow = document.createElement('div');
addRow.style.cssText = 'margin-top:0.85rem;';
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary); color:var(--cds-text-on-color); border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
container.appendChild(addRow);
}
@@ -955,7 +955,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('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:var(--cds-text-on-color); border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -980,11 +980,13 @@ function toggleConstraint(name){
function showConstraintLibrary(){
const modal = document.getElementById('constraint-modal');
const lib = document.getElementById('constraint-library');
// C1/T9.5: a library entry is an ACTION, so it is a button - keyboard and
// touch come free, and the hover styling moved to CSS where it belongs.
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
<button type="button" class="constraint-option" onclick="addCustomConstraint('${c}')">
<strong>${c}</strong>
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
</div>
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Add to this project</div>
</button>
`).join('');
modal.style.display = 'flex';
}
@@ -1559,7 +1561,10 @@ if(typeof WPUrl !== 'undefined'){
// NAVIGATE, so Back would bounce forward again and the button would appear
// broken. Those addresses belong to the creator's own history stack, and the
// boot redirect uses replace() precisely so no such entry is left here.
const step = parseInt(state.step, 10);
// BL-016 (fixed at T9.9): Back from ?step=6 to a URL with NO step used to
// parse NaN and do nothing - the wizard stayed on 6 while the address bar
// said otherwise. A step-less wizard URL IS step 1.
const step = parseInt(state.step, 10) || 1;
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
});
}
@@ -2202,20 +2207,18 @@ function loadStepComments(){
}
}
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
// Lightweight usage analytics stored in localStorage so the tool owner can review
// engagement over time. No field VALUES are stored (field-edit events record only
// the field id), keeping captured data non-sensitive.
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
// on the admin console. The wizard's own copy of showAnalytics() never had a
// caller here - the button lived on the creator - and once B7 dissolved the
// frame the duplicate sat in the same document as five colliding globals.
// This page only records; dwell tracking keeps its page-local state below.
// ── USAGE ANALYTICS (retired, T11.6) ────────────────────────────────────────
// This used to write to wp-usage.js's per-browser localStorage log (D5/T7.10),
// read back by the admin console's old "Usage logs" panel. CR-019 replaced
// both with real, server-side, per-user activity (UsageEvent + the Activity &
// usage card) - see decisions-2026-09-17.md for why this per-browser data was
// never a source the new report could adopt. track() is now a no-op; kept
// (rather than deleting its handful of call sites, including the dwell-timer
// plumbing below) so this stays a one-line change instead of touching every
// caller for the same outcome.
let _stepEnter = Date.now();
function track(event, detail){
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
}
function track(event, detail){ /* retired, T11.6 — see comment above */ }
function trackStepDwell(){
const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});

View File

@@ -741,6 +741,13 @@ body {
/* .field-error is declared once, in theme-light.css — the launcher's create form
and T5.8's step validation use the same component. */
/* The constraint-library entries (C1/T9.5): real buttons, block layout. */
.constraint-option { display:block; width:100%; text-align:left; padding:0.75rem;
background:var(--bg); border:1px solid var(--border); border-radius:6px;
margin-bottom:0.5rem; cursor:pointer; font:inherit; color:inherit; transition:all .2s; }
.constraint-option:hover, .constraint-option:focus-visible {
border-color:var(--primary); background:var(--primary-light); }
/* NAVIGATION
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
steps the proposal's beside-the-fields actions meant scrolling to save; the

View File

@@ -11,7 +11,6 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<script src="wp-list-import.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>

View File

@@ -319,3 +319,20 @@
the gate panel is what does the explaining. */
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
.nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); }
/* C2 / T9.6: touch sizing. At phone widths (and any coarse pointer) every
control meets the 44px bar the field surfaces are held to; checkboxes,
radios and the help-tip badge get the 24px WCAG floor with spacing doing
the rest. Shared here because every page loads this sheet - six copies of
this block is how the six pages drift apart again. */
@media (max-width: 500px), (pointer: coarse) {
button, .btn, .add-btn, .nav-btn, .header-button,
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
select, textarea { min-height: 44px; }
a.wp-appbar-link, .wp-sidenav-item, .nav-tab {
min-height: 44px; display: inline-flex; align-items: center; }
input[type="checkbox"], input[type="radio"] { min-width: 24px; min-height: 24px; }
.help-tip { min-width: 24px; min-height: 24px; }
.wp-navbtn, .ui-help-fab, .wp-sidenav-close { min-width: 44px; }
.wp-appbar-brand { min-height: 44px; display: inline-flex; align-items: center; }
}

View File

@@ -285,6 +285,7 @@ function _openDialog(opts){
document.getElementById('wp-dialog-err').textContent='';
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':'';
ov.classList.add('open');
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
});
@@ -314,6 +315,10 @@ function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
// prompt() said string or null; so does this - and a validate() answer renders
// AT the input instead of round-tripping through another dialog.
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
// alert() said one thing and offered one button; so does this (D11 - the asset
// importer arrived using alert(), and the kit had no one-button shape).
// Escape still closes it; the resolved value is not meaningful for alerts.
function wpAlertDialog(opts){ return _openDialog({...opts, input:false, okOnly:true}); }
document.addEventListener('keydown', e=>{
const ov=document.getElementById('wp-dialog');
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
@@ -435,7 +440,7 @@ function applySOP(){
applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner();
// CR-006. Last, after every builder has rendered its card — applying it earlier
@@ -467,7 +472,7 @@ function applyKindVisibility(){
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj);
show('bim-card', ewp); // model area / clash + IFF # / scan
show('asset-card', !ewp); // controls.dev assets
show('asset-card', !ewp); // Micron DB assets
show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
@@ -519,7 +524,7 @@ function lockQuality(id){
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
el.classList.toggle('locked-field', false);
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
if(btn) btn.textContent = fromSOP ? '✎ Edit (reason required)' : '↺ Revert to SOP';
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
}
function editQuality(id){
@@ -542,15 +547,20 @@ function editQuality(id){
}
function renderCtxBar(){
const bar=document.getElementById('ctx-bar');
const archMark = window._projArchived
? ` <span class="ctx-sample" style="background:var(--red);color:var(--cds-text-on-color)">ARCHIVED — READ-ONLY</span>` : '';
if(!SOP){
bar.innerHTML = activeProjectId
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.</div>`;
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.${archMark}</div>`
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.${archMark}</div>`;
return;
}
const p=SOP.project||{}, g=SOP.governance||{};
// D7: an archived project is read-only. The chip is the courtesy; the server's
// write refusal is the rule, and savePackage() says so before the round trip.
const archived = archMark;
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}${archived}</div>
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
}
@@ -570,7 +580,7 @@ function renderSopRefLinks(){
const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">▸ ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
}
function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return;
@@ -802,7 +812,7 @@ function renderPredHint(){
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
el.style.color = '';
} else if(blocking.length){
el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
el.innerHTML = '⊘ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
'. Release is gated until they are Closed.';
el.style.color = 'var(--red)';
} else {
@@ -1152,20 +1162,349 @@ async function splitByDiscipline(){
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
}
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
// Interim: assets are linked manually back to controls.dev. A future direct
// integration will let the user pick them from a list instead of pasting links.
// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
// Assets are picked from the Micron asset catalog, a SQL Server database this app
// reads through /api/assets. The lookup is strictly read-only — picking an asset
// never writes to the catalog, and there is no endpoint that could.
//
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
// neither may block someone from writing a work package: in both cases the picker
// says so and manual entry carries on. Manually entered assets are marked
// source:'manual' so it stays visible which rows the catalog vouches for.
// The whole catalog is fetched once when the page loads and searched in memory —
// it is slow-moving reference data, so a request per keystroke would buy nothing
// and cost latency on every one.
let assetCatalog = []; // the full catalog, loaded once
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
let assetCatalogState = 'loading'; // loading | ready | absent | error
let assetResults = []; // current matches; the result list indexes into this
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
function assetAlreadyAdded(tag){
const k = String(tag||'').trim().toLowerCase();
return pkgAssets.some(a => assetKey(a) === k && k);
}
function buildAssets(){
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'&quot;')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
<td><input type="text" value="${(a.desc||'').replace(/"/g,'&quot;')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
<td><input type="url" value="${(a.link||'').replace(/"/g,'&quot;')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
tb.appendChild(tr); });
if(!pkgAssets.length){
tb.innerHTML = `<tr><td colspan="3" class="asset-empty">No assets yet — search the Micron DB above to add the assets this package covers.</td></tr>`;
return;
}
pkgAssets.forEach((a,i)=>{
const tr=document.createElement('tr');
// The asset ID on a catalog row is shown as text, not an input: the catalog
// is the source of truth for it and a locally edited copy would silently
// disagree. The note is always the user's own, so it stays editable either
// way. Every interpolation below is esc()'d text content or a numeric index
// — never a raw string inside an inline handler, which is the bug pattern
// recorded in KNOWN-ISSUES.md §1.
const idCell = a.source === 'catalog'
? `<td><span class="asset-tag">${esc(a.tag)}</span> <span class="asset-badge" title="From the Micron DB">Micron DB</span></td>`
: `<td><input type="text" value="${esc(a.tag)}" placeholder="asset ID" oninput="pkgAssets[${i}].tag=this.value"></td>`;
tr.innerHTML = idCell +
`<td><input type="text" value="${esc(a.desc)}" placeholder="what it is / why it's in scope" oninput="pkgAssets[${i}].desc=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAsset(${i})" title="Remove">✕</button></td>`;
tb.appendChild(tr);
});
}
// Kept for saved packages written before the picker existed: their rows have no
// `source`, so they would render as read-only catalog rows with no way to fix a
// typo. Anything that didn't come from the catalog is treated as manual.
function normaliseAsset(a){
const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
if(o.source !== 'catalog') o.source = 'manual';
return o;
}
function addManualAsset(){
pkgAssets.push(normaliseAsset({}));
buildAssets();
track('asset_added',{source:'manual'});
}
// ── CSV IMPORT ───────────────────────────────────────────────────────────────
// Bulk-add a list of asset ids. Each imported id is checked against the loaded
// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
// for rather than silently dropped. Nothing is ever written back to Micron.
const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
function importAssets(ev){
const f = ev.target.files && ev.target.files[0];
if(!f){ return; }
const clear = () => { ev.target.value = ''; };
if(/\.xlsx?$/i.test(f.name)){
wpAlertDialog({title:'Load from CSV', message:'Please save the workbook as CSV first (File → Save As → CSV), then load it here.'});
clear(); return;
}
const r = new FileReader();
r.onload = () => {
let rows;
try { rows = parseCSV(r.result); }
catch(e){ toast('Could not parse that CSV.', 'alert'); clear(); return; }
if(!rows.length){ toast('That file has no rows.', 'alert'); clear(); return; }
applyImportedAssets(rows);
clear();
};
r.onerror = () => { toast('Could not read that file.', 'alert'); clear(); };
r.readAsText(f);
}
// Which column holds the ids, and whether row 0 is a header.
// - a recognised header name wins outright;
// - otherwise pick the column with the most Micron DB hits, so an export with
// the ids in column D works without the user rearranging it;
// - failing both (nothing matches — e.g. the DB is offline), use column 0.
function pickAssetColumn(rows){
const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
if(named >= 0) return { col: named, start: 1 };
const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
let best = 0, bestHits = 0;
for(let c = 0; c < width; c++){
let hits = 0;
for(let i = 0; i < Math.min(rows.length, 200); i++){
const v = String((rows[i] || [])[c] || '').trim();
if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
}
if(hits > bestHits){ bestHits = hits; best = c; }
}
return { col: best, start: 0 };
}
function applyImportedAssets(rows){
const { col, start } = pickAssetColumn(rows);
// Collect, trimmed and de-duplicated within the file itself.
const seen = new Set(), ids = [];
for(let i = start; i < rows.length; i++){
const v = String((rows[i] || [])[col] || '').trim();
if(!v) continue;
const k = v.toLowerCase();
if(seen.has(k)) continue;
seen.add(k); ids.push(v);
}
if(!ids.length){ toast('No asset ids found in that file.', 'alert'); return; }
// Cap the import rather than building a table with thousands of rows. Reported,
// never silent — a truncated import that looked complete would be worse.
const capped = ids.length > ASSET_IMPORT_MAX;
const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
let matched = 0, unmatched = 0, dupes = 0;
take.forEach(id => {
if(assetAlreadyAdded(id)){ dupes++; return; }
const canonical = assetCatalogIndex.get(id.toLowerCase());
if(canonical){
pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
matched++;
} else {
pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
unmatched++;
}
});
buildAssets();
renderAssetResults(); // rows just added should now read "added"
track('asset_imported', { matched: matched, unmatched: unmatched });
// Every id matched, nothing skipped, nothing truncated: a toast is enough.
// Anything the user needs to act on — unmatched ids, a silent-looking
// truncation, an unchecked import — interrupts with the detail instead.
const offline = assetCatalogState !== 'ready';
if(matched && !unmatched && !dupes && !capped && !offline){
toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
return;
}
const parts = [];
if(matched) parts.push(matched + ' found in the Micron DB');
if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
if(dupes) parts.push(dupes + ' already on this package (skipped)');
let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
(parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
'checked against it — every row was added as manual.';
wpAlertDialog({title:'Asset import', message:msg});
}
function removeAsset(i){
pkgAssets.splice(i,1);
buildAssets();
renderAssetResults(); // a removed asset becomes addable again
}
// ── Catalog lookup ───────────────────────────────────────────────────────────
function assetSourceNote(msg, tone){
const el = document.getElementById('asset-source-note'); if(!el) return;
el.textContent = msg || '';
el.style.color = tone === 'warn' ? 'var(--red)' : '';
}
function initAssetPicker(){
const box = document.getElementById('asset-search'); if(!box) return;
box.addEventListener('input', () => runAssetSearch(box.value));
// Re-open on focus only when the list is actually closed. Adding an asset
// returns focus to this box, and re-running the search there would rebuild the
// list under the cursor and throw away the scroll position mid-multi-add.
box.addEventListener('focus', () => {
const results = document.getElementById('asset-results');
if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
});
// Pasting a column of ids straight out of Excel adds them all, rather than
// dropping a multi-line blob into a search box that can only match one thing.
// Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
// importer's row/cell shape, so it goes through the same matching path.
// A single value is left alone: that is an ordinary search, not a bulk add.
box.addEventListener('paste', e => {
const cb = e.clipboardData || window.clipboardData;
const text = cb ? cb.getData('text') : '';
if(!text) return;
const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
if(lines.length < 2) return; // one id — paste it and search as normal
e.preventDefault();
applyImportedAssets(lines.map(l => l.split('\t')));
box.value = '';
assetResults = [];
openAssetResults(false);
});
box.addEventListener('keydown', e => {
if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
// Enter adds the first result that isn't already on the package — the common
// case of typing an exact tag and taking it without reaching for the mouse.
if(e.key === 'Enter'){
e.preventDefault();
const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
if(ix >= 0) addCatalogAsset(ix);
}
});
// Click-away closes, matching the .pp-menu pickers elsewhere on this form.
// Tested against composedPath() rather than e.target: adding an asset can
// re-render the row that was clicked, and a detached target reports itself as
// outside every container, which would close the list on every add.
document.addEventListener('click', e => {
const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
const inside = path && path.length
? path.some(n => n && n.id === 'asset-pick')
: !!(e.target.closest && e.target.closest('#asset-pick'));
if(!inside) openAssetResults(false);
});
// Delegated so result rows never need an inline handler carrying catalog text.
const results = document.getElementById('asset-results');
if(results) results.addEventListener('click', e => {
const row = e.target.closest('[data-asset-ix]'); if(!row) return;
addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
});
box.disabled = true;
box.placeholder = 'Loading asset IDs from the Micron DB…';
assetSourceNote('Loading asset IDs from the Micron DB…');
fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
.then(r => r.ok ? r.json()
: r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
.then(body => {
if(!body.configured){
assetCatalogState = 'absent';
box.placeholder = 'Micron DB not configured — add assets manually below';
assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
return;
}
assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
// Lowercased lookup for the CSV importer: it decides whether an imported id
// is a real Micron asset, and maps it back to the DB's own casing so an
// id typed as "ahu-2p-014" is stored exactly as Micron spells it.
assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
assetCatalogState = 'ready';
box.disabled = false;
box.placeholder = 'Search asset IDs, or paste a column from Excel…';
assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
})
.catch(err => {
assetCatalogState = 'error';
box.placeholder = 'Micron DB unavailable — add assets manually below';
assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
: 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
});
}
// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
// so typing a full asset ID puts that asset first rather than whichever ID
// happens to sort first.
function runAssetSearch(q){
q = String(q||'').trim().toLowerCase();
if(!q || assetCatalogState !== 'ready'){
assetResults = []; renderAssetResults(); openAssetResults(false); return;
}
const exact=[], prefix=[], other=[];
// The cap bounds each TIER, never the scan: breaking on a combined count let
// 500 alphabetically-early contains-matches evict an exact or prefix match
// that sorted after them - and Enter then added the wrong asset. The scan is
// in-memory and cheap; "the box scrolls, not the search" means the search
// sees everything.
for(const tag of assetCatalog){
const t = tag.toLowerCase();
if(t === q) exact.push(tag);
else if(t.startsWith(q)){ if(prefix.length < ASSET_RESULT_MAX) prefix.push(tag); }
else if(t.includes(q)){ if(other.length < ASSET_RESULT_MAX) other.push(tag); }
}
assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
renderAssetResults();
openAssetResults(true);
}
function renderAssetResults(){
const box = document.getElementById('asset-results'); if(!box) return;
if(!assetResults.length){
box.innerHTML = `<div class="asset-result-note">No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.</div>`;
return;
}
box.innerHTML = assetResults.map((tag,ix) => {
const on = assetAlreadyAdded(tag);
return `<button type="button" class="asset-result${on?' is-added':''}" ${on?'disabled':''} data-asset-ix="${ix}">
<span class="asset-result-tag">${esc(tag)}</span>
<span class="asset-result-add">${on ? 'added' : '+ add'}</span>
</button>`;
}).join('');
}
function openAssetResults(open){
const box = document.getElementById('asset-results');
const inp = document.getElementById('asset-search');
if(box) box.hidden = !open;
if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function addCatalogAsset(ix){
const tag = assetResults[ix]; if(!tag) return;
if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
buildAssets();
toast('Added ' + tag); // announced (role=status) - a keyboard pick is otherwise silent
// Mark just this row instead of re-rendering the list: the results stay open
// for the next pick, the scroll position holds, and the clicked element is
// never detached mid-click (see the composedPath note in initAssetPicker).
markAssetResultAdded(ix);
const box = document.getElementById('asset-search');
if(box) box.focus(); // keep typing straight into the next search
track('asset_added',{source:'catalog'});
}
function markAssetResultAdded(ix){
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
if(!row) return;
row.classList.add('is-added');
row.disabled = true;
const label = row.querySelector('.asset-result-add');
if(label) label.textContent = 'added';
}
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){
@@ -1241,7 +1580,7 @@ function wpFilesMirror(){
function wpFilesRender(){
const box=document.getElementById('wp-file-list'); if(!box) return;
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a>
<span class="wf-size">${wpFileSize(f.size||0)}</span>
<input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
@@ -1310,7 +1649,7 @@ function renderSopFileFolders(){
const srcs=sopLinkedSources();
box.innerHTML = srcs.length
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">▸ ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
}
function toggleSopFilePanel(){
@@ -1417,7 +1756,7 @@ function readiness(){
function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
if(st==='Issue'){ cls='rb-hold'; txt=`⊘ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(st==='Ready for QA'){
// CR-014: the QA decision lives where the state is announced. Accept moves to
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
@@ -1764,6 +2103,10 @@ function collectPackage(){
};
}
async function savePackage(view){
if(window._projArchived){
toast('This project is archived — read-only. Nothing can be saved to it.', 'alert');
return;
}
if(!wpValidateForm()) return;
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
// The status can also be set programmatically (the per-discipline roll-up), so
@@ -1839,10 +2182,12 @@ function renderPackage(pkg){
// because "the toggle does nothing" and "the toggle governs one row" look the
// same from outside.
// D11: two columns now. The controls.dev link column died with the link field;
// a catalog row's identity is its ID, and the note is the user's own text.
if(pkg.assets&&pkg.assets.length){
let t=`<table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
add('assets', 'Assets (controls.dev)', t+`</tbody></table>`);
let t=`<table><thead><tr><th style="width:240px">Asset ID</th><th>Note</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`);
add('assets', 'Assets', t+`</tbody></table>`);
}
let scopeHtml;
@@ -1935,7 +2280,11 @@ function renderPackage(pkg){
}
function printPackage(){
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
// C4/T9.9 (and BL-008): the popup document has no stylesheet, so the theme's
// values are read from the live page and inlined - including THE blue where
// the second brand blue (#2563d6) used to be.
const tok=(name)=>getComputedStyle(document.documentElement).getPropertyValue(name).trim();
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:${tok('--cds-text-primary')};padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid ${tok('--cds-highlight')};padding-bottom:4px;margin-top:22px;color:${tok('--cds-link-primary')}}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:${tok('--cds-layer-accent')};border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px;text-align:left}td{border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
w.document.close(); w.print();
}
@@ -2585,12 +2934,12 @@ 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;}
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:var(--wp-nav-bg);
border-right:1px solid var(--cds-border-subtle);}
.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;
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:var(--cds-text-on-color);
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
.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;}
@@ -2682,8 +3031,19 @@ function setWpNavView(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'];
const WP_BADGE_COLORS = (() => {
// C4/T9.9: the values live in theme-light.css (--wp-chart-1..10), the ONE
// place a colour may exist; this reads them at boot. The fallback array is
// token NAMES, not values - if the theme fails to load there are no colours
// anywhere, which is the correct failure.
const cs = getComputedStyle(document.documentElement);
const out = [];
for(let k = 1; k <= 10; k++){
const v = (cs.getPropertyValue('--wp-chart-' + k) || '').trim();
out.push(v || 'var(--wp-chart-' + k + ')');
}
return out;
})();
function wpBadgeColor(p){
const key = (p.number || p.id || '') + '';
let h = 0;
@@ -2790,7 +3150,7 @@ function renderWpNav(){
const h = live[live.length - 1];
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
: 'no reason recorded — log it from the status control';
holdLine = '<span class="wp-nav-hold">⛔ ' + (open ? open + ' open — ' : '')
holdLine = '<span class="wp-nav-hold">⊘ ' + (open ? open + ' open — ' : '')
+ esc(why) + '</span>';
}
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
@@ -2911,7 +3271,18 @@ function loadPackageIntoForm(p){
onClashChange();
applyKind();
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
buildCostCodes();
// BL-019 (fixed at T9.9): setting a <select> to a value with no option does
// NOTHING, silently - so a code that left COST_CODES blanked on open and the
// next save wrote the blank over the record. Same fix as gov_wosize: keep
// the stored value as an option so the round-trip preserves it.
{ const cs=document.getElementById('wp_cost');
if(p.cost && ![...cs.options].some(o=>o.value===p.cost)){
const o=document.createElement('option');
o.value=p.cost; o.textContent=p.cost+' (not in the current list)';
cs.appendChild(o);
}
cs.value=p.cost||''; }
set('wp_wbs',p.wbs);
document.getElementById('wp_kit_status').value=p.kitStatus||'';
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
@@ -2933,7 +3304,7 @@ function loadPackageIntoForm(p){
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
// collections
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
@@ -3004,7 +3375,7 @@ function newPackage(){
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
pkgAssets=[]; buildAssets();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
@@ -3406,10 +3777,22 @@ function renderDashboard(){
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
${card('Est. hrs', Math.round(estH))}
${card('Actual hrs', Math.round(actH))}
${(()=>{
// D12 (was BL-023): the productivity factor - actual against estimated,
// the measure CR-017's tracking exists to enable. At or under 1.0 the
// work beat the estimate (green); over it (red). Both hour fields are
// optional, so with nothing to divide the card shows an em dash rather
// than vanishing - a metric that disappears reads as "no such measure",
// not "nothing logged yet". Server sums (B4), same as its neighbours.
const pf = (estH > 0 && actH > 0) ? actH / estH : null;
return card('Productivity (act/est)', pf === null ? '—' : pf.toFixed(2),
pf === null ? '' : (pf <= 1 ? 'dm-green' : 'dm-red'));
})()}
</div>`;
// status + discipline breakdown chips (status chips also filter the board)
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
// C1/T9.5: the chip filters the board, so it is a button.
const statusChip=(label,count,cls,status)=>`<button type="button" class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></button>`;
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
@@ -3430,7 +3813,7 @@ function renderDashboard(){
// gating panel — what's blocking release, from the server
const gated=m.gating||[];
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
h+=`<div class="dash-panel"><div class="dash-panel-title">⊘ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
gated.map(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td>
<td>${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+
@@ -3610,11 +3993,17 @@ function openSopModal(){
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
}
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for
// the whole suite - and its report lives on the admin console, where an
// operator-facing readout belongs. This page only records. Same key, same
// event shape: everything recorded before the move is still readable after it.
function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); }
// CR-019 / T11.6 (2026-09-23): this used to write to wp-usage.js's per-browser
// localStorage log (D5/T7.10). Retired along with the admin console's old
// "Usage logs" panel, its only reader - real, server-side, per-user activity
// now exists (UsageEvent, the Activity & usage card, T11.1-T11.5) and this
// data was never reliably tied to a real identity anyway, so it was not a
// source the new report could adopt (decisions-2026-09-17.md). track() stays
// as a no-op rather than deleting its ~35 call sites throughout this file:
// removing every call individually is a much larger, riskier diff for the
// same outcome, and a call site here still documents the moment worth
// recording if usage analytics are ever rebuilt server-side.
function track(event,detail){ /* retired, T11.6 — see comment above */ }
// ── COMMENTS ─────────────────────────────────────────────────────────────────
const COMMENTS_KEY='wp_iwp_comments_v1';
@@ -3633,7 +4022,9 @@ async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filte
if(!mine){ toast('No comments to clear.', 'alert'); return; }
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
function cmtInit(){
const ov=document.getElementById('cmt-overlay');
if(ov && !ov._wired){ ov._wired=true; ov.addEventListener('click', toggleComments); } const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
@@ -3830,6 +4221,16 @@ function bootData(){
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
initAssetPicker(); // D11: the Micron catalog, fetched once per page load
// D7: the read-only courtesy needs the SERVER's answer, not a stale local
// summary - the page's project comes from the URL, which may not be the one
// last stored. The chip and the save guard both read this flag.
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){
ProjectData.get(activeProjectId).then(p=>{
window._projArchived = !!(p && p.archived);
if(window._projArchived) renderCtxBar();
}).catch(()=>{});
}
initWpNavDrawer();
renderSavedList();
positionSectionNav();

View File

@@ -11,7 +11,6 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
@@ -303,12 +302,24 @@
</div>
</div>
<!-- ASSETS (controls.dev) -->
<!-- ASSETS (Micron asset catalog) -->
<div class="card" id="asset-card">
<div class="sub-heading">Assets</div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
<button class="add-btn" onclick="addAsset()">+ Add asset</button>
<div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
<div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
<div class="asset-pick" id="asset-pick">
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
placeholder="Search asset IDs, or paste a column from Excel…"
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
<div class="asset-results" id="asset-results" hidden></div>
</div>
<!-- role=status: loading -> ready/absent/error announces (the login.html pattern) -->
<div class="field-hint" id="asset-source-note" role="status"></div>
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
<div class="material-actions">
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
</div>
</div>
<!-- DISCIPLINES -->
@@ -440,12 +451,12 @@
<div class="sub-heading">Quality, Inspection & Hold Points</div>
<div class="field-grid">
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">✎ Edit (reason required)</button><span class="override-note"></span></div>
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
</div>
@@ -476,7 +487,7 @@
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
<div style="display:flex;gap:10px">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save &amp; view</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div></div>
<!-- DASHBOARD -->
@@ -591,7 +602,9 @@
</div>
<!-- COMMENTS DRAWER -->
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
<!-- The backdrop is NOT a control (C1): pointer dismissal is attached in
cmtInit(), and Escape + the drawer's close button are the real paths. -->
<div class="cmt-overlay" id="cmt-overlay"></div>
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
@@ -614,7 +627,7 @@
<span class="sticky-status" id="sticky-status"></span>
<div class="sticky-actions">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save &amp; view</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div>
</div>

View File

@@ -154,7 +154,7 @@
after .main is what lets it be a sticky column without wrapping the layout. */
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
padding: 14px 28px 72px calc(var(--nav-w,288px) + 28px); /* F6: top pad only; bottom stays clear of the sticky bar */
transition: padding-left .18s ease; }
.section { display: none; }
@@ -308,7 +308,7 @@
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
/* ── NAV ── */
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); }
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border); } /* F6 */
.btn {
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s;
@@ -537,7 +537,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
.ctx-bar { max-width:none; margin:0; padding:7px 28px 7px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; /* F6: denser, still the SOP identity strip */
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -575,7 +575,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
.release-banner .rb-inner { margin-top:8px; border-radius:var(--radius); padding:8px 16px; font-size:13px; font-weight:600; /* F6: A2's one warning, denser */
display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
@@ -689,7 +689,9 @@
/* Dev mode (comment 7) */
.logo-wrap { position:relative; display:flex; align-items:center; }
.dev-toggle { position:absolute; left:2px; bottom:-9px; width:18px; height:7px; padding:0; border:none;
/* C2/T9.6: a deliberately unobtrusive dev switch is still a control - it
meets the 24px floor and earns its subtlety with opacity, not size. */
.dev-toggle { position:absolute; left:2px; bottom:-12px; width:24px; height:24px; padding:0; border:none;
background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; }
.dev-toggle:hover { opacity:0.35; }
.dev-banner { background:var(--wp-dev-bg); color:var(--wp-dev-fg); font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; }
@@ -756,7 +758,13 @@
/* A collapsed section should read as a ROW in a list, not as a box with one line
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
which is a third of what F6 was measuring, arriving by a different door. */
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
/* F6 strict 2.0: a collapsed row is 36px on fine pointers - 13 of them at
rest is where most of the two-screens overage lived. Coarse pointers keep
the taller row below (the 44px tablet target, C1). */
.card.collapsed { padding-top: 5px; padding-bottom: 5px; }
@media (pointer: coarse) {
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
}
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
.card.collapsed .sub-heading { margin-bottom: 0; }
@@ -906,6 +914,38 @@
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
A search box over a read-only catalog. Results drop below the input and are
added to the table as rows; the catalog itself is never written to. */
.asset-pick { position:relative; margin-bottom:10px; }
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
box-sizing:border-box; }
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
max-height:320px; overflow-y:auto; background:var(--surface);
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
box-shadow:0 8px 24px rgba(20,30,50,.18); }
.asset-results[hidden] { display:none; }
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
width:100%; text-align:left; background:none; border:0;
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
.asset-result:hover:not(:disabled) { background:var(--surface2); }
.asset-result:disabled { cursor:default; opacity:.55; }
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
/* Marks rows the catalog vouches for, so a manually typed asset is never
mistaken for a looked-up one. */
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
.asset-tag { font-weight:600; }
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
@@ -1115,7 +1155,8 @@
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
.dash-chip[onclick] { cursor:pointer; }
/* Chips are buttons since T9.5; reset the button chrome, keep the chip look. */
button.dash-chip { font:inherit; font-size:12px; cursor:pointer; }
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }

172
html/wp-dialog.js Normal file
View File

@@ -0,0 +1,172 @@
/* Dialog kit + toast, shared (BL-024, 2026-08-20).
*
* The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
* (index.html), the admin console and the user console carried 21 native
* dialogs between them. Same contract as the creator's copy:
*
* wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise<bool>
* wpPromptDialog({title, message, label, value, validate}) -> Promise<string|null>
* wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
* toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
*
* Self-contained on purpose: markup and styles are injected on first use, the
* styles are theme tokens only (the token rule), and the class names are its
* own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
* The creator keeps its inline copy - it owns the same-id markup in its HTML -
* so everything here is guarded: if the page already has the kit, this file
* defines nothing.
*/
(function (global) {
'use strict';
if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
var CSS =
'#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
'#wp-dlg-overlay.open{display:flex;}' +
'.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
'.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
'.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
'color:var(--cds-text-secondary);padding:4px 6px;}' +
'.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
'.wp-dlg-body{padding:16px 18px;}' +
'#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
'#wp-dlg-input-wrap{margin-top:10px;}' +
'#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
'#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
'#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
'#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
'#wp-dlg-err:empty{display:none;}' +
'.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
'border-top:1px solid var(--cds-border-subtle);}' +
'.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
'.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
'color:var(--cds-text-on-color);}' +
'.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
'@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
'#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
'#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
function ensure() {
var ov = document.getElementById('wp-dlg-overlay');
if (ov) return ov;
var st = document.createElement('style');
st.textContent = CSS;
document.head.appendChild(st);
ov = document.createElement('div');
ov.id = 'wp-dlg-overlay';
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.setAttribute('aria-labelledby', 'wp-dlg-title');
ov.innerHTML =
'<div class="wp-dlg">' +
'<div class="wp-dlg-head"><div id="wp-dlg-title"></div>' +
'<button type="button" class="wp-dlg-x" id="wp-dlg-x" title="Cancel" aria-label="Cancel">✕</button></div>' +
'<div class="wp-dlg-body">' +
'<div id="wp-dlg-msg"></div>' +
'<div id="wp-dlg-input-wrap">' +
'<label id="wp-dlg-label" for="wp-dlg-input"></label>' +
'<input type="text" id="wp-dlg-input">' +
'<div id="wp-dlg-err" role="alert"></div>' +
'</div>' +
'</div>' +
'<div class="wp-dlg-foot">' +
'<button type="button" class="wp-dlg-btn" id="wp-dlg-cancel">Cancel</button>' +
'<button type="button" class="wp-dlg-btn primary" id="wp-dlg-ok">OK</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
document.getElementById('wp-dlg-x').addEventListener('click', cancel);
document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
document.getElementById('wp-dlg-ok').addEventListener('click', ok);
document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') ok();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
});
return ov;
}
var resolveFn = null;
function open(opts) {
return new Promise(function (res) {
resolveFn = res;
var ov = ensure();
ov._opts = opts || {};
document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
document.getElementById('wp-dlg-msg').textContent = opts.message || '';
document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
document.getElementById('wp-dlg-label').textContent = opts.label || '';
var inp = document.getElementById('wp-dlg-input');
inp.value = (opts.value != null ? String(opts.value) : '');
document.getElementById('wp-dlg-err').textContent = '';
document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
var cb = document.getElementById('wp-dlg-cancel');
cb.textContent = opts.cancelLabel || 'Cancel';
cb.style.display = opts.okOnly ? 'none' : '';
ov.classList.add('open');
setTimeout(function () {
(opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
}, 0);
});
}
function close(val) {
var ov = document.getElementById('wp-dlg-overlay');
if (ov) ov.classList.remove('open');
var r = resolveFn;
resolveFn = null;
if (r) r(val);
}
function ok() {
var ov = document.getElementById('wp-dlg-overlay');
var opts = (ov && ov._opts) || {};
if (opts.input) {
var v = document.getElementById('wp-dlg-input').value;
if (opts.validate) {
var err = opts.validate(v);
if (err) {
document.getElementById('wp-dlg-err').textContent = err;
document.getElementById('wp-dlg-input').focus();
return;
}
}
close(v);
} else close(true);
}
function cancel() {
var ov = document.getElementById('wp-dlg-overlay');
var opts = (ov && ov._opts) || {};
close(opts.input ? null : false);
}
global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
if (typeof global.toast !== 'function') {
// S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
global.toast = function (msg, kind) {
ensure();
var t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
t.textContent = msg;
t.classList.add('show');
clearTimeout(global.toast._t);
global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
};
}
})(window);

View File

@@ -120,12 +120,12 @@
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;background:var(--cds-layer);';
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:var(--cds-text-helper);margin-bottom:6px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language &amp; time</div>' +
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Language &amp; time</div>' +
'<div style="padding:4px 18px 16px;">' +
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
'<label style="' + lbl + '">Language &amp; number format</label>' +
@@ -135,19 +135,19 @@
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
'Calendar dates like a due date are never shifted.</div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:var(--cds-layer-accent);border-radius:6px;font-size:12.5px;"></div>' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
function msg(text, ok) {
var e = document.getElementById('wp-prefs-msg');
e.style.display = 'block'; e.textContent = text;
e.style.background = ok ? '#defbe6' : '#fff1f1';
e.style.color = ok ? '#0e6027' : '#da1e28';
e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)';
e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);

View File

@@ -105,6 +105,18 @@
say(bits.join(''), problem);
}
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
// throws - which used to land in catch() and read as "could not reach the
// server" while the server was answering fine (found 2026-08-23, the
// production locations import). Read text, parse if it parses, keep status.
function readJson(r) {
return r.text().then(function (t) {
var j = null;
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
return { ok: r.ok, status: r.status, body: j };
});
}
function importText(dryRun) {
var text = (el(p + '-paste') || {}).value || '';
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
@@ -114,7 +126,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(readJson)
.then(function (res) {
if (!res.ok) {
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
@@ -142,7 +154,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(read.payload),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(readJson)
.then(function (res) {
if (!res.ok) {
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
@@ -161,7 +173,7 @@
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(patchBody),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(readJson)
.then(function (res) {
if (!res.ok) {
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);

View File

@@ -32,7 +32,7 @@
{ id: 'scope', label: 'Scope of Work',
note: 'The ordered steps the crew performs, and the labour estimate.' },
{ id: 'assets', label: 'Assets',
note: 'Asset tags and controls.dev links. Off for Micron EUV — the content duplicates the database Clinton’s team maintains (CR-016).' },
note: 'Asset IDs picked read-only from the Micron DB (D11), with manual entry for anything not listed. Off for Micron EUV — the customer’s own database stays the source of truth; this section only references it (CR-016).' },
{ id: 'materials', label: 'Materials',
note: 'The bill of materials that feeds kitting.' },
{ id: 'kitting', label: 'Kitting',

View File

@@ -43,7 +43,7 @@
{ 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',
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console',
sub: 'Settings & diagnostics',
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
// Account actions, inherited from the flat user menu that used to sit in the app
@@ -51,9 +51,8 @@
// drawer already had; these two were its only unique contents, so they moved here
// rather than being lost with it. `action` items render as buttons, not links.
{ section: 'Account' },
{ action: 'wpPreferences', icon: '⌚', label: 'Language & time',
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
];
function esc(v) {

View File

@@ -1,58 +0,0 @@
/* Usage analytics core — the ONE implementation (D5 / T7.10).
This existed three times: the creator's copy, the wizard's copy (which had no
caller — the button lived on the creator), and the admin console's own reader.
Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
document as five colliding globals; an unreferenced duplicate is exactly what
produced D5. One core now; the pages keep only a thin track() wrapper because
page state (the creator's dev-mode pause) belongs to the page.
The storage KEYS are unchanged on purpose: everything recorded before this
file existed is still readable through it. No field VALUES are ever stored —
a field-edit event records the field id, nothing else.
Classic script, no modules: exposes window.WPUsage. */
'use strict';
(function () {
var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
function load(key) {
try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
catch (e) { return { events: [] }; }
}
function save(key, data) {
try { localStorage.setItem(key, JSON.stringify(data)); }
catch (e) { /* storage unavailable — degrade silently */ }
}
function track(key, event, detail) {
try {
var d = load(key);
d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
if (d.events.length > 5000) d.events = d.events.slice(-5000);
save(key, d);
} catch (e) { /* never let telemetry break the tool it watches */ }
}
function download(key, prefix) {
var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.WPUsage = {
load: load,
save: save,
track: track,
download: download,
// The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
};
})();

View File

@@ -12,14 +12,58 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# CORS_ORIGINS=http://localhost:5500
# ── Authentication ────────────────────────────────────────────────────────────
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
# the API falls back to a random per-process key, so logins reset on every
# restart and break across multiple gunicorn workers. Generate a strong one:
# There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in
# still ends the same way it always did: a signed JWT in an HttpOnly session
# cookie, which is what the four vars right below this line are for. The five
# OKTA_* vars after that are what makes the actual sign-in possible; without
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
# describe()'s startup log line says so and nobody can sign in.
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
# someone is — this app still decides roles/authorization locally, unchanged
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
# falls back to a random per-process key, so logins reset on every restart and
# break across multiple gunicorn workers. Generate a strong one:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12
# D18 (2026-09-23): a session slides on activity, capped by a hard ceiling
# underneath - not one flat lifetime. Both defaults are proposed, not
# confirmed against this tenant's actual Okta SSO session policy - if Okta's
# own session outlives either number, re-auth here is likely a fast redirect
# rather than a real login screen, so these cost less than they look like.
#
# No request for this many minutes ends the session outright.
# AUTH_IDLE_MINUTES=30
#
# The absolute ceiling from original sign-in, regardless of activity - no
# session outlives this no matter how continuously active it is. Default 8.
# AUTH_SESSION_HOURS=8
# ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default
# or a custom authorization server URL. The API discovers the authorize/token/
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing
# else about Okta's endpoints is hand-entered.
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
# Client ID and secret from the Okta app integration (Sign-in method: OIDC -
# Authorization Code, Application type: Web Application). The secret is exactly
# that — treat it like AUTH_SECRET_KEY, never commit it.
OKTA_CLIENT_ID=CHANGE_ME
OKTA_CLIENT_SECRET=CHANGE_ME
# Must exactly match a "Sign-in redirect URI" registered on the Okta app
# integration, scheme and path included, e.g.:
# https://wp-suite.company.local/api/auth/okta/callback
OKTA_REDIRECT_URI=CHANGE_ME
# Which ID token claim carries this person's directory identity, matched
# against the local users.username column (server/app.py's okta_callback()).
# preferred_username is Okta's usual default for an AD-imported user; override
# it if your security team's Okta configuration uses a different claim (upn,
# a custom claim, …) — no code change needed, just this value.
# OKTA_IDENTITY_CLAIM=preferred_username
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
@@ -30,3 +74,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password
# ── Micron asset catalog (optional) ───────────────────────────────────────────
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
# app only ever runs the single SELECT in server/assets_db.py, so give it a
# db_datareader login and nothing more.
#
# Leave this unset and the suite works normally — the picker reports that no
# catalog is configured and people type asset tags in by hand.
#
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
#
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
# and install the Microsoft ODBC driver in the image:
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
#
# Two things to check when the picker says the catalog is unreachable:
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
# Micron schema — that one constant is the whole schema contract.
# 2. The api container is on the `outbound` network in docker-compose.yml. The
# `internal` network has no default gateway, which blocks the VPN as well as
# the internet.

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) |
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
| GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) |
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
| POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | create an account (**admin**) |
| POST | `/api/auth/users` | pre-create an account by username (**admin**) |
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries |
@@ -40,50 +40,81 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
---
## Login portal (user accounts)
## Sign-in (Okta)
The suite is gated by a username/password login. Sign-in issues a signed JWT
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
marked **Secure** automatically whenever the request arrives over HTTPS (via
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
request is validated by checking the cookie's signature and expiry.
There is no local password anywhere in this app (D15/D16) — Okta OIDC is the
only way in. `login.html` is a single "Sign in with Okta" button; the actual
exchange is `server/okta_auth.py` (the Okta client config) and the two routes
in `server/app.py`: `okta_login()` sends the browser to Okta's authorize
endpoint, `okta_callback()` exchanges the code, matches the ID token's identity
claim against `users.username`, and signs the person in.
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly,
SameSite=Lax** cookie (`wp_session`), marked **Secure** automatically whenever
the request arrives over HTTPS (via NGINX's `X-Forwarded-Proto`). There is no
server-side session store — each request is validated by checking the cookie's
signature and expiry. Okta only confirms *who* someone is; this app still
decides *what* they may do — roles, project membership, everything below stays
local and unchanged by Okta.
**The real security boundary is the API:** every `/api/` data route is refused
with `401` unless a valid session cookie is present (see `auth_gate` in
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
to `login.html` when there's no session — that's for UX, not protection.
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
`admin` (may manage users) and `user`.
**Access gating is Okta's job, not this app's.** Only accounts assigned to the
app integration in Okta can complete the sign-in flow at all, so there is no
required-group or claim check layered on top here. Once Okta lets someone
through, this app decides their role — see below.
### Set the signing secret
Roles are `admin`, `project_super_user`, `project_admin`, `project_user`
(`html/users.js`, enforced server-side).
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
without it the API uses a random per-process key, so logins reset on restart.
### Set the signing secret and the Okta app integration
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY`
is **required in production**: without it the API uses a random per-process
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the
same way — the API starts without them, it just refuses every sign-in and says
so in the startup log (`okta_auth.describe()`).
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
The Okta app integration itself (sign-in method OIDC, Application type Web
Application) needs its **Sign-in redirect URI** set to exactly
`OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to
it — that assignment IS the access control (see above).
### Create the first admin
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
shell (run from the **project root**, like uvicorn):
There's no `create-admin` command anymore — creating an account from scratch
by hand-typed username risks a second, orphaned row if it doesn't exactly match
what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead,
have the first admin **sign in through Okta once** — they land as an ordinary
`project_user`, JIT-provisioned — then promote that existing row from a shell
(run from the **project root**, like uvicorn):
```bash
python -m server.manage_users create-admin alice --name "Alice Smith"
# prompts for a password (min 8 chars)
python -m server.manage_users promote alice --role admin
```
In Docker:
```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
docker compose exec api python -m server.manage_users promote alice --role admin
```
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
`disable <user>`, `enable <user>`. After that, admins can add users through the
API (or you can keep using the CLI).
Other commands: `list`, `disable <user>`, `enable <user>`. After the first
admin exists, they can promote others through the User Directory page (or keep
using the CLI) — no shell access needed for anyone after the first.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored (D16) — this
is a deliberate choice, the same one the abandoned LDAPS design made, not an
oversight.
---
@@ -291,25 +322,25 @@ docker compose down -v
## Quick test
`/api/health` is open; data routes now require a session, so log in first and
reuse the cookie jar:
`/api/health` is open; every other `/api/` route needs a session cookie:
```bash
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
# Sign in, saving the session cookie to a jar
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"<password>"}'
# Reuse the cookie on protected routes
curl -b jar.txt http://127.0.0.1:8000/api/comments
```
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`.
Or via the nginx proxy (replace with your hostname):
```bash
curl https://wp-suite.company.local/api/health
```
There's no `curl`-able login anymore — Okta requires a real browser to
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
exercise a protected route from a script instead, use `server/smoketest.py`'s
own technique (mint a session with `auth.create_token()` and set it as the
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
back an identity) rather than reaching for curl by hand — see that script's
own AUTHENTICATION section for the exact steps, and why it has to run
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.

View File

@@ -52,6 +52,13 @@ def run_migrations_online() -> None:
connection=connection,
target_metadata=target_metadata,
compare_type=True,
# Each migration commits on its own. One transaction for the WHOLE
# run meant a crash at step N rolled back steps 1..N-1 while their
# "Running upgrade" lines stayed on screen claiming they ran - the
# 2026-08-21 outage's stamp-to-head repair trusted those lines and
# left production missing two tables (found 2026-08-23 when the
# locations import 500'd on a table that "had been created").
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -0,0 +1,29 @@
"""drop local password (T10.4, wave 10 / D15 / D16)
Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
`password_hash` was NOT NULL at the database level since the baseline schema, so
downgrade re-adds it with server_default='' rather than leaving existing rows
without a value — the same pattern used for the locale/timezone drop-precedent
columns, applied in reverse.
Revision ID: 1d60a608bb51
Revises: a1b8c6d4e2f9
Create Date: 2026-09-03 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1d60a608bb51'
down_revision = 'a1b8c6d4e2f9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column('users', 'password_hash')
def downgrade() -> None:
op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))

View File

@@ -0,0 +1,46 @@
"""usage events (CR-019, wave 11)
Append-only navigation/session activity, separate from audit_log on purpose —
see the UsageEvent docstring in server/models.py. Retention is indefinite by
decision (docs/waves/decisions-2026-09-17.md); nothing here schedules a purge.
Revision ID: 8e2cb3003f8a
Revises: 1d60a608bb51
Create Date: 2026-09-23 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8e2cb3003f8a'
down_revision = '1d60a608bb51'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('usage_events',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('username', sa.String(length=200), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('tool', sa.String(length=40), nullable=False),
sa.Column('event', sa.String(length=40), nullable=False),
sa.Column('detail', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_usage_events_at'), 'usage_events', ['at'], unique=False)
op.create_index(op.f('ix_usage_events_username'), 'usage_events', ['username'], unique=False)
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
op.create_index(op.f('ix_usage_events_tool'), 'usage_events', ['tool'], unique=False)
op.create_index(op.f('ix_usage_events_event'), 'usage_events', ['event'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_usage_events_event'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_tool'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_username'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_at'), table_name='usage_events')
op.drop_table('usage_events')

View File

@@ -32,7 +32,11 @@ def upgrade() -> None:
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
# Postgres refuses it (DatatypeMismatch) - found when this migration
# took down the wp.controls.dev api container on 2026-08-21. The
# location-taxonomy migration next door had it right all along.
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),

View File

@@ -9,24 +9,32 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs
"""
import base64
import csv
import hashlib
import hmac
import io
import logging
import os
import re
import uuid
from datetime import timedelta, timezone
from time import monotonic
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from authlib.integrations.base_client import OAuthError
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete, func
from sqlalchemy import select, delete, func, or_
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from .db import Base, engine, get_db
from . import models, auth, notify
from . import models, auth, notify, assets_db, okta_auth, okta_fake
log = logging.getLogger("wpsuite.app")
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -56,6 +64,22 @@ if _origins:
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
)
# Authlib's Okta client needs request.session to carry the OIDC state/nonce (and,
# below, our own post-login redirect target) across the round trip to Okta and
# back — it raises an AssertionError without this. Bug found in T10.2 (those
# routes never crashed in testing because every prior check mocked
# authorize_redirect/authorize_access_token directly, bypassing Authlib's real
# implementation); fixed here rather than reworking already-shipped T10.2 code.
#
# This is NOT the app's session cookie — wp_session (auth.py) still carries the
# actual signed-in identity, unchanged. This cookie holds nothing but ephemeral,
# per-attempt OAuth state, so it gets a short lifetime and a plain secret reuse
# (auth.SECRET_KEY) rather than its own required config knob.
app.add_middleware(
SessionMiddleware, secret_key=auth.SECRET_KEY, session_cookie="wp_oauth_state",
same_site="lax", https_only=False, max_age=600,
)
# ── Authentication gate ────────────────────────────────────────────────────────
# Every /api/ data route requires a valid session cookie. Login, health, and the
@@ -84,12 +108,23 @@ def _csrf_ok(request: Request) -> bool:
async def auth_gate(request: Request, call_next):
path = request.url.path
method = request.method
claims = None
if method != "OPTIONS" and auth._needs_auth(path):
if not auth.is_request_authenticated(request):
claims = auth.is_request_authenticated(request)
if not claims:
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
return await call_next(request)
response = await call_next(request)
# D18: slide the session forward on activity, capped by its absolute
# ceiling. Reads only the already-validated claims - no DB hit here, and
# the separate is_active/token_version check in get_current_user still
# runs on its own for every request regardless of whether this refreshes.
if claims is not None:
refreshed = auth.maybe_refresh_token(claims)
if refreshed:
auth.set_session_cookie(response, request, refreshed)
return response
def gen_id(prefix: str) -> str:
@@ -458,15 +493,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
return (base + path) if base else path
def wp_titled(wp: "models.WorkPackage") -> str:
"""Number — title, for a message body. Decided 2026-08-20: the title is
customer CONTEXT and may ride in mail; document CONTENT may not."""
t = (wp.subject or "").strip()
n = wp.number or "a work package"
return f"{n} — {t}" if t else n
def wp_where(wp: "models.WorkPackage") -> str:
"""Where the work happens, for a message body: the CR-004 structured
fields (stored as paths — stable, and readable to the people these mails
address), else the pre-CR-004 free text. Empty string when unset, and
callers drop the line entirely rather than mail 'Where: '."""
data = wp.data or {}
parts = [str(data.get(d) or "").strip() for d in LOCATION_DIMENSIONS]
parts = [p for p in parts if p]
return " / ".join(parts) if parts else str(data.get("location") or "").strip()
def _where_line(wp: "models.WorkPackage") -> str:
w = wp_where(wp)
return f"Where: {w}\n" if w else ""
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
# customer IP inside the app, behind login).
# Number, title and location — customer context, allowed since the
# 2026-08-20 decision (decisions-2026-08-20.md). Contents stay behind
# the link: no scope text, no descriptions, no attachments.
who = actor.full_name or actor.username
name = assignee.full_name or assignee.username
return (
f"Hi {name},\n\n"
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
f"{who} assigned you a work package: {wp_titled(wp)}.\n"
+ _where_line(wp) +
f"\nOpen the Work Package Suite to view and action it:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
)
@@ -579,14 +640,8 @@ def health():
# ── Authentication ─────────────────────────────────────────────────────────────
class LoginIn(BaseModel):
username: str
password: str
class NewUserIn(BaseModel):
username: str
password: str
full_name: str = ""
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
@@ -608,24 +663,6 @@ class PreferencesIn(BaseModel):
timezone: Optional[str] = None
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
class AdminPasswordIn(BaseModel):
new_password: str
class ActiveIn(BaseModel):
is_active: bool
@@ -648,160 +685,148 @@ class AutoAddIn(BaseModel):
role: str = ""
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie.
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
user = auth.find_user(db, body.username)
now = models.utcnow()
# Always run the hash comparison first — even for missing or locked accounts —
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "")
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
if not user or not valid:
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = now
db.commit()
token = auth.create_token(user)
auth.set_session_cookie(response, request, token)
return {"user": user.to_dict()}
@app.post("/api/auth/logout")
def logout(response: Response):
auth.clear_session_cookie(response)
return {"ok": True}
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
# ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
# Access gating is Okta's job: only accounts assigned to this app integration in Okta
# can complete authorize_redirect at all. No app-side group/claim check is layered on
# top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
def _safe_next_path(raw: str) -> str:
"""A same-site path only — same rule login.js's own nextTarget() enforces
client-side. Rejects absolute/scheme URLs ('//evil.com', 'https://evil.com')
so a crafted ?next= can't turn a real Okta sign-in into an open redirect."""
raw = (raw or "").strip()
if raw and raw.startswith("/") and not raw.startswith("//"):
return raw
return ""
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
@app.get("/api/auth/okta/login")
async def okta_login(request: Request):
"""Send the browser to Okta's authorize endpoint. Where to land afterward
(?next=, e.g. from a deep link an assignment email carried — X1/CR-011/CR-014)
rides in the OAuth-state session cookie alongside Authlib's own state/nonce,
since nothing else survives the round trip to Okta and back."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
next_path = _safe_next_path(request.query_params.get("next", ""))
if next_path:
request.session["post_login_redirect"] = next_path
return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
@app.get("/api/auth/okta/callback")
async def okta_callback(request: Request, db: Session = Depends(get_db)):
"""Exchange the authorization code for tokens, validate the ID token, and sign the
person in. T10.3: matches the identity claim to a local account, or JIT-provisions
one, then issues the same session cookie login() does today.
Failure paths land back on the login page with a plain-language ?error= instead
of a raw HTTPException — this route is reached by a full-page browser navigation
from Okta, not a fetch() call, so a JSON error body is just a broken-looking page
to whoever is sitting at the keyboard (T10.5)."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
try:
token = await okta_auth.oauth.okta.authorize_access_token(request)
except OAuthError as exc:
log.warning("Okta callback rejected: %s", exc)
return RedirectResponse(url="/login.html?error=cancelled", status_code=303)
claims = token.get("userinfo") or {}
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
if not identity:
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
user = auth.find_user(db, identity)
if user is None:
# JIT provisioning (D15). Okta is the only gate on WHO can reach this route
# at all — this app still decides what a first-time sign-in may do. A new
# account gets the lowest-privilege role and no project membership; an admin
# or project super user grants access afterward, same as any account created
# by hand today (create_user() above). No password field exists at all —
# Okta is the only credential (D15/D16, real deletion as of T10.4).
user = models.User(
id=gen_id("user"),
username=identity,
email=(claims.get("email") or "").strip(),
full_name=(claims.get("name") or "").strip(),
role=auth.ROLE_PROJECT_USER,
)
if _reset_throttled(request, body.username):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
db.add(user)
db.flush()
log_event(db, user.username, "user_created", "user", user.id, summary=user.username,
detail={"role": user.role, "via": "okta_jit"})
elif not user.is_active:
# Deprovisioning stays local (D15's "roles stay local"): Okta letting someone
# through does not override an account this app has disabled. Same rule
# login() enforced today, now surfaced as a login-page banner (T10.5)
# instead of a raw 403 body, for the reason in this route's docstring.
return RedirectResponse(url="/login.html?error=disabled", status_code=303)
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0
user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
user.last_login_at = models.utcnow()
# CR-019: one login event per Okta sign-in, written here rather than
# inferred from session creation elsewhere, so there is exactly one
# source of truth for "did this person sign in" - not one per page load
# afterward (T11.2 covers that separately, as page_open events).
db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
db.commit()
return {"ok": True}
db.refresh(user)
tok = auth.create_token(user)
target = _safe_next_path(request.session.pop("post_login_redirect", "")) or "/index.html"
redirect = RedirectResponse(url=target, status_code=303)
auth.set_session_cookie(redirect, request, tok)
return redirect
# ── Fake Okta test seam (T10.7) ──────────────────────────────────────────────
# Registered ONLY when the fake is active — checked once, at import time, same
# timing okta_auth.oauth itself is built at. In production these two routes do
# not exist at all, not merely refuse a request: see server/okta_fake.py's
# docstring for why that distinction matters given D16 leaves no other way in.
if okta_fake.is_active():
@app.get("/api/auth/okta/_fake_provider")
async def okta_fake_provider(request: Request):
"""Stands in for Okta's own sign-in screen. A plain list of the
identities WP_OKTA_FAKE_DIRECTORY defines, so a browser check drives a
real page through a real round trip rather than skipping it."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
from fastapi.responses import HTMLResponse
return HTMLResponse(okta_fake.picker_page(state, redirect_uri))
@app.get("/api/auth/okta/_fake_provider/consent")
async def okta_fake_consent(request: Request):
"""What clicking an identity (or Deny) on the fake picker does: hands
back an authorization code (or an error) at okta_callback, exactly the
shape a real Okta redirect would carry. Everything after this — the
state check, JIT provisioning, the disabled-account and open-redirect
guards — is the real okta_callback() above, unmodified."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
if request.query_params.get("deny"):
return RedirectResponse(
url=f"{redirect_uri}?error=access_denied&error_description=denied+by+fake+user&state={state}",
status_code=303)
username = request.query_params.get("username", "")
entry = okta_fake.directory().get(username)
if not isinstance(entry, dict):
return RedirectResponse(
url=f"{redirect_uri}?error=invalid_request&error_description=unknown+fake+identity&state={state}",
status_code=303)
claims = {okta_auth.IDENTITY_CLAIM: username,
"email": entry.get("email", ""), "name": entry.get("name", "")}
code = okta_fake.new_code(claims)
return RedirectResponse(url=f"{redirect_uri}?code={code}&state={state}", status_code=303)
@app.get("/api/auth/me")
@@ -857,22 +882,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
return {"user": user.to_dict()}
@app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True}
# ── User administration ─────────────────────────────────────────────────────────
# 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.
@@ -960,9 +969,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users")
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)
"""Create an account ahead of its first Okta sign-in — e.g. to put it on
projects or hand it a role before anyone has ever signed in as them.
`body.username` MUST match what Okta's identity claim will send for this
person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
fail loudly; it silently produces a second, JIT-provisioned account instead
of signing this person into the one just created here."""
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -994,7 +1008,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
username=body.username.strip(),
email=body.email.strip(),
full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password),
role=body.role,
project_role=body.project_role.strip()[:120],
)
@@ -1021,24 +1034,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
return directory_entry(db, u, actor)
@app.post("/api/auth/users/{user_id}/password")
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active")
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)
@@ -1294,6 +1289,15 @@ def list_projects(
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()
# D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone
# below that sees them nowhere, counts and pickers included. The default
# listing already excludes them; asking for them is what gets gated, and it
# is gated per project, so admin-on-Job-A does not surface archived Job B.
if archived != "exclude":
rows = [p for p in rows
if p.archived_at is None
or effective_role(db, user, p.id) in (
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)]
return [p.summary() for p in rows]
@@ -1588,15 +1592,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
).first()
if not sop:
return []
proj = (sop.data or {}).get("project") or {}
data = sop.data or {}
# BL-021, fixed 2026-08-20: pushSOP writes the row as data={sop, state}, so
# the project block lives at data['sop']['project']. This read was one
# level too shallow - always {} - and the critical-reopen mail never
# reached the PM or CM its docstring promises. Same tolerant read as
# project_qa_group below: nested shape first, flat shape for hand-written rows.
proj = ((data.get("sop") or {}).get("project")
or data.get("project") or {})
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]:
"""D2: the QA group named on the project's latest complete SOP. pushSOP writes
the row as data={sop, state}, so the project block is data['sop']['project'] -
note that project_sop_team above reads data['project'], which that shape never
has (BL-021, logged, not fixed here)."""
project_sop_team above read the flat shape until BL-021 was fixed
(2026-08-20); both now read nested-first, exactly alike."""
if not project_id:
return []
sop = db.scalars(
@@ -1643,14 +1654,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
actor: "models.User", link: str) -> str:
# A WP number and a deep link - NOT the package contents. The task text asked
# for location and a scope summary, but the done-when list (and the standing
# rule) says no customer IP in a message body; the link is the summary.
# Number, title and location ride in the body — the 2026-08-20 decision
# restored the location the T7.6 done-when had excluded. The SCOPE summary
# stays out: scope text is document content, and the link is its summary.
who = actor.full_name or actor.username
name = user.full_name or user.username
return (
f"Hi {name},\n\n"
f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n"
f"{who} moved {wp_titled(wp)} to Ready for QA.\n"
+ _where_line(wp) +
f"It is in the QA queue waiting to be accepted or returned.\n\n"
f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
@@ -1663,7 +1675,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
name = user.full_name or user.username
return (
f"Hi {name},\n\n"
f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n"
f"{who} returned {wp_titled(wp)} from Ready for QA to In Progress.\n"
+ _where_line(wp) +
f"The reason is recorded on the package.\n\n"
f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
@@ -1696,18 +1709,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage",
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
actor: "models.User", link: str) -> str:
# Constraint names and a WP number only — no package contents, same rule as the
# assignment mail.
# Constraint names, number, title and location — customer context per the
# 2026-08-20 decision. No package contents; the link carries those.
who = user.full_name or user.username
by = actor.full_name or actor.username
which = ", ".join(names)
return (
f"Hi {who},\n\n"
f"A critical constraint was reopened on {wp.number or 'a work package'} "
f"A critical constraint was reopened on {wp_titled(wp)} "
f"after it was released to the field, so the package is on hold.\n\n"
f"Constraint: {which}\n"
+ _where_line(wp) +
f"Reopened by: {by}\n\n"
f"Open the package:\n{link}\n"
f"Open the package:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
)
@@ -1724,7 +1739,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
return (
f"Hi {name},\n\n"
f"{who} moved kitting on {wp.number or 'a work package'} from "
f"{who} moved kitting on {wp_titled(wp)} from "
f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n"
@@ -1740,7 +1755,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage",
needed_line = f" needed by {needed}" if needed else ""
return (
f"Hi {name},\n\n"
f"{who} raised a material request on {wp.number or 'a work package'}: "
f"{who} raised a material request on {wp_titled(wp)}: "
f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n"
@@ -2342,6 +2357,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
rejected.append({"line": i, "text": line,
"reason": "no letters or digits to make a code from"})
continue
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
# production import (2026-08-23, BL-027's class again) instead of coming
# back as a rejection with its line number. Validate per row, here,
# so every dialect answers the same way: with a reason.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
continue
long_p = next((p for p in parts if len(p) > 200), None)
if long_p is not None:
rejected.append({"line": i, "text": line[:120],
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
continue
if any(len(location_slug(p)) > 60 for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "a code would be longer than 60 characters"})
continue
rows.append((i, parts))
return rows, rejected
@@ -2408,6 +2441,7 @@ def import_locations(project_id: str, body: LocationImportIn,
require_project_writable(db, user, project_id, "The location list cannot be changed")
rows, rejected = parse_location_rows(body.text)
read_total = len(rows) + len(rejected)
existing = {n.path: n for n in db.scalars(
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
@@ -2422,6 +2456,10 @@ def import_locations(project_id: str, body: LocationImportIn,
for line_no, parts in rows:
segs = [location_slug(p) for p in parts]
full = "/".join(segs)
if len(full) > 200:
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
"reason": "the combined path is longer than 200 characters"})
continue
if full in seen_in_file:
duplicates.append({"line": line_no, "path": full, "names": parts,
"reason": "already on line %d of this import" % seen_in_file[full]})
@@ -2464,7 +2502,7 @@ def import_locations(project_id: str, body: LocationImportIn,
result = {
"project_id": project_id, "dry_run": bool(body.dry_run),
"read": len(rows) + len(rejected),
"read": read_total,
"created": created, "duplicates": duplicates,
"reactivated": reactivated, "rejected": rejected,
}
@@ -2961,6 +2999,18 @@ def parse_material_rows(text: str):
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "more than three columns - description, unit, code is the whole shape"})
continue
# Same guard as parse_location_rows: reject what Postgres would refuse
# (VARCHAR limits, control bytes) with the line number, never a 500.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
continue
caps = ((300, "description"), (20, "unit"), (80, "code"))
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
if long_col:
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
continue
rows.append((i, parts))
return rows, rejected
@@ -3345,6 +3395,177 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
return _save_comment(body, db, user)
# ── CR-019: usage/activity metrics ──────────────────────────────────────────
class UsageIn(BaseModel):
tool: str = ""
project_id: Optional[str] = None
@app.post("/api/usage/ping")
def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""One usage_events row per authenticated page load. Called exactly once,
from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
per page's own script, the same lesson S4's per-page nav already taught
this codebase. `user` comes from the session via get_current_user, never
from anything the client claims - identity here is a server-enforced
fact, matching every other write in this file, not a client-reported one."""
tool = (body.tool or "").strip()[:40]
db.add(models.UsageEvent(
id=gen_id("uev"), username=user.username, project_id=body.project_id,
tool=tool, event="page_open",
))
db.commit()
return {"ok": True}
def _parse_date_q(v: str, end: bool = False) -> Optional[datetime]:
"""Accepts a plain YYYY-MM-DD (what a <input type=date> sends) or a full
ISO datetime. A date-only `to` means "through the end of that day", not
midnight at its start - otherwise a range of "today" would match nothing
from today at all."""
if not v:
return None
try:
d = datetime.fromisoformat(v)
except ValueError:
return None
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
if end and len(v) <= 10: # date-only
d = d + timedelta(days=1) - timedelta(microseconds=1)
return d
def _usage_query(db: Session, caller: "models.User", date_from, date_to, project_id, username, tool):
"""Shared by the summary and export endpoints so the two can never
disagree about which rows a filter set matches - the export is a raw
dump of exactly what the summary counted, not a separately-derived view."""
stmt = select(models.UsageEvent)
managed = managed_project_ids(db, caller)
if managed is not None:
# A project_super_user (never an app admin, who gets managed=None) is
# scoped to events tied to a project they administer, plus their OWN
# suite-wide activity (admin console opens etc. carry no project_id) -
# never another user's activity outside what they manage.
if managed:
stmt = stmt.where(or_(
models.UsageEvent.project_id.in_(managed),
models.UsageEvent.username == caller.username,
))
else:
stmt = stmt.where(models.UsageEvent.username == caller.username)
df = _parse_date_q(date_from)
dt = _parse_date_q(date_to, end=True)
if df:
stmt = stmt.where(models.UsageEvent.at >= df)
if dt:
stmt = stmt.where(models.UsageEvent.at <= dt)
if project_id:
stmt = stmt.where(models.UsageEvent.project_id == project_id)
if username:
stmt = stmt.where(models.UsageEvent.username == username)
if tool:
stmt = stmt.where(models.UsageEvent.tool == tool)
return stmt.order_by(models.UsageEvent.at)
@app.get("/api/usage/summary")
def usage_summary(
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
project_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
tool: Optional[str] = Query(None),
caller: models.User = Depends(require_user_manager),
db: Session = Depends(get_db),
):
"""CR-019. Same gate as the User Directory (require_user_manager): an app
admin or a project_super_user with at least one managed project. Filters
combine. Aggregated in Python over the filtered row set rather than a SQL
GROUP BY - correct and simple at today's scale; if usage_events grows
into the millions (plausible, given retention is indefinite by decision),
the day/week/month bucketing here is the first thing to move server-side
into SQL. Not done now because nothing currently requires it."""
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
by_day: dict[str, set] = {}
by_week: dict[str, set] = {}
by_month: dict[str, set] = {}
per_user_last: dict[str, datetime] = {}
per_tool: dict[str, int] = {}
for e in rows:
by_day.setdefault(e.at.date().isoformat(), set()).add(e.username)
by_week.setdefault(e.at.strftime("%G-W%V"), set()).add(e.username)
by_month.setdefault(e.at.strftime("%Y-%m"), set()).add(e.username)
if e.username not in per_user_last or e.at > per_user_last[e.username]:
per_user_last[e.username] = e.at
per_tool[e.tool] = per_tool.get(e.tool, 0) + 1
return {
"active_users": {
"by_day": {k: len(v) for k, v in sorted(by_day.items())},
"by_week": {k: len(v) for k, v in sorted(by_week.items())},
"by_month": {k: len(v) for k, v in sorted(by_month.items())},
},
"per_user_last_active": {u: models._iso(t) for u, t in sorted(per_user_last.items())},
"by_tool": dict(sorted(per_tool.items(), key=lambda kv: -kv[1])),
"event_count": len(rows),
}
def _pseudonym(username: str) -> str:
"""A stable per-user id for the sanitized export — the SAME input always
produces the SAME output, within one export and across separate export
runs, so an external system (Power BI or similar) can still group and
trend "by user" without ever receiving a real name. HMAC rather than a
plain hash: a plain sha256(username) is trivially reversed against a
wordlist of the handful of usernames this app actually has; keying it
with AUTH_SECRET_KEY (already a real secret, already required in
production — see auth.py) means recovering a username from its
pseudonym requires the signing key, not just guessing."""
digest = hmac.new(auth.SECRET_KEY.encode(), username.encode(), hashlib.sha256).hexdigest()
return "u_" + digest[:16]
@app.get("/api/usage/export")
def usage_export(
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
project_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
tool: Optional[str] = Query(None),
sanitize: bool = Query(False),
caller: models.User = Depends(require_user_manager),
db: Session = Depends(get_db),
):
"""CR-019. Same gate, same filters, same underlying row set as
usage_summary() (_usage_query) — the export can never show a different
slice of data than what the console counted for the same filters.
sanitize=true replaces `username` with a stable pseudonym (_pseudonym)
and — deliberately — the `detail` column is not exported in EITHER mode.
Every event this app writes today (login, page_open) leaves `detail`
empty, so this costs nothing now, but it also means a future event type
that DOES populate `detail` can't accidentally leak a real name into a
sanitized file through a column nobody thought to scrub. If `detail`
is ever needed in the export, it has to be sanitized explicitly, not
assumed safe because the rest of the row was."""
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["at", "username", "project_id", "tool", "event"])
for e in rows:
who = _pseudonym(e.username) if sanitize else e.username
w.writerow([models._iso(e.at), who, e.project_id or "", e.tool, e.event])
filename = "usage_export_%s_%s.csv" % (
"sanitized" if sanitize else "raw", datetime.now(timezone.utc).strftime("%Y%m%d"),
)
return Response(
content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.get("/api/comments")
def list_comments(
source: Optional[str] = Query(None),
@@ -3383,6 +3604,29 @@ def list_comments(
return [c.to_dict() for c in rows]
# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
# Backs the asset picker in the work package creator. This is a *lookup*, not a
# resource this app owns: there is no POST, and nothing here ever writes to the
# Micron database. It is deliberately not project-scoped by the app's own access
# rules — the catalog is reference data, and any signed-in user who can build a
# work package needs to be able to name the assets it covers. Authentication is
# still required (the auth_gate middleware covers every /api/ path).
@app.get("/api/assets")
def list_assets(_user: models.User = Depends(auth.get_current_user)):
"""The whole catalog, fetched once when the creator loads. Searching happens
in the browser — there is no per-keystroke endpoint by design."""
if not assets_db.configured():
# Not an error — the suite is designed to run without Micron wired up.
# The picker reads this and switches to manual entry.
return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
try:
return {"configured": True, "assets": assets_db.load()}
except assets_db.AssetSourceError as exc:
# 503, not 500: the suite is healthy, its upstream lookup is not. The
# picker degrades to manual entry rather than blocking the package.
raise HTTPException(status_code=503, detail=str(exc))
# ── Local dev convenience: serve the static site from this app ──────────────────
# In production NGINX serves html/ and only proxies /api/ here, so this app never
# receives "/" requests, and the api Docker image doesn't even include html/ — so

246
server/assets_db.py Normal file
View File

@@ -0,0 +1,246 @@
"""Read-only reader for the Micron asset catalog.
The work package creator used to ask people to paste a controls.dev link for
every asset. Assets actually live in the Micron database — a SQL Server instance
that is NOT part of this repo and whose schema is not managed here. This module
gives the API a *read-only* window onto it so the creator can offer a searchable
picker instead of free-text links.
How it works: the whole catalog is fetched in one query and handed to the browser
when the creator loads. Searching then happens in the browser with no round trip
at all. The catalog is a list of asset IDs — about 9k of them today and not
expected past 100k — so it is small enough to send whole, and it is slow-moving
reference data, so there is nothing to gain from querying it per keystroke and a
lot of latency to lose. A short server-side cache keeps a room full of people
opening the page from turning into a query each.
Other deliberate constraints:
* **Read-only, always.** The only statement in this file is the SELECT below.
Point it at a login with `db_datareader` and nothing else.
* **No prime_db dependency.** A plain SQLAlchemy connection built from a
connection string, kept separate from the app's own engine in `db.py`, so a
Micron outage can never affect the suite's own database.
Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
returns False, the API says so, and the UI falls back to manual entry. The suite
boots and runs fine without the Micron database being reachable.
"""
import os
import time
import logging
import threading
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
log = logging.getLogger(__name__)
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
# ── The query ─────────────────────────────────────────────────────────────────
# The only place the Micron schema appears; everything else here is plumbing.
# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
# enough to hand over whole, and a partial list would silently hide assets.
#
# Add a WHERE clause here if some rows should never be offered at all
# (decommissioned assets, other sites, …). Filtering at the source keeps the
# payload small, which matters more than anything else here.
ASSET_QUERY = """
SELECT a.AssetID AS tag
FROM Asset.Asset AS a
ORDER BY a.AssetID
"""
def _env_int(name: str, default: int) -> int:
"""A malformed tuning knob degrades to its default; it must never keep the
suite from booting. app.py imports this module unconditionally, so a bare
int() here would turn "300s" in someone's .env into a crash-looping API -
the total-outage switch an OPTIONAL feature is not allowed to own."""
raw = os.getenv(name, "")
try:
return int(raw.strip()) if raw.strip() else default
except ValueError:
log.warning("%s=%r is not an integer; using the default %d.", name, raw, default)
return default
# How long a fetched catalog is reused before the next page load re-queries.
CACHE_SECONDS = _env_int("MICRON_ASSETS_CACHE_SECONDS", 300) # 5 min
# How long a FAILURE is remembered before the next request retries the source.
# Without this, every page load during a Micron outage spends CONNECT_TIMEOUT
# seconds inside a worker thread; enough concurrent loads exhaust the app's
# shared sync threadpool and take unrelated endpoints down with the picker.
FAIL_CACHE_SECONDS = _env_int("MICRON_ASSETS_FAIL_CACHE_SECONDS", 30)
CONNECT_TIMEOUT = 8
class AssetSourceError(RuntimeError):
"""The catalog is configured but could not be read."""
# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
# A full SQLAlchemy URL, e.g.
# mssql+pymssql://user:pass@host:1433/MicronDB
# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
# URL-encode any special characters in the password.
_engine = None
_engine_lock = threading.Lock()
def _db_url() -> str:
return os.getenv("MICRON_DB_URL", "").strip()
def configured() -> bool:
return bool(_db_url())
def _validate_url(url: str) -> None:
"""Catch the one URL mistake that produces a baffling error message.
A password containing an unencoded '@' makes the URL ambiguous: the parser
splits on the first '@', so part of the password ends up parsed as the host.
The driver then reports a connection failure against a nonsense hostname that
happens to contain a fragment of the password — confusing to read and unsafe
to display. Detect it here and say plainly what is wrong.
Nothing from the URL is included in the message; it never leaves this process.
"""
authority = url.split("://", 1)[-1].split("/", 1)[0]
if authority.count("@") > 1:
raise AssetSourceError(
"MICRON_DB_URL is ambiguous: the username or password contains an "
"unencoded '@'. Percent-encode the special characters — @ = %40, "
": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
)
def _connect_args(url: str) -> dict:
"""Per-driver connect timeouts, so an unreachable Micron host fails fast
instead of tying up a worker until the OS gives up."""
if url.startswith("mssql+pymssql"):
return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
if url.startswith("mssql+pyodbc"):
return {"timeout": CONNECT_TIMEOUT}
return {}
def _get_engine():
global _engine
if _engine is not None:
return _engine
url = _db_url()
if not url:
raise AssetSourceError("The Micron DB is not configured.")
_validate_url(url)
with _engine_lock:
if _engine is None:
try:
_engine = create_engine(
url,
connect_args=_connect_args(url),
pool_pre_ping=True, # a recycled dead connection retries instead of erroring
pool_recycle=1800,
pool_size=1, # one catalog query now and then, not a workload
max_overflow=1,
future=True,
)
except Exception as exc: # bad URL, missing driver package, …
# See the note on load() — the exception text can echo the
# connection string, so it is logged and not propagated.
log.error("Micron asset catalog: could not open the connection: %s", exc)
raise AssetSourceError(
"Could not open a connection to the Micron DB. "
"Check MICRON_DB_URL and the API log for the driver error."
) from exc
return _engine
# ── Cache ─────────────────────────────────────────────────────────────────────
# Every page load asks for the whole catalog, so without this a shift change
# would be one full-table query per person. Held per worker process.
_cache: list[dict] | None = None
_cached_at = 0.0
_error: str | None = None # negative cache: the last failure's user-safe text
_error_at = 0.0
_cache_lock = threading.Lock()
def load(force: bool = False) -> list[dict]:
"""Return the whole catalog as [{'tag': …}, …]. Never writes.
Failures are handled in two tiers so a Micron outage stays the picker's
problem and never the suite's (the module contract above):
* a previously fetched catalog is served STALE - it is slow-moving
reference data, and old-but-real beats an error;
* with nothing to serve, the failure itself is cached for
FAIL_CACHE_SECONDS, so an outage costs one CONNECT_TIMEOUT per window
instead of one per page load stacking up in the shared threadpool."""
global _cache, _cached_at, _error, _error_at
with _cache_lock:
if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
return _cache
if (_error is not None and not force
and (time.monotonic() - _error_at) < FAIL_CACHE_SECONDS
and _cache is None):
raise AssetSourceError(_error)
try:
engine = _get_engine()
with engine.connect() as conn:
result = conn.execute(text(ASSET_QUERY)).mappings().all()
except AssetSourceError as exc:
# _get_engine already logged and sanitised; remember or stale-serve.
with _cache_lock:
if _cache is not None:
log.warning("Micron asset catalog unavailable; serving the cached "
"catalog (%d rows).", len(_cache))
return _cache
_error, _error_at = str(exc), time.monotonic()
raise
except SQLAlchemyError as exc:
# The driver's message is NOT propagated. AssetSourceError text reaches the
# browser, and connection errors quote the host, the login, and — when the
# URL is malformed — fragments of the password. Operators get the detail
# from the API log, where it belongs; users get a message they can act on.
log.error("Micron asset catalog query failed: %s", exc)
msg = ("The Micron DB could not be read. Check that the host is "
"reachable, that the login has SELECT on the asset table, and that "
"ASSET_QUERY matches the real schema — the API log has the driver error.")
with _cache_lock:
if _cache is not None:
log.warning("Micron asset catalog unavailable; serving the cached "
"catalog (%d rows).", len(_cache))
return _cache
_error, _error_at = msg, time.monotonic()
raise AssetSourceError(msg) from exc
# Drop rows with no identifier — an asset with no tag is not selectable and
# would render as a blank line in the picker.
rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
with _cache_lock:
_cache, _cached_at = rows, time.monotonic()
_error = None
return rows
def status() -> dict:
"""Describe the source for the UI, so it can explain itself rather than just
showing an empty dropdown."""
if not configured():
return {
"configured": False, "ok": False, "count": 0,
"detail": "The Micron DB is not configured — enter assets manually.",
}
try:
rows = load()
except AssetSourceError as exc:
return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
return {"configured": True, "ok": True, "count": len(rows),
"detail": f"{len(rows):,} asset IDs from the Micron DB."}

View File

@@ -1,10 +1,11 @@
"""Authentication for the Work Package Suite.
A self-contained username/password login. Passwords are stored only as bcrypt
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
(`wp_session`). Because the token is signed and self-validating, there is no
server-side session store — every request is checked by verifying the cookie's
signature and expiry (see `auth_gate` and `get_current_user`).
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
and the routes in server/app.py); there is no local password anywhere in this app
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
self-validating, there is no server-side session store — every request is checked by
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
Security model:
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
@@ -17,7 +18,9 @@ Security model:
warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
project, which lives in `User.project_role` and grants nothing. Decided entirely
locally: Okta gates WHO can authenticate at all, this app decides what an
authenticated account may do — see D15/D16.
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
@@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
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.
Also where Okta JIT provisioning (T10.3) lands a brand-new
account — the lowest-privilege role, promoted locally from
there by an admin (see manage_users.py for the bootstrap case).
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
superseded link stops validating.
"""
import os
import secrets
@@ -46,7 +47,6 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
@@ -59,10 +59,26 @@ log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# D18 (2026-09-23): a session now slides on activity, capped by a hard ceiling
# underneath - not a single flat lifetime. A pure idle timer with no ceiling
# would let a continuously-active session never force a fresh Okta recheck,
# which is a worse fit for this item's own purpose (catching someone still
# active after being deprovisioned) than a flat expiry would have been. Both
# numbers are proposed defaults, not confirmed against the tenant's actual
# Okta SSO session policy - see docs/waves/decisions-2026-09-17.md.
#
# No request for this long invalidates the session outright.
IDLE_MINUTES = int(os.getenv("AUTH_IDLE_MINUTES", "30"))
# The absolute ceiling from the ORIGINAL sign-in, regardless of activity. Same
# env var name as the old flat-lifetime design; the meaning changed, the name
# didn't, because it still answers "how long can this session possibly live."
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "8"))
# How much later a refreshed exp must be before it's worth rewriting the
# cookie. Without this, an active session gets a new Set-Cookie on literally
# every request - correct, but wasteful, and it makes the cookie a busier
# target than it needs to be. A third of the idle window is a reasonable
# balance: refreshed a few times within any idle window, never every request.
_REFRESH_SLACK = timedelta(minutes=max(1, IDLE_MINUTES // 3))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
@@ -121,30 +137,7 @@ def is_project_admin(user: "models.User") -> bool:
# 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"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
"/api/health",
@@ -184,23 +177,31 @@ def _load_secret() -> str:
SECRET_KEY = _load_secret()
# ── password hashing ──────────────────────────────────────────────────────────
def hash_password(plain: str) -> str:
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
if not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
except (ValueError, TypeError):
return False
# ── tokens ──────────────────────────────────────────────────────────────────
def _parse_claim_dt(v) -> Optional[datetime]:
"""`login_at` is a custom claim, so unlike `exp`/`iat` (which PyJWT
special-cases for a datetime -> POSIX-timestamp conversion on encode) it
is stored and read back as a plain numeric timestamp. Returns None for
anything unparseable rather than raising - a malformed or legacy token
should fail closed into "no refresh", not 500."""
if v is None:
return None
try:
return datetime.fromtimestamp(float(v), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
def _next_exp(login_at: datetime, now: datetime) -> datetime:
"""Whichever comes first: another IDLE_MINUTES of quiet from now, or the
absolute SESSION_HOURS ceiling measured from the session's original
sign-in. Shared by create_token and maybe_refresh_token so the two can't
drift apart."""
ceiling = login_at + timedelta(hours=SESSION_HOURS)
idle_edge = now + timedelta(minutes=IDLE_MINUTES)
return min(ceiling, idle_edge)
def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc)
payload = {
@@ -209,7 +210,48 @@ def create_token(user: "models.User") -> str:
"role": user.role,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(hours=SESSION_HOURS),
"login_at": now.timestamp(),
"exp": _next_exp(now, now),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def maybe_refresh_token(claims: dict) -> Optional[str]:
"""Given a validated token's claims, return a reissued token if the
session is worth extending, or None if nothing should change. Called from
`auth_gate` on every authenticated request (D18) - deliberately reads only
the already-validated claims, never the database, so it costs nothing
beyond the JWT encode itself. The separate is_active/token_version check
in get_current_user is unaffected either way.
Three ways this returns None: past the absolute ceiling (session is done,
full re-login required - never extended, not even by a second); the
claims can't be parsed (fails closed into no-refresh rather than guessing);
or a refresh happened recently enough that a new cookie isn't worth
writing yet (_REFRESH_SLACK)."""
now = datetime.now(timezone.utc)
login_at = _parse_claim_dt(claims.get("login_at"))
if login_at is None:
# Pre-D18 token (no login_at claim) - fall back to iat so it still
# gets a real ceiling instead of riding on the old flat exp forever.
login_at = _parse_claim_dt(claims.get("iat"))
if login_at is None:
return None
ceiling = login_at + timedelta(hours=SESSION_HOURS)
if now >= ceiling:
return None
new_exp = _next_exp(login_at, now)
current_exp = _parse_claim_dt(claims.get("exp"))
if current_exp is not None and (new_exp - current_exp) < _REFRESH_SLACK:
return None
payload = {
"sub": claims.get("sub"),
"username": claims.get("username"),
"role": claims.get("role"),
"ver": claims.get("ver", 0),
"iat": now,
"login_at": login_at.timestamp(),
"exp": new_exp,
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
@@ -221,39 +263,15 @@ def decode_token(token: str) -> Optional[dict]:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# A password-reset token must never be usable as a session cookie.
# No route issues a `typ`-carrying token anymore (that was the password-reset
# token, removed in T10.4), but a session token still must never validate as
# one — kept as a defensive check, cheap insurance against a future token type
# riding the same cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,

View File

@@ -1,24 +1,28 @@
"""Command-line user management for the Work Package Suite.
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
existing admin, so you have to bootstrap one here), and for occasional account
There is no local password (D15) and no local account creation from here anymore
(D16, T10.4) — accounts are created by signing in through Okta, which JIT-
provisions a row at the lowest-privilege role (see server/okta_auth.py,
server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower
now: change the role on an account that already exists, and do routine account
maintenance from a shell on the server.
That narrower job is still how the very first admin gets named (D16): have that
person sign in through Okta once — they land as project_user — then promote them
from here. Promoting an existing row, rather than creating one blind, matters
because it never has to guess the exact string Okta will send as the identity
claim; a hand-typed username that doesn't match it exactly would just produce a
second, orphaned account instead of the one you meant to promote.
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does:
python -m server.manage_users create-admin alice --name "Alice Smith"
python -m server.manage_users create bob --role user --name "Bob Jones"
python -m server.manage_users promote alice --role admin
python -m server.manage_users list
python -m server.manage_users reset-password alice
python -m server.manage_users disable bob
python -m server.manage_users enable bob
If --password is omitted you'll be prompted (input is hidden). Passwords must be
at least 8 characters.
"""
import argparse
import getpass
import sys
import uuid
@@ -26,53 +30,48 @@ from .db import SessionLocal, Base, engine
from . import models, auth
def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
if not pw:
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
def cmd_promote(args) -> None:
role = args.role
# 'user' is the pre-roles spelling of 'project_user', accepted here so a
# documented one-liner from before this rework keeps working.
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):
sys.exit(f"A user named '{args.username}' already exists.")
u = models.User(
id=_gen_id(),
username=args.username.strip(),
full_name=(args.name or "").strip(),
email=(args.email or "").strip(),
password_hash=auth.hash_password(pw),
role=role,
)
db.add(u)
u = auth.find_user(db, args.username)
if not u:
sys.exit(
f"No user named '{args.username}'. This promotes an existing account, it "
f"doesn't create one — they need to sign in through Okta at least once first."
)
old_role = u.role
u.role = role
# Audited the same way a role change from the web Admin Console already is
# (server/app.py's set_user_role() -> log_event(), action "role_changed") —
# this command changes the same field and previously left no record of who
# ran it or what it changed (T10.10). "actor" can't name a real person here:
# a container shell exec carries no signed-in identity to attribute it to,
# so it's tagged as the tool itself rather than guessing. "via" mirrors JIT
# provisioning's own tag on user_created events.
db.add(models.AuditLog(
id=f"ev_{uuid.uuid4().hex[:12]}",
actor="cli:manage_users",
action="role_changed",
entity_type="user",
entity_id=u.id,
summary=u.username,
detail={"from": old_role, "to": role, "via": "cli"},
))
db.commit()
print(f"Created {role}: {u.username} (id={u.id})")
print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
def cmd_list(args) -> None:
with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all()
if not rows:
print("No users yet. Create one with: create-admin <username>")
print("No users yet. Accounts appear here once someone signs in through Okta.")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows:
@@ -80,17 +79,6 @@ def cmd_list(args) -> None:
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:
sys.exit(f"No user named '{args.username}'.")
u.password_hash = auth.hash_password(pw)
db.commit()
print(f"Password reset for {u.username}.")
def _set_active(username: str, active: bool) -> None:
with SessionLocal() as db:
u = auth.find_user(db, username)
@@ -108,39 +96,23 @@ def main() -> None:
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
sub = p.add_subparsers(dest="cmd", required=True)
def add_create(name, help_):
sp = sub.add_parser(name, help=help_)
sp.add_argument("username")
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
sp.add_argument("--name", default="", help="full name")
sp.add_argument("--email", default="")
return sp
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
pr.add_argument("username")
pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts")
rp = sub.add_parser("reset-password", help="reset a user's password")
rp.add_argument("username")
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username")
args = p.parse_args()
if args.cmd == "create-admin":
cmd_create(args, role="admin")
elif args.cmd == "create":
cmd_create(args)
if args.cmd == "promote":
cmd_promote(args)
elif args.cmd == "list":
cmd_list(args)
elif args.cmd == "reset-password":
cmd_reset_password(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":

View File

@@ -142,8 +142,10 @@ class WorkPackage(Base):
class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with.
"""A login account. No password is stored here or anywhere else — identity is
confirmed by Okta (OIDC), this app only decides what the account may do once
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
`username` is what Okta's identity claim resolves to.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
@@ -159,7 +161,6 @@ class User(Base):
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
@@ -336,6 +337,45 @@ class AuditLog(Base):
}
class UsageEvent(Base):
"""CR-019: append-only record of who used the suite, when, and which tool —
navigation/session activity, not business mutations. Deliberately a SEPARATE
table from AuditLog rather than a new `action` value there: AuditLog answers
"who changed what" and is read by people auditing a specific record's
history; mixing in a `page_open` row for every authenticated page load
would make that trail noisy for its existing purpose. This table answers a
different question — "who is active, and on what" — and CR-019's admin
console reads from here, not from AuditLog.
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
is later removed should still show up in historical activity rather than
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
`is_active=False` — it never deletes a row — so this is defensive symmetry
rather than a live concern today.
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
nothing purges rows written here; that is a deliberate product decision,
not an oversight to fix later."""
__tablename__ = "usage_events"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
username: Mapped[str] = mapped_column(String(200), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
# creator | wizard | field_view | dashboard | admin | directory | ...
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
# page_open | login
event: Mapped[str] = mapped_column(String(40), default="", index=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "username": self.username,
"project_id": self.project_id, "tool": self.tool, "event": self.event,
"detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like

View File

@@ -7,11 +7,14 @@ and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP
number and a deep link, not the work-package contents.
background task. Notification bodies carry customer CONTEXT — the WP number, its
title, where the work happens — and a deep link, never customer document CONTENT
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
the summary of everything a body leaves out.
"""
import os
import smtplib
import socket
import uuid
import logging
from email.message import EmailMessage
@@ -107,7 +110,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv:
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
# is slow or unreachable - sends are sequential background tasks, so a batch
# of notifications trickled out one per five seconds. gethostname() never
# touches the network. Found 2026-08-20 when the office link dropped.
with smtplib.SMTP(host, port, timeout=15,
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
if s.get("smtp_use_tls", True):
srv.starttls()
if user:

109
server/okta_auth.py Normal file
View File

@@ -0,0 +1,109 @@
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
This module owns the conversation with Okta and nothing else: it does not issue this
app's own session cookie, does not touch the database, and does not decide who may sign
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
account on every request. Only how a person's identity gets confirmed changes: an Okta
authorization-code flow, in place of the local username/password check.
Access gating is Okta's job, not this module's. Only accounts assigned to this app
integration in Okta ever complete the flow at all, so there is no required-group or
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
difference from D13's design, not an oversight.
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
with any of the four settings below missing, `is_configured()` is False and `describe()`
says so in the startup log, rather than the app discovering it later at the login button.
"""
import logging
import os
from typing import Optional
log = logging.getLogger("wpsuite.okta")
try:
from authlib.integrations.starlette_client import OAuth
HAVE_AUTHLIB = True
except ImportError: # pragma: no cover
HAVE_AUTHLIB = False
OAuth = None # type: ignore[assignment]
# ── configuration ─────────────────────────────────────────────────────────────
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
# hand-entered except this base URL, the client credentials, and our own callback.
ISSUER = os.getenv("OKTA_ISSUER", "")
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
# e.g. https://wp.controls.dev/api/auth/okta/callback
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
# why access gating and permissions both stay out of the token.
SCOPES = "openid profile email"
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
# matching against the local `users` table (T10.3). Not yet confirmed by security —
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
# documented placeholder, NOT a verified answer. Override via env once security replies
# so the real value can drop in without a code change.
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
def is_configured() -> bool:
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
network — that would be a `selftest()`, added when T10.2 needs one."""
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
def describe() -> str:
"""One line for the startup log, matching the LDAPS module's discipline: an
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
the login button."""
from . import okta_fake
if okta_fake.is_active():
return (f"*** FAKE OKTA PROVIDER ACTIVE — identities come from "
f"{okta_fake.ENV_VAR}, NOT from Okta. Tests only. ***")
if not HAVE_AUTHLIB:
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
missing = [name for name, val in (
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
) if not val]
if missing:
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
f"identity claim {IDENTITY_CLAIM!r}")
def _build_oauth():
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
routes) can fail loudly instead of Authlib raising deep inside a request.
Checked before `is_configured()`: the fake (T10.7) needs none of the four real
Okta settings, and must win whenever it is legitimately active so a test run
never has to also fill in placeholder OKTA_ISSUER/CLIENT_ID/etc. `is_active()`
already refuses outside a SQLite-backed test database — see okta_fake.py."""
from . import okta_fake
if okta_fake.is_active():
log.warning("*** FAKE OKTA PROVIDER ACTIVE (%s) — tests only ***", okta_fake.ENV_VAR)
return okta_fake.build()
if not is_configured():
return None
oauth = OAuth()
oauth.register(
name="okta",
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": SCOPES},
)
return oauth
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
oauth = _build_oauth()

190
server/okta_fake.py Normal file
View File

@@ -0,0 +1,190 @@
"""A fake Okta, for tests only — T10.7.
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
endpoint and exchanges the code with Okta's token endpoint over the network
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
cannot reach any of that: there is no live Okta tenant in CI, and the browser
checks launch the app as a SUBPROCESS (`start_server` in tests/browser_check.py),
so a monkeypatch in the test process would never reach the code doing the
authenticating. The seam has to be configurable from the ENVIRONMENT — same
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
and the token exchange — with no network call anywhere:
{"root": {"email": "root@example.test", "name": "Root Person"}}
The key is the identity value a real Okta ID token would carry in whichever
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
name is actually configured rather than a hard-coded one.
WHAT THIS DOES AND DOES NOT REPLACE
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
third-party library, not this app's security logic. Everything this app itself
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
check, JIT provisioning, and which claim carries identity. A fake run exercises
the actual code for all of that, not a re-implementation of it — the same
boundary `ldap_fake.py` drew around the anonymous-bind guard.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that lets anyone "sign in" as any identity by visiting a
picker page is exactly the kind of thing that must never be reachable outside a
test process — D16 leaves no local password fallback and no break-glass, so a
fake provider silently active in production would be a total authentication
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
database is configured, using the same test `auth._load_secret` and
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
production, full stop. `okta_auth.describe()` also shouts when the fake is
live, and `app.py` registers the picker/consent routes only when the fake is
active at import time — in production they do not exist, not merely refuse.
"""
import json
import logging
import os
import secrets
import time
from html import escape
from typing import Optional
from urllib.parse import quote
from starlette.responses import RedirectResponse
log = logging.getLogger("wpsuite.okta.fake")
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
# One-time authorization codes, in-process only. The login and the callback that
# redeems the code both happen inside the SAME uvicorn process within one test
# run, so this needs no more durability than that — the server restart every
# check does between runs clears it for free. Not a cache: entries are popped on
# first use (below) and expire on their own otherwise.
_CODE_TTL_SECONDS = 120
_PENDING_CODES: dict = {}
def _raw() -> str:
return os.getenv(ENV_VAR, "").strip()
def is_active() -> bool:
"""Whether the fake should answer. False in anything resembling production."""
if not _raw():
return False
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
# imported from okta_auth (and app.py), which must stay importable on its own.
from .db import DATABASE_URL
if not str(DATABASE_URL).startswith("sqlite"):
log.error(
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
"the fake Okta provider — this looks like production, and D16 leaves no "
"other way in, so honouring it would be an authentication bypass. "
"Unset %s.", ENV_VAR, ENV_VAR)
return False
return True
def directory() -> dict:
try:
data = json.loads(_raw())
if not isinstance(data, dict):
raise ValueError("top level must be an object")
return data
except Exception as exc: # noqa: BLE001 — a malformed fake must not look auth-shaped
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def new_code(claims: dict) -> str:
code = secrets.token_urlsafe(24)
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
return code
def consume_code(code: str) -> Optional[dict]:
"""Pop and return the claims for a code, or None if unknown/expired/reused.
Popping makes the code single-use, matching a real authorization code."""
entry = _PENDING_CODES.pop(code, None)
if not entry or entry["expires"] < time.time():
return None
return entry["claims"]
def picker_page(state: str, redirect_uri: str) -> str:
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
through a real page rather than skip the round trip with a minted cookie.
Deliberately plain: nothing here is styled to resemble a real Okta page."""
from . import okta_auth
rows = []
for username in directory():
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
rows.append(
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
f'Continue as {escape(username)}</a></li>')
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
deny_href = escape(deny_href, quote=True)
return (
"<!doctype html><title>FAKE Okta — tests only</title>"
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
"<ul>" + "".join(rows) + "</ul>"
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
)
class _FakeOktaClient:
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
calls, the same async signatures, zero network calls."""
async def authorize_redirect(self, request, redirect_uri):
state = secrets.token_urlsafe(24)
# The only session write this fake makes. authorize_access_token below is
# the only read — mirrors exactly what real Authlib does with `state`,
# which is what T10.2's missing-SessionMiddleware bug was about: this
# round trip is a genuine test of the same plumbing.
request.session["_okta_fake_state"] = state
target = redirect_uri or "/api/auth/okta/callback"
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
f"&redirect_uri={quote(target, safe='')}")
return RedirectResponse(url=url, status_code=302)
async def authorize_access_token(self, request):
from authlib.integrations.base_client import OAuthError
expected = request.session.pop("_okta_fake_state", None)
given = request.query_params.get("state", "")
if not expected or given != expected:
raise OAuthError(
error="invalid_state",
description="fake Okta: state did not match the session (T10.7 seam)")
err = request.query_params.get("error")
if err:
raise OAuthError(
error=err,
description=request.query_params.get("error_description", "denied"))
code = request.query_params.get("code", "")
claims = consume_code(code)
if claims is None:
raise OAuthError(error="invalid_grant",
description="fake Okta: unknown or expired code")
return {"userinfo": claims}
class FakeOAuth:
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
registered client as an attribute by name; `app.py` only ever touches
`.okta`, so that is the only attribute this needs."""
def __init__(self):
self.okta = _FakeOktaClient()
def build() -> "FakeOAuth":
return FakeOAuth()

View File

@@ -9,8 +9,17 @@ gunicorn==26.0.0
sqlalchemy==2.0.51
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
# Chosen over pyodbc because it ships self-contained wheels —
# pyodbc would also need msodbcsql18 + unixODBC installed in
# the image. To use pyodbc instead, add it here, install the
# Microsoft ODBC driver in the Dockerfile, and switch
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
itsdangerous==2.2.0 # signs the OAuth-state cookie SessionMiddleware sets — required by
# Authlib's authorize_redirect/authorize_access_token, not optional

View File

@@ -9,15 +9,17 @@ and to have data to inspect.
Every /api/ route except /api/health requires a session, so this signs in first and
keeps the session cookie for the rest of the run — the same way server/smoketest.py
does, reusing its opener rather than growing a second implementation of it.
Credentials come from the environment so the password never has to appear in a
command line or shell history:
does, reusing its opener AND its session-minting (not a second implementation).
There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
AUTHENTICATION section for why and what that means: this needs to run where it can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
and the account must already exist (this seeds a project, not a user).
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD
…or pass --user / --password. Use an admin account: seeding creates a project, and
--clean deletes one, which needs Project Admin on it.
…or pass --user. Use an admin account: seeding creates a project, and --clean
deletes one, which needs Project Admin on it.
USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure
@@ -48,16 +50,19 @@ import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# So `from server import auth, models` / `from server.db import SessionLocal` also
# resolve (needed to mint a session — see AUTHENTICATION above).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# The session handling is smoketest.py's, imported rather than copied: one cookie
# jar implementation, one login flow, one place to fix. Importing is safe — that
# module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener # noqa: E402
# jar implementation, one session-minting flow, one place to fix. Importing is
# safe — that module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener, seed_session_cookie # noqa: E402
BASE = ""
CTX = None
# Carries the cookie jar holding the session issued by /api/auth/login. This
# script used to call urllib.request.urlopen() directly, which has no cookie
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
# This script used to call urllib.request.urlopen() directly, which has no cookie
# support, so the session was dropped and every data route answered 401 (S13).
OPENER = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
@@ -116,29 +121,22 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
"Use an admin account.")
ap.add_argument("--password",
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
"preferred, so it stays out of shell history)")
help="existing account to sign in as (default: $WP_SEED_USER, then "
"$WP_SMOKE_USER). Use an admin account.")
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)
if not args.user or not args.password:
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
("WP_SEED_PASSWORD", args.password)) if not v)
if not args.user:
return abort(
f"no credentials — {missing} not set.",
"no account — $WP_SEED_USER not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n"
" this can seed without one. Set them and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n"
" export WP_SEED_PASSWORD='…'\n\n"
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
" too, so one set of credentials serves this and smoketest.py.")
" this can seed without one. Set it and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n\n"
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
" this and smoketest.py.")
# health gate
try:
@@ -148,18 +146,25 @@ def main():
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
# 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 a\n"
" few consecutive failures, so retrying with the wrong password makes this\n"
" worse. 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)
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
# into OPENER's jar, so it rides every request after this one.
try:
from server import auth as srv_auth
from server.db import SessionLocal
except ImportError as e:
return abort(f"cannot import the server package to mint a session: {e}",
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
with SessionLocal() as db:
user = srv_auth.find_user(db, args.user)
if not user:
return abort(f"no account named '{args.user}'.",
" This signs in as an existing account, it doesn't create one — sign in\n"
" through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True
print(f"Signed in as {args.user}.")

View File

@@ -6,16 +6,23 @@ 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:
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
and Okta requires a real browser to complete, which this stdlib script cannot
do. So instead of signing in over HTTP the way the front end does, this script
mints a session the same way server/app.py's okta_callback() does after Okta
hands back an identity: auth.create_token() for an existing account, seeded
straight into the cookie jar. That means it needs to run somewhere that can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
test — inside the api container, or locally against your dev DB. It can no
longer sign in to an arbitrary remote URL from an unrelated workstation; if
the target is remote, run it on that host or inside that container instead.
export WP_SMOKE_USER=smoketest
export WP_SMOKE_PASSWORD='…'
python3 server/smoketest.py https://wp-suite.company.local
…or pass --user / --password explicitly.
…or pass --user explicitly. The account must already exist — sign it in
through Okta once first (or create it from the admin console) if it doesn't;
this script promotes no one and provisions nothing.
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);
@@ -24,26 +31,29 @@ AUTHENTICATION
discover it in the cleanup step.
USAGE
# Against the deployed site (through the NGINX proxy):
python3 server/smoketest.py https://wp-suite.company.local
# Self-signed / internal TLS cert? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly):
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
# FastAPI directly):
docker compose exec -e WP_SMOKE_USER api \
python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
python3 server/smoketest.py http://localhost:8000
# Self-signed / internal TLS cert on the HTTP side? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Leave the demo project in the database so you can open it in the UI:
python3 server/smoketest.py https://wp-suite.company.local --keep
python3 server/smoketest.py http://localhost:8000 --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
could not start (unreachable host, missing or rejected credentials). 2 is kept
distinct on purpose: "I could not test this" is not the same answer as "this is
broken", and conflating them is what made an unauthenticated version of this
script report a wall of failures against a perfectly healthy stack.
could not start (unreachable host, missing credentials, or no account by that
username). 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
@@ -53,6 +63,12 @@ import ssl
import sys
import urllib.error
import urllib.request
from urllib.parse import urlparse
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
# when this file is run directly (`python3 server/smoketest.py`) rather than as
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], []
@@ -66,19 +82,40 @@ 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.
# One opener for the whole run, carrying the cookie jar that holds the session.
# 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
COOKIE_JAR = None
def build_opener(ctx=None):
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
global COOKIE_JAR
COOKIE_JAR = http.cookiejar.CookieJar()
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers)
def seed_session_cookie(token: str, base: str) -> None:
"""Put a minted session into the jar directly, the same shape a Set-Cookie
response from the old /api/auth/login would have produced — so the logout
check below (which relies on the jar honoring logout()'s Set-Cookie that
expires it) keeps working unchanged. `base` is explicit rather than read off
this module's own BASE global, so seed_demo.py (which imports this function
but has its own BASE) seeds the cookie for the host it's actually targeting."""
host = urlparse(base).hostname or "localhost"
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
version=0, name="wp_session", value=token,
port=None, port_specified=False,
domain=host, domain_specified=True, domain_initial_dot=False,
path="/", path_specified=True,
secure=False, expires=None, discard=True,
comment=None, comment_url=None, rest={"HttpOnly": None},
))
def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path
@@ -116,10 +153,7 @@ def main():
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)")
help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
@@ -128,17 +162,14 @@ def main():
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)
# Refuse to start without a username rather than running headlong into 401s.
if not args.user:
return abort(
f"no credentials — {missing} not set.",
"no account — $WP_SMOKE_USER 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"
" meaningful to test without one. Set it and re-run:\n\n"
" export WP_SMOKE_USER=<admin-account>\n\n"
" Or pass --user. 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
@@ -157,21 +188,29 @@ def main():
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}")
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
# every request after this one.
st, body = call("POST", "/api/auth/login",
{"username": args.user, "password": args.password})
if st != 200:
detail = body.get("detail") if isinstance(body, dict) else body
hint = (" The account may be locked: the API locks an account for a while after\n"
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
" so re-running with the wrong password makes this worse, not better.\n"
" Check the password, then wait out the lockout window."
if st in (401, 403, 423, 429) else
" Unexpected status from the login endpoint — check the API logs.")
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
# it into OPENER's jar, so it rides every request after this one exactly the
# way a real Set-Cookie response would have.
try:
from server import auth as srv_auth
from server.db import SessionLocal
except ImportError as e:
return abort(f"cannot import the server package to mint a session: {e}",
" This script now needs to run where server/ is importable and\n"
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
" AUTHENTICATION above.")
with SessionLocal() as db:
user = srv_auth.find_user(db, args.user)
if not user:
return abort(f"no account named '{args.user}'.",
" This script signs in as an existing account, it doesn't create one —\n"
" sign in through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True
check("login issues a session", st == 200)
check("session cookie seeded", bool(token))
# 3) Prove the session actually travels — this is the check whose absence let
# an unauthenticated version of this script look like a broken stack.

View File

@@ -155,6 +155,18 @@ def main():
str(shown) == str(as_root["total"]),
"shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"]))
chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown)
# D12: the productivity factor card, computed from the SAME server
# sums as its neighbours. Both hour fields are optional (CR-017),
# so the expected value is derived, not hardcoded: a real quotient
# when both sums exist, an em dash when either is zero.
pf_shown = page.eval(
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
".find(x=>/Productivity/i.test(x.textContent));"
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
est, act = as_root.get("est_hours") or 0, as_root.get("actual_hours") or 0
pf_want = ("%.2f" % (act / est)) if est > 0 and act > 0 else "—"
chk("the D12 productivity card shows actual/estimated from the server sums",
pf_shown == pf_want, "shown=%r want=%r (est=%r act=%r)" % (pf_shown, pf_want, est, act))
print("\n3. a failed aggregate request is an error, not a zero")
page.eval("""(() => {

181
tests/archived_check.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Can a project admin get back into an archived project — and only they? — D7, T9.8.
Archiving read as deletion because there was no way back in. Now: a separate,
labelled, read-only list on the launcher for project admins; the server filters
the answer by per-project role, refuses every write regardless of what the
browser sends, and shows archived projects to nobody else anywhere - counts and
pickers included.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
from qa_gate_check import api # noqa: E402
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def archive_projB(db_path):
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
proj = db.get(models.Project, "projB")
proj.archived_at = models.utcnow()
db.commit()
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-arch-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
archive_projB(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root, bob, pat = tok["root"], tok["bob"], tok["pat"]
# ── 1. who sees what ──────────────────────────────────────────────────
print("\n1. visibility, by role")
_, rows = api(base, "/api/projects", root)
chk("the default list hides archived projects from EVERYONE, admin included",
all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows]))
_, rows = api(base, "/api/projects?archived=only", root)
chk("an admin asking for the archived list gets it",
[p["id"] for p in rows] == ["projB"], ascii_(rows))
_, rows = api(base, "/api/projects?archived=only", bob)
chk("a plain project user ON that project gets an empty list - no leak",
rows == [], ascii_(rows))
_, rows = api(base, "/api/projects?archived=all", bob)
chk("...and cannot smuggle it through archived=all either",
all(p["id"] != "projB" for p in rows), ascii_(rows))
_, rows = api(base, "/api/projects?archived=only", pat)
chk("a user with no access to it sees nothing, same as before",
rows == [], ascii_(rows))
# ── 2. the server refuses writes regardless of the browser ───────────
print("\n2. frozen means frozen")
code, out = api(base, "/api/wps", root, "POST", {
"id": "wpArch1", "project_id": "projB", "number": "AR-1",
"subject": "write into the archive", "status": "Draft",
"data": {"constraints": []}})
chk("a direct write to an archived project is refused, even for an admin",
code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out)))
code, _ = api(base, "/api/projects/projB/materials", root, "POST",
{"description": "Sample sneak", "unit": "EA"})
chk("...and so is every other write route (material list)", code in (403, 409), code)
code, wps = api(base, "/api/wps?project_id=projB", root)
chk("reading it still works - archived is readable, not gone",
code == 200, code)
# ── 3. the launcher, both roles, at 390px ─────────────────────────────
print("\n3. the launcher")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(390, 844, mobile=True)
page.goto(base + "/index.html")
dismiss_dialogs(page)
settle(2.5)
sec = json.loads(page.eval("""JSON.stringify((() => {
const s = document.getElementById('archived-projects');
return {hidden: !s || s.hidden,
text: s ? s.textContent : '',
buttons: s ? s.querySelectorAll('button').length : 0};
})())"""))
chk("a project admin sees the archived list, separate and labelled",
not sec["hidden"] and "Archived projects" in sec["text"]
and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec))
chk("...and it fits at 390px", page.eval(
"document.getElementById('archived-projects').scrollWidth <= 392"))
page.eval("document.querySelector('[data-open-archived]').click()")
settle(2.0)
chk("opening one makes it the active project",
page.eval("(ProjectData.getActive()||{}).id") == "projB")
# the creator's read-only courtesy on top of the server's rule
page.goto(base + "/wp-creation-index.html?project=projB")
dismiss_dialogs(page)
settle(2.5)
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
chk("the creator says ARCHIVED where the project is named",
"ARCHIVED" in page.eval(
"(document.getElementById('ctx-bar')||{textContent:''}).textContent"))
page.eval("document.getElementById('wp_subject').value='x'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
n0 = page.eval("savedPackages.length")
page.eval("void savePackage(false)")
settle(0.8)
chk("saving is refused with a reason, before the round trip",
page.eval("savedPackages.length") == n0
and "archived" in page.eval(
"(document.getElementById('toast')||{textContent:''}).textContent").lower())
# a NON-admin's launcher shows no archived section at all
page.clear_cookies()
page.set_cookie("wp_session", bob)
page.goto(base + "/index.html")
dismiss_dialogs(page)
settle(2.5)
chk("a non-admin's launcher never shows the section",
page.eval("(() => { const s=document.getElementById('archived-projects');"
" return !s || s.hidden; })()"))
# projB has no SOP, and GET /api/sops/latest answering 404 for it is the
# correct answer, not an error - the seed fixture documents exactly this
# false alarm.
js_errors = [e for e in page.js_errors()
if "beforeunload" not in e and "sops/latest" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

263
tests/assets_check.py Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Is the Micron asset picker read-only, and does it degrade to manual entry? — D11.
Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to
the R2 creator (decisions-2026-08-20.md). The properties this pins:
* **Read-only, structurally.** assets_db.py holds one SELECT and nothing else;
/api/assets has no writing verb. Picking an asset can never change Micron.
* **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false,
the picker says so, and manual entry carries the package. The suite must run
without Micron existing at all — every other probe implicitly relies on that.
* **Broken is not a leak.** A configured-but-unusable URL 503s with a message
that never echoes the connection string (whose parse errors can quote
password fragments).
* **The client honours the catalog.** Search ranks exact matches first, a
picked row is locked to the DB's own casing and badged, imports canonicalise
casing / fall back to manual / skip duplicates, and the import summary goes
through the T7.9 dialog kit, not a native alert().
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not
back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
from qa_gate_check import api # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
SERVER = os.path.join(ROOT, "server")
def ascii_(v, n=240):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def strip_py(src):
src = re.sub(r'""".*?"""', "", src, flags=re.S)
return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n"))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. read-only, structurally ─────────────────────────────────────────────
print("\n1. read-only, structurally")
src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read())
verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b",
src, re.I)
chk("assets_db.py contains no writing SQL verb", not verbs, verbs)
chk("...and exactly one SELECT (the whole schema contract)",
len(re.findall(r"\bSELECT\b", src, re.I)) == 1)
app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read()
chk("/api/assets is a GET and only a GET",
len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1
and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src))
outside = [f for f in ("models.py", "auth.py", "notify.py")
if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()]
chk("the connection string is env-only plumbing, not model or auth state",
not outside, outside)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
# ── 2. the API's unconfigured state ────────────────────────────────────
print("\n2. unconfigured is a first-class state")
st, _ = api(base, "/api/assets", "not-a-session")
chk("anonymous gets 401, same as every other /api/ path", st == 401, st)
st, body = api(base, "/api/assets", tok["root"])
chk("signed in, no MICRON_DB_URL: 200 with configured:false",
st == 200 and body and body.get("configured") is False
and body.get("assets") == [], ascii_(body))
chk("...and the detail tells the user what to do instead",
"manual" in (body.get("detail") or "").lower(), ascii_(body))
# ── 3. the picker, catalog absent ──────────────────────────────────────
print("\n3. the picker degrades to manual entry")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
time.sleep(1.2)
chk("the search box is disabled and says the catalog is not configured",
page.eval("(() => { const b=document.getElementById('asset-search');"
" return b.disabled && /not configured/i.test(b.placeholder); })()"))
chk("the source note announces it (role=status, non-empty)",
page.eval("(() => { const n=document.getElementById('asset-source-note');"
" return n.getAttribute('role')==='status' && n.textContent.length>0; })()"))
chk("no assets yet: the empty state renders instead of a blank table",
page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)"))
page.eval("addManualAsset()")
chk("+ Add asset adds an editable manual row",
page.eval("pkgAssets.length") == 1
and page.eval("pkgAssets[0].source") == "manual"
and page.eval("!!document.querySelector('#asset-body input')"))
page.eval("document.querySelector('#asset-body input').value='HAND-01';"
"document.querySelector('#asset-body input')"
".dispatchEvent(new Event('input',{bubbles:true}))")
chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01")
# ── 4. the client honours the catalog (injected; no SQL Server here) ──
print("\n4. search, pick, import — against an injected catalog")
page.eval("pkgAssets=[]; buildAssets();"
"assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];"
"assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));"
"assetCatalogState='ready';"
"(() => { const b=document.getElementById('asset-search');"
" b.disabled=false; b.placeholder='Search asset IDs'; })()")
page.eval("runAssetSearch('pump-01')")
chk("an exact match outranks a longer contains-match",
page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]',
ascii_(page.eval("JSON.stringify(assetResults)")))
chk("results render as real <button>s, none disabled yet",
page.eval("(() => { const r=[...document.querySelectorAll('#asset-results button.asset-result')];"
" return r.length===2 && r.every(b=>!b.disabled); })()"))
page.eval("addCatalogAsset(0)")
chk("the pick is announced (role=status toast) - a keyboard pick is otherwise silent",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Added PUMP-01/.test(t.textContent); })()"))
chk("picking adds a catalog row: locked ID (no input), badge, source:'catalog'",
page.eval("pkgAssets.length") == 1
and page.eval("pkgAssets[0].source") == "catalog"
and page.eval("(() => { const tr=document.querySelector('#asset-body tr');"
" return !!tr.querySelector('.asset-badge')"
" && !tr.cells[0].querySelector('input'); })()"))
n0 = page.eval("pkgAssets.length")
page.eval("addCatalogAsset(0)")
chk("picking it again is refused (already on the package)",
page.eval("pkgAssets.length") == n0)
chk("normaliseAsset: no source means manual; an unknown source means manual",
page.eval("normaliseAsset({tag:'X'}).source") == "manual"
and page.eval("normaliseAsset({tag:'X',source:'evil'}).source") == "manual"
and page.eval("normaliseAsset({tag:'X',source:'catalog'}).source") == "catalog")
page.eval("void applyImportedAssets([['asset id'],['ahu-2p-015'],['NOT-IN-DB'],['AHU-2P-015']])")
time.sleep(0.4)
got = json.loads(page.eval(
"JSON.stringify(pkgAssets.map(a=>({t:a.tag,s:a.source})))"))
chk("import: a hit is canonicalised to the DB's own casing and badged catalog",
{"t": "AHU-2P-015", "s": "catalog"} in got, ascii_(got))
chk("...a miss is kept, visibly manual — not silently dropped",
{"t": "NOT-IN-DB", "s": "manual"} in got, ascii_(got))
chk("...the in-file duplicate is skipped (3 rows total: pick + hit + miss)",
len(got) == 3, ascii_(got))
chk("...and the summary is the T7.9 dialog, not a native alert()",
page.eval("document.getElementById('wp-dialog').classList.contains('open')")
and page.eval("document.getElementById('wp-dialog-cancel').style.display") == "none")
page.eval("wpDialogOk()")
page.eval("(() => { const b=document.getElementById('asset-search');"
" b.value='ct-1'; runAssetSearch(b.value);"
" b.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); })()")
chk("Enter takes the first result not already on the package",
page.eval("pkgAssets[pkgAssets.length-1].tag") == "CT-100")
# removal reopens the row for re-adding
page.eval("runAssetSearch('ct-100')")
chk("a just-added result reads 'added' and is disabled",
page.eval("(() => { const b=document.querySelector('#asset-results button');"
" return b.disabled && /added/.test(b.textContent); })()"))
page.eval("removeAsset(pkgAssets.length-1)")
chk("removing the asset makes it addable again",
page.eval("(() => { const b=document.querySelector('#asset-results button');"
" return !b.disabled && /add/.test(b.textContent); })()"))
# The tier-cap regression (review finding, fixed same day): 600
# alphabetically-early contains-matches must not evict a prefix match
# that sorts after every one of them. Before the fix the scan broke at
# a COMBINED 500 and Enter added the wrong asset, ID-locked.
got = json.loads(page.eval(
"(() => { const c=[];"
" for(let i=0;i<600;i++) c.push('A'+String(i).padStart(4,'0')+'-PMP-10');"
" c.push('PMP-10-EXTRA');"
" assetCatalog=c; assetCatalogIndex=new Map(c.map(t=>[t.toLowerCase(),t]));"
" assetCatalogState='ready'; runAssetSearch('pmp-10');"
" return JSON.stringify([assetResults[0], assetResults.length]); })()"))
chk("a prefix match outranks 600 earlier contains-matches (cap is per tier)",
got[0] == "PMP-10-EXTRA" and got[1] == 500, ascii_(got))
# ── 5. configured-but-broken: a 503 that does not leak ────────────────
print("\n5. broken is not a leak")
browser.close()
browser = None
server.terminate()
server.wait(timeout=10)
os.environ["MICRON_DB_URL"] = "mssql+pymssql://user:S3CRETpw@127.0.0.1:1/MicronDB"
# A malformed tuning knob must degrade, not crash the boot (review
# finding: int() at import time made "5m" a total-outage switch).
os.environ["MICRON_ASSETS_CACHE_SECONDS"] = "5m"
try:
port2 = cdp.free_port()
base2 = "http://127.0.0.1:%d" % port2
server = start_server(port2, db_path)
chk("the suite boots with MICRON_ASSETS_CACHE_SECONDS='5m' (degrades, no crash)",
server is not None and server.poll() is None)
st, body = api(base2, "/api/assets", tok["root"])
detail = (body or {}).get("detail") or ""
chk("a configured-but-unusable catalog answers 503, not 500",
st == 503, (st, ascii_(body)))
chk("...and the message never echoes the URL, login or password",
"S3CRETpw" not in detail and "user" not in detail
and "127.0.0.1:1" not in detail, ascii_(detail))
# The negative cache (review finding): the second request inside the
# failure window must answer from the remembered error - same 503,
# same safe text - not stack another connect attempt in a worker.
st2, body2 = api(base2, "/api/assets", tok["root"])
chk("...and a second request answers the cached failure, stable and safe",
st2 == 503 and (body2 or {}).get("detail") == detail,
(st2, ascii_(body2)))
finally:
del os.environ["MICRON_DB_URL"]
del os.environ["MICRON_ASSETS_CACHE_SECONDS"]
finally:
if browser:
browser.close()
if server:
server.terminate()
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -178,6 +178,24 @@ def main():
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
browser = cdp.Browser()
page = browser.page()
# BL-012 (fixed at T9.9): admin's captured height varied ~600px between
# runs and the creator at 1440px shifted, because live timestamps and
# relative times re-render per run. Freezing Date (and Math.random) in
# every new document makes a capture comparable with the last one.
page.ws.call("Page.addScriptToEvaluateOnNewDocument", {"source": (
"(function(){"
"var FIXED = 1755600000000;" # 2026-08-19T10:40Z
"var RealDate = Date;"
"function FrozenDate(){ return new RealDate(FIXED); }"
"FrozenDate.now = function(){ return FIXED; };"
"FrozenDate.parse = RealDate.parse; FrozenDate.UTC = RealDate.UTC;"
"FrozenDate.prototype = RealDate.prototype;"
"window.Date = FrozenDate;"
"var seed = 42;"
"Math.random = function(){ seed = (seed * 9301 + 49297) % 233280;"
" return seed / 233280; };"
"})();"
)})
for name, filename, user, wait_for in pages:
capture(page, base, tok, name, filename, user, wait_for,
widths, args.out, args.label)

View File

@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
not test this" is not the same answer as "this is broken".
"""
import argparse
import json
import os
import subprocess
import sys
@@ -39,7 +40,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
PW = "CorrectHorseBattery9"
_PASS, _FAIL = [], []
@@ -86,7 +86,7 @@ def seed(db_path):
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))
role=role))
mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
@@ -109,9 +109,34 @@ def seed(db_path):
# 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.
# BL-018 (fixed at T9.9): the production shape is {sop, state}, as
# ProjectData.pushSOP writes it. The old {"governance": ...} blob was a
# shape no code path produces, and it sent four probes' creators to the
# SOP gate until each imported set_sop() to overwrite it.
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
complete=True,
data={"governance": {"disciplines": ["Mechanical", "Electrical"]}}))
data={"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
"governance": {"disciplines": ["Mechanical", "Electrical"],
"woFormat": "WP##-[TYPE]"},
"woTypes": [{"name": "Conduit Install", "enabled": True}],
"sections": {}},
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
"division": "Internal", "site": "QA Lab"},
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamMembers": [], "sections": {},
"signoffRoles": [{"role": "Superintendent", "name": ""},
{"role": "Foreman", "name": ""}],
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
"disciplines": ["Mechanical", "Electrical"],
"discMode": "choice", "instanceSuffix": "letter",
"sizeHoursMax": ""},
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
"trackingUrl": "", "commissioningUrl": ""},
"constraints": [], "sequence": [], "sources": []}}))
db.flush()
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
@@ -125,10 +150,29 @@ def seed(db_path):
for u in db.query(models.User).all()}
def start_server(port, db_path):
# T10.7. The app authenticates through Okta, which no test can reach, and
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
# reach the code doing the authenticating. server/okta_fake.py reads this
# instead, and refuses to work against a non-SQLite database.
#
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
# is driven — url_state_check's deep-link case. It is set for every server here
# anyway so that a test which starts signing in later does not fail
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
FAKE_DIRECTORY = json.dumps({
u: {"email": f"{u}@example.test", "name": u.title()}
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
})
def start_server(port, db_path, extra_env=None):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
if extra_env:
env.update(extra_env)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
@@ -411,7 +455,10 @@ def main():
finally:
if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}")
print(" Sign in as root / " + PW)
# No local password exists (D15/D16) — there's nothing to type into a login
# form. Set the session cookie directly, the same way this script's own
# fixture does, from the browser console on that origin:
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
else:
if server:
# Wait for it to actually exit before deleting the database out from

110
tests/color_check.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Is theme-light.css the only place a colour exists? — C4, T9.9.
The token rule, finally enforceable everywhere: after this sweep no colour
literal survives outside theme-light.css - not in page stylesheets, not in the
help centre's injected styles (BL-004), not in the JS-built dialogs (BL-005),
not in the print popup. One accent blue (BL-008 - the second brand blue is
gone, .sop-inherited tints with THE blue) and one warning amber (BL-009 - the
alt token is deleted). Comments are stripped first: quoting a hex while
explaining it is not declaring one (the BL-017 lesson).
The exceptions, in full: <meta name="theme-color"> (a meta attribute cannot
resolve a CSS var), and rgba() shadow/overlay alphas, which are opacity
recipes, not palette entries.
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
"""
import io
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from browser_check import chk, _PASS, _FAIL # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def strip_comments(src, is_css):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
if not is_css:
src = "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
src = re.sub(r"<!--.*?-->", "", src, flags=re.S)
return src
def main():
print("\n1. hex literals outside theme-light.css")
offenders = []
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html", ".css")) or name == "theme-light.css":
continue
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
name.endswith(".css"))
# the one exception: the browser-chrome hint, which cannot use var()
src = re.sub(r'<meta name="theme-color" content="#[0-9a-fA-F]{6}"\s*/?>', "", src)
for m in re.finditer(r"#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b", src):
offenders.append("%s: %s" % (name, m.group(0)))
chk("no hex colour literal outside theme-light.css; grep confirms",
not offenders, offenders[:8])
print("\n2. one blue, one amber")
theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read()
code = strip_comments(theme, True)
# BL-025 widened this: the rgb spelling is compared space-free, because
# rgba(37,99,214,.15) in help.js slid past the spaced grep for months.
chk("the second brand blue (#2563d6) is gone from the theme itself",
"2563d6" not in code.lower()
and "37,99,214" not in code.replace(" ", ""))
chk("the ninth amber (--wp-status-warning-text-alt) is deleted",
"--wp-status-warning-text-alt" not in code)
others = []
for name in sorted(os.listdir(HTML)):
if name == "theme-light.css" or not name.endswith((".js", ".css", ".html")):
continue
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
name.endswith(".css"))
if ("warning-text-alt" in src or "2563d6" in src.lower()
or "37,99,214" in src.replace(" ", "")):
others.append(name)
chk("...and no consumer still references either", not others, others)
print("\n3. every token consumed is a token defined")
# The bug this pins: help.js (and six other files) shipped consuming
# --cds-layer-01/-02 and --cds-border-subtle-01/-strong-01 - names the theme
# never defined (its names carry no -01 suffix). An undefined var() makes
# the whole declaration invalid, so the help centre modal, the password and
# language dialogs, and the print popup all rendered TRANSPARENT
# backgrounds. Found by the user, 2026-08-20. Definitions are collected
# from every file (page aliases are legal); consumption of a name nobody
# defines is the defect.
defined, consumed = set(), {}
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html", ".css")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
for m in re.finditer(r"(--[a-zA-Z0-9-]+)\s*:", src):
defined.add(m.group(1))
for m in re.finditer(r"setProperty\(\s*['\"](--[a-zA-Z0-9-]+)", src):
defined.add(m.group(1))
for m in re.finditer(r"var\(\s*(--[a-zA-Z0-9-]+)", src):
consumed.setdefault(m.group(1), set()).add(name)
# --wp-chart- is the creator's JS-concatenated fallback ('--wp-chart-'+k);
# the numbered names it builds are all defined, the fragment is not a name.
unresolved = ["%s (%s)" % (t, ", ".join(sorted(fs)))
for t, fs in sorted(consumed.items())
if t not in defined and t != "--wp-chart-"]
chk("no var() anywhere names a token that nothing defines",
not unresolved, unresolved[:8])
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Are the consoles' and launcher's 21 native dialogs gone? — BL-024, 2026-08-20.
S1 counted 79 native dialogs app-wide; its two tasks (T5.8 wizard, T7.9
creator) removed 58 and the audit found the remaining 21 on surfaces no S1
task named: admin.js (6), users.js (10), the launcher's inline script (5).
They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
self-injecting component (guarded so the creator's inline copy still wins on
its own page).
Static half greps the counts; browser half drives the users console's dialogs
with natives poisoned and proves they still work end to end.
Used to drive this via the password-reset prompt specifically, because it was
the one place on this page exercising wp-dialog.js's PROMPT variant (text input
+ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
removed admin password reset entirely — there is no password to reset anymore
— so that coverage moved with it. The prompt-with-validate() pattern itself is
still exercised, just not on this page: see creator_dialogs_check.py for
wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
a new prompt-style dialog, it belongs back in this file.
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from qa_gate_check import api # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=240):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def natives(name):
code = strip_js(io.open(os.path.join(HTML, name), encoding="utf-8").read())
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", code))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
print("\n1. the counts (baseline 6 + 10 + 5 = 21)")
for name in ("admin.js", "users.js", "index.html"):
chk("%s: 0 native dialogs" % name, natives(name) == 0, natives(name))
chk("wp-dialog.js exists, has the guard, and no natives of its own",
natives("wp-dialog.js") == 0
and "typeof global.wpConfirmDialog === 'function'" in
io.open(os.path.join(HTML, "wp-dialog.js"), encoding="utf-8").read())
for page in ("index.html", "admin.html", "users.html"):
chk("%s loads the kit" % page,
'src="wp-dialog.js"' in io.open(os.path.join(HTML, page), encoding="utf-8").read())
chk("the creator keeps its own copy (it owns the same-id markup in its HTML)",
"function wpConfirmDialog" in
io.open(os.path.join(HTML, "wp-creation-app.js"), encoding="utf-8").read())
tmpdir = tempfile.mkdtemp(prefix="wpsuite-condlg-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
print("\n2. the users console, natives poisoned")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/users.html")
time.sleep(2.0)
page.eval("window.alert=()=>{throw new Error('native alert reached')};"
"window.confirm=()=>{throw new Error('native confirm reached')};"
"window.prompt=()=>{throw new Error('native prompt reached')};")
chk("the console booted with a user table",
page.eval("!!document.querySelector('table')"))
print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4)
chk("the delete asks through the kit, spelling out what goes with it",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /cannot be undone/.test(document.getElementById('wp-dlg-msg').textContent); })()"))
page.eval("document.getElementById('wp-dlg-cancel').click()")
time.sleep(0.6)
st, users = api(base, "/api/auth/users", tok["root"])
chk("cancel means no: bob is still an account",
st == 200 and any(u.get("username") == "bob" for u in (users or [])),
ascii_([u.get("username") for u in (users or [])]))
errs = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors, and no path reached a native dialog (they throw here)",
not errs, ascii_(errs[:3]))
finally:
if browser:
browser.close()
if server:
server.terminate()
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Does the critical-reopen mail reach the PM and CM? — BL-021, fixed 2026-08-20.
`project_sop_team()` read `sop.data['project']`, but `pushSOP` stores every row
as `data={sop, state}` — the project block is one level deeper. The lookup
returned `[]` for every real row, so the on-hold email's recipient list was
silently reduced to assignee + distribution: the PM and CM named in
`notify_critical_reopen`'s own docstring never got it, from the day it shipped.
The fixture writes the PRODUCTION shape (nested under 'sop'), because a
hand-built flat row would have passed against the bug — which is exactly how it
went unverified this long. Sink pattern from qa_gate_check, one implementation.
Boots its own throwaway SQLite + uvicorn; run it alone, not back to back.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
import os
import re
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from qa_gate_check import SmtpSink, api, wait_for # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def set_team(pm_id, cm_id):
"""The PRODUCTION shape: data['sop']['project'], as pushSOP writes it."""
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
sop = db.get(models.Sop, "sopA")
data = json.loads(json.dumps(sop.data or {}))
proj = data.setdefault("sop", {}).setdefault("project", {})
proj["pmId"], proj["cmId"] = pm_id, cm_id
sop.data = data
db.commit()
def main():
print("\n1. the read matches the written shape (static)")
src = io.open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()
fn = src[src.index("def project_sop_team"):src.index("def project_qa_group")]
chk("project_sop_team reads the nested data['sop']['project'] first",
'.get("sop")' in fn and '.get("project")' in fn)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-reopen-")
db_path = os.path.join(tmpdir, "check.db")
server = None
sink = SmtpSink()
sink.start()
try:
tok = seed(db_path)
set_sop(db_path, {})
set_team("user_sue", "user_pat")
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root = tok["root"]
api(base, "/api/settings", root, "PUT", {
"email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
"smtp_use_tls": False, "from_addr": "suite@sink.local",
"app_base_url": base})
print("\n2. a released package, its critical constraint reopened")
body = {
"id": "wpCR1", "project_id": "projA", "number": "CR-01",
"subject": "energize MCC-4", "status": "In Progress",
"assignee_id": "user_mix",
"data": {"location": "B-100 / Level 2",
"constraints": [{"name": "Power shutdown", "status": "cleared",
"critical": True, "comment": ""}]}}
code, _ = api(base, "/api/wps", root, "POST", body)
chk("the package saves", code == 200, code)
# The creation enqueues a wp_assigned mail delivered by a background
# task; wait for it BEFORE clearing or it leaks into the reopen count.
wait_for(lambda: len(sink.messages) >= 1, 10)
code, _ = api(base, "/api/wps/wpCR1/status", root, "POST", {"status": "Issued"})
chk("...and releases (the critical constraint is cleared)", code == 200, code)
sink.messages.clear()
body["status"] = "Issued"
body["data"]["constraints"][0]["status"] = "open"
code, wp = api(base, "/api/wps", root, "POST", body)
chk("reopening the critical constraint saves through the normal upsert",
code == 200, code)
print("\n3. the mail reaches everyone the docstring promises")
chk("three messages: assignee + PM + CM (the actor is excluded)",
wait_for(lambda: len(sink.messages) == 3, 15), len(sink.messages))
rcpts = sorted(m["to"][0] for m in sink.messages)
chk("...the PM and CM are among them - THE BL-021 fix, sink-verified",
"sue@example.test" in rcpts and "pat@example.test" in rcpts, ascii_(rcpts))
chk("...and the assignee, exactly them, nobody twice",
rcpts == ["mix@example.test", "pat@example.test", "sue@example.test"],
ascii_(rcpts))
text = next((m["text"] for m in sink.messages if "On hold:" in m["data"]), "")
chk("the body names the constraint and the package, title included (CR-014)",
"Power shutdown" in text and "CR-01" in text and "energize MCC-4" in text,
ascii_(text, 300))
chk("...and where the work happens, and a deep link to THAT package",
"B-100 / Level 2" in text
and "/wp-creation-index.html?project=projA&wp=wpCR1" in text, ascii_(text, 300))
chk("...in the house convention (the footer this body alone used to lack)",
"automated message from the Work Package Suite" in text)
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=wpCR1"
"&action=constraint_reopened", root)
chk("the reopen is in the audit history", bool(ev), ascii_(ev[:1] if ev else ev))
finally:
sink.stop()
if server:
server.terminate()
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -190,7 +190,10 @@ def run(page, base, tok, db_path):
spans = len(re.findall(r"<span[^>]*onclick", code))
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
% spans)
chk("...and the only one left in this file is the dashboard's", spans == 1, spans)
# T9.5 converted the dashboard chip to a button, so the count is 0 now -
# pinned there, because a new span-with-onclick would be a C1 regression.
chk("...and none is left in this file at all (the chip became a button at T9.5)",
spans == 0, spans)
# ── 2. the rail ──────────────────────────────────────────────────────────
print("\n2. the rail is built from the cards")

View File

@@ -366,13 +366,13 @@ def measurement(page, base, tok):
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
if over["scroll"] > over["client"] + 2:
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
# out again - which is the point of pinning rather than printing.
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
"fails when it is fixed",
over["scroll"] > over["client"] + 2, over)
# CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
# cause was actually removed: the help-tip's CSS ::after escaped its badge to
# the right, and rebuilding the component (S8) with a viewport-clamped bubble
# ended the overflow. The check now asserts the FIX, so a regression reopens
# BL-001 loudly instead of quietly re-widening the page.
chk("BL-001 is closed: the creator does not overflow at 390px",
over["scroll"] <= over["client"] + 2, over)
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,

196
tests/helptip_check.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Is every help-tip reachable by keyboard and by touch? — C1 + S8, T9.5.
The badges were <span> elements whose :focus CSS was dead code (no tabindex)
and whose touch path did not exist - on tablets, Field View's surface. Now the
component upgrades every badge to a button at load, and one viewport-clamped
role=tooltip bubble serves them all. This probe drives a badge with REAL key
events and a tap at 390px, then greps the app-wide metrics the audit document
cites.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the greps the audit cites ──────────────────────────────────────────
print("\n1. the audit's grep metrics")
divspan = 0
outline_bad = []
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js", ".css")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
code = strip_js(src) if not name.endswith(".css") else re.sub(r"/\*.*?\*/", "", src, flags=re.S)
divspan += len(re.findall(r"<(div|span)[^>]*\bonclick=", code))
for m in re.finditer(r"outline:\s*none", code):
# The replacement can sit in the rule ABOVE (wp-chrome's search shell
# rings on :focus-within, and ringing input + shell would draw two),
# so the window looks both ways.
ctx = code[max(0, m.start() - 400):m.start() + 260]
tail = ctx[ctx.find("outline:") + 12:]
if ("outline" not in tail and "box-shadow" not in ctx
and "focus-within" not in ctx and "border" not in tail):
outline_bad.append(name)
chk("div/span click handlers app-wide: 0 (baseline 12/2)", divspan == 0, divspan)
chk("outline:none without a replacement: 0", not outline_bad, outline_bad[:4])
# The glossary pill classes are injected app-wide and MUST stay scoped:
# a bare .pill-hold painted the creator's Issue (hold) status radio
# error-red at all times (found 2026-08-20).
help_src = io.open(os.path.join(HTML, "help.js"), encoding="utf-8").read()
bare = re.findall(r"(?<!\.ui-help-pill)\.pill-[a-z]+(?=\s*\{)", help_src)
chk("help.js pill classes are scoped to .ui-help-pill (no bare .pill-*)",
not bare, bare[:4])
chk("the audit document exists with the per-page table",
"## Per-page results" in io.open(os.path.join(ROOT, "docs", "reference",
"accessibility-audit.md"), encoding="utf-8").read())
tmpdir = tempfile.mkdtemp(prefix="wpsuite-tip-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(390, 844, mobile=True)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots at 390px", wait_creator(page))
settle(1.8)
# ── 2. every badge is a real button ──────────────────────────────────
print("\n2. the component, upgraded")
counts = json.loads(page.eval("""JSON.stringify({
total: document.querySelectorAll('.help-tip').length,
buttons: document.querySelectorAll('button.help-tip').length,
spans: document.querySelectorAll('span.help-tip').length,
})"""))
chk("every help-tip on the page is a <button>; zero spans remain",
counts["total"] > 0 and counts["spans"] == 0
and counts["buttons"] == counts["total"], ascii_(counts))
chk("...with an accessible name and a declared state",
page.eval("""[...document.querySelectorAll('button.help-tip')]
.every(b => b.getAttribute('aria-label') && b.getAttribute('aria-expanded') !== null)"""))
# ── 3. keyboard ───────────────────────────────────────────────────────
print("\n3. keyboard")
# Programmatic focus() fires no focusin unless the document HAS focus -
# the exact trap form_structure_check documents. Emulate it, loudly.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so a focus reading means something",
page.eval("document.hasFocus()") is True)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null) || document.querySelector('button.help-tip');
b.scrollIntoView({block:'center'}); b.focus();
})()""")
settle(0.4)
chk("focusing a badge shows the tooltip",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("(document.getElementById('wp-tip-bubble')||{}).textContent.length > 0"))
chk("...as a role=tooltip the badge points at",
page.eval("document.getElementById('wp-tip-bubble').getAttribute('role')") == "tooltip"
and page.eval("document.activeElement.getAttribute('aria-describedby')") == "wp-tip-bubble")
bubble = json.loads(page.eval("""JSON.stringify((() => {
const r = document.getElementById('wp-tip-bubble').getBoundingClientRect();
return {left: r.left, right: r.right};
})())"""))
chk("390px: the bubble is CLAMPED to the viewport (BL-001's cause, dead)",
bubble["left"] >= 0 and bubble["right"] <= 390, ascii_(bubble))
# ── 4. touch ──────────────────────────────────────────────────────────
print("\n4. touch")
page.eval("document.activeElement.blur()")
settle(0.3)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null);
b.click();
})()""")
settle(0.4)
chk("tapping a badge opens the tooltip and says so with aria-expanded",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("!!document.querySelector(%s)"
% json.dumps('button.help-tip[aria-expanded="true"]')))
page.eval("document.body.click()")
settle(0.3)
chk("tapping elsewhere closes it",
page.eval("!document.getElementById('wp-tip-bubble') || document.getElementById('wp-tip-bubble').hidden"))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -460,8 +460,12 @@ def main():
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
bad = []
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
# wp.status specifically - T8.3's coalescer sets a NOTIFICATION
# row's .status (held.status = "pending"), which is outbox state,
# not a release transition.
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Attribute) and t.attr == "status"
and isinstance(t.value, ast.Name) and t.value.id == "wp"
for t in n.targets)]
if not assigns:
continue

91
tests/icon_check.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Is there one icon system, with one meaning per glyph? — S6, T9.3.
The set mixed emoji and dingbats, and at least one glyph carried two meanings.
Now: monochrome text-presentation glyphs only, mapped one-to-one in
docs/reference/tokens.md. Emoji render as per-platform colour artwork - which
is WHY the same glyph read as two things - so the enforceable form of "renders
identically on Windows, macOS and a tablet" is: no emoji-range codepoint and
no U+FE0F emoji-presentation selector anywhere in the UI source.
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
"""
import io
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from browser_check import chk, _PASS, _FAIL # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
# The approved system, verbatim from docs/reference/tokens.md.
APPROVED = {
0x2713, 0x2715, 0x26A0, 0x2298, 0x270E, 0x21BA, 0x21BB, 0x2699,
0x2913, 0x2912, 0x25C6, 0x25B8, 0x24D8, 0x2190, 0x2039, 0x203A,
0x2192, 0x2193, 0x25D4, 0x25A4, 0x25A6, 0x25A7, 0x283F, 0x25BE,
0x2248, 0x2398, 0x29C9, 0x2022, 0x00B7, 0x2298,
0x2302, 0x2315, 0x2399, 0x23FB, 0x25B2, 0x25BC, 0x25C9, 0x25F7,
0x2630, 0x263A, 0x2692, 0x26BF,
}
# Emoji territory: anything here in UI source is a system violation.
def is_emoji(o):
return (0x1F000 <= o <= 0x1FAFF) or o in (0x2705, 0x274C, 0x26D4, 0x2B50,
0x26A1, 0xFE0F, 0x2757, 0x2B55)
def main():
print("\n1. no emoji, anywhere in the UI")
offenders = []
glyphs_seen = set()
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
for ch in set(src):
o = ord(ch)
if is_emoji(o):
offenders.append((name, "U+%04X" % o))
if o > 0x2000 and o not in (0x2013, 0x2014, 0x2018, 0x2019,
0x201C, 0x201D, 0x2026):
glyphs_seen.add(o)
chk("no emoji-range codepoint and no U+FE0F selector survives in any page",
not offenders, offenders[:8])
print("\n2. the mapping document")
tokens = io.open(os.path.join(ROOT, "docs", "reference", "tokens.md"),
encoding="utf-8").read()
chk("the meaning-to-icon mapping is documented in tokens.md",
"## Icons (S6 / T9.3)" in tokens and "U+2713" in tokens and "U+2298" in tokens)
body = tokens[tokens.find("## Icons"):]
rows = re.findall(r"^\| ([^|]+) \| [^|]+ \| (U\+[0-9A-F]{4}[^|]*) \|", body, re.M)
meanings = [r[0].strip() for r in rows]
chk("no meaning appears twice in the mapping", len(meanings) == len(set(meanings)),
[m for m in meanings if meanings.count(m) > 1])
codes = []
for _, cp in rows:
codes.extend(re.findall(r"U\+([0-9A-F]{4})", cp))
chk("no glyph carries two meanings", len(codes) == len(set(codes)),
[c for c in codes if codes.count(c) > 1])
print("\n3. what the pages use is what the document names")
# Box-drawing comment art (U+2500-257F) and the A7 locale samples in
# wp-format.js are not icons; everything else above U+2200 must be mapped.
unmapped = sorted("U+%04X" % o for o in glyphs_seen
if o >= 0x2200 and o not in APPROVED
and not (0x2500 <= o <= 0x257F)
and not (0x4E00 <= o <= 0xD7FF)) # CJK/Hangul: A7 locale data
chk("every glyph in use above the punctuation range is in the approved set",
not unmapped, unmapped[:10])
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -106,7 +106,7 @@ def main():
rcpts = sorted(m["to"][0] for m in sink.messages)
chk("...exactly them, actor excluded",
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
body = sink.messages[0]["data"] if sink.messages else ""
body = sink.messages[0]["text"] if sink.messages else ""
chk("the mail says old status, new status and who",
"In Transit" in body and "Delivered" in body and "Root" in body,
ascii_(body, 260))

Some files were not shown because too many files have changed in this diff Show More