# 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