CR-019 - usage/activity metrics (admin console), wave 11 CR-020 - bulk editing of users, wave 12 D18 - Okta/AD deprovisioning detection and auto-disable, wave 13 Raised by Matt Mabrey 2026-09-17. Decision record and task breakdowns only in this commit - no feature code yet.
7.3 KiB
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_ids 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
AuditLogentry, 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-liveannounces 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-020acceptance criteria indecisions-2026-09-17.mdare met or a failure is stated with a reason - screenshots committed
- smoke test and
seed_demo.pyboth 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-020fully accounted for, no open acceptance criteria