Compare commits
13 Commits
dc13f9b0e3
...
feat/waves
| Author | SHA1 | Date | |
|---|---|---|---|
| 2842ec996c | |||
| b2a083ac7c | |||
| 0b5ab59518 | |||
| a6c3fbfe50 | |||
| 2de76d52e6 | |||
| 8e863ae7d0 | |||
| 6cde6e3f60 | |||
| 358469531c | |||
| 850b78972b | |||
| 0652fa732d | |||
| 75ac930d0c | |||
| df20b8f18d | |||
| cc64c88c3e |
@@ -58,6 +58,15 @@ POSTGRES_PASSWORD=<strong-random-password>
|
||||
# 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
|
||||
|
||||
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 34 KiB |
@@ -283,7 +283,9 @@ python tests/triage_check.py # A6 - the sidebar answers the stand-up
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
340
docs/waves/decisions-2026-09-17.md
Normal 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`.
|
||||
85
docs/waves/notes-2026-09-23.md
Normal 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.
|
||||
@@ -156,6 +156,24 @@ Depends only on `main` as it stands after `D15`. Not sequenced behind any other
|
||||
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
|
||||
@@ -183,10 +201,33 @@ Depends only on `main` as it stands after `D15`. Not sequenced behind any other
|
||||
|
||||
## Still open
|
||||
|
||||
- The OIDC claim mapping (`T10.3`).
|
||||
- Final confirmation of the redirect/callback URI (`https://wp.controls.dev/api/auth/okta/callback`
|
||||
proposed, pending security).
|
||||
- The `Business Technology Group` pilot assignment in Okta.
|
||||
- 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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 & 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 & 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,7 +237,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="console-util.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
141
html/admin.js
@@ -24,7 +24,7 @@ function reveal(){
|
||||
loadNotifications();
|
||||
loadComments();
|
||||
loadAudit();
|
||||
loadUsage();
|
||||
loadActivity();
|
||||
}
|
||||
function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
@@ -189,6 +189,19 @@ async function loadProjects(){
|
||||
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(){
|
||||
@@ -465,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(){
|
||||
@@ -656,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.
|
||||
|
||||
@@ -125,12 +125,40 @@
|
||||
// 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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2207,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});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3993,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
})();
|
||||
@@ -27,8 +27,18 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
# 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
|
||||
|
||||
46
server/alembic/versions/8e2cb3003f8a_usage_events.py
Normal 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')
|
||||
198
server/app.py
@@ -9,10 +9,15 @@ 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 datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -22,7 +27,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
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
|
||||
|
||||
@@ -103,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:
|
||||
@@ -756,6 +772,11 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
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()
|
||||
db.refresh(user)
|
||||
|
||||
@@ -3374,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),
|
||||
|
||||
@@ -59,8 +59,26 @@ log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a session lasts before the person must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# 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"
|
||||
@@ -160,6 +178,30 @@ SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── 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 = {
|
||||
@@ -168,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)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ and .env resolve the same way the API does:
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
@@ -44,7 +45,24 @@ def cmd_promote(args) -> None:
|
||||
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"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
|
||||
|
||||
|
||||
@@ -337,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
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is there exactly one analytics implementation, reported from admin? — D5, T7.10.
|
||||
|
||||
Usage analytics existed twice (creator + wizard), five of the nine colliding
|
||||
globals `creator-frame.md` counted, and the wizard's copy had no caller. One
|
||||
core survives in wp-usage.js; the pages keep thin track() wrappers; the report
|
||||
and its downloads live on the admin console behind the same role gate as the
|
||||
rest of that page. Keys are unchanged, so pre-move data still reads.
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
||||
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
|
||||
|
||||
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.6):
|
||||
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 main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
# ── 1. the grep half: one implementation, no leftover controls ───────────
|
||||
print("\n1. grep: one implementation, nothing unreferenced")
|
||||
files = {}
|
||||
for name in os.listdir(HTML):
|
||||
if name.endswith((".js", ".html")):
|
||||
files[name] = strip_js(open(os.path.join(HTML, name), encoding="utf-8").read())
|
||||
|
||||
core_defs = [n for n, src in files.items() if "window.WPUsage" in src]
|
||||
chk("the WPUsage core is defined in wp-usage.js and only there",
|
||||
core_defs == ["wp-usage.js"], ascii_(core_defs))
|
||||
chk("the pages record THROUGH it - no page touches the storage keys directly",
|
||||
all("wp_iwp_analytics_v1" not in src and "wp_suite_analytics_v1" not in src
|
||||
for n, src in files.items()
|
||||
if n not in ("wp-usage.js",) and n.endswith(".js")))
|
||||
leftovers = {n: re.findall(r"analyticsLoad|analyticsSave|downloadAnalytics|showAnalytics"
|
||||
r"|ANALYTICS_KEY|USAGE_KEY|usageLoad|downloadUsage", src)
|
||||
for n, src in files.items() if n != "wp-usage.js"}
|
||||
leftovers = {n: v for n, v in leftovers.items() if v}
|
||||
chk("none of the five colliding globals survives anywhere; grep confirms",
|
||||
not leftovers, ascii_(leftovers))
|
||||
chk("no analytics control remains on the creator or the wizard; grep confirms",
|
||||
"Usage data" not in files["wp-creation-index.html"]
|
||||
and "showAnalytics" not in files["work-package-suite.html"])
|
||||
chk("both storage keys survive, verbatim, in the core (data continuity)",
|
||||
"wp_iwp_analytics_v1" in files["wp-usage.js"]
|
||||
and "wp_suite_analytics_v1" in files["wp-usage.js"])
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-usage-")
|
||||
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(1440, 900)
|
||||
|
||||
# ── 2. recording still works from both tools ─────────────────────────
|
||||
print("\n2. the tools still record")
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.0)
|
||||
# Plant a LEGACY-format event under the pre-move key: the done-when is
|
||||
# that data recorded before this task is still readable after it.
|
||||
page.eval("""(() => {
|
||||
const d = JSON.parse(localStorage.getItem('wp_iwp_analytics_v1')) || {events: []};
|
||||
d.events.unshift({ts: '2026-07-01T10:00:00Z', session: 's_legacy',
|
||||
event: 'legacy_probe_event', detail: null});
|
||||
localStorage.setItem('wp_iwp_analytics_v1', JSON.stringify(d));
|
||||
})()""")
|
||||
n0 = page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length")
|
||||
page.eval("track('probe_event')")
|
||||
chk("the creator's track() still lands events under the old key",
|
||||
page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") == n0 + 1)
|
||||
|
||||
page.goto(base + "/work-package-suite.html?tab=sop")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.0)
|
||||
n0 = page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length")
|
||||
page.eval("track('probe_event_wizard')")
|
||||
chk("the wizard's track() still lands events under its old key",
|
||||
page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") == n0 + 1)
|
||||
wiz_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||
chk("...and the wizard page throws no errors without its old globals "
|
||||
"(the blocked-beforeunload console line is BL-020, filtered not hidden)",
|
||||
not wiz_errors, ascii_(wiz_errors[:2]))
|
||||
|
||||
# ── 3. the report, on admin, behind the admin gate ───────────────────
|
||||
print("\n3. the admin report")
|
||||
page.goto(base + "/admin.html")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.0)
|
||||
page.eval("loadUsage()")
|
||||
settle(0.5)
|
||||
report = page.eval("(document.getElementById('usage-admin')||{textContent:''}).textContent")
|
||||
chk("usage data is reachable from admin.html, both tools reported",
|
||||
"Work package creator" in report and "SOP wizard" in report, ascii_(report, 160))
|
||||
chk("the pre-move legacy event is readable in the report",
|
||||
"legacy_probe_event" in report)
|
||||
chk("this session's fresh events are in it too",
|
||||
"probe_event" in report and "probe_event_wizard" in report)
|
||||
chk("each tool offers its download from the report",
|
||||
page.eval("[...document.querySelectorAll('#usage-admin button')].length") >= 2)
|
||||
|
||||
page.viewport(390, 844, mobile=True)
|
||||
settle(0.6)
|
||||
fits = page.eval("""(() => {
|
||||
const box = document.getElementById('usage-admin');
|
||||
return box && box.scrollWidth <= box.clientWidth + 2
|
||||
&& document.documentElement.scrollWidth <= 392;
|
||||
})()""")
|
||||
chk("the report is usable at 390px - no sideways scrolling", bool(fits))
|
||||
page.viewport(1440, 900)
|
||||
settle(0.4)
|
||||
|
||||
# The same role gate as the rest of the console: a non-admin sees the
|
||||
# denied card and no cards, this one included.
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["pat"])
|
||||
page.goto(base + "/admin.html")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.0)
|
||||
chk("a non-administrator gets the denied notice, not the usage report",
|
||||
page.eval("""(() => {
|
||||
const denied = document.getElementById('admin-denied');
|
||||
const usage = document.getElementById('usage-admin');
|
||||
const visible = el => !!el && el.offsetParent !== null;
|
||||
return visible(denied) && !visible(usage);
|
||||
})()"""))
|
||||
|
||||
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())
|
||||