Skip to content

Fix #1301: require non-empty reason + confirm modal on banlist unban / commslist unmute & ungag - #1323

Merged
rumblefrog merged 2 commits into
mainfrom
fix/issue-1301-unban-confirm
May 10, 2026
Merged

rumblefrog merged 2 commits into
mainfrom
fix/issue-1301-unban-confirm

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Fixes #1301.

Summary

v1.x had two safeguards before lifting a ban or comm block: a confirm modal and a non-empty unblock-reason prompt. Both lived in the deleted web/scripts/sourcebans.js (UnbanBan / UnMuteBan / UnGagBan), and the v2.0 cutover left the row's affordance as a single click that silently sent an empty ureason. The audit log lost the why behind every block lift for ~18 months.

This PR restores both safeguards on the v2.0 chrome:

  • New confirm + reason modals — <dialog id="bans-unban-dialog"> in page_bans.tpl and <dialog id="comms-unblock-dialog"> in page_comms.tpl. The textarea uses aria-required="true" (NOT the native required) so the JS submit handler owns the empty-reason inline-error UX rather than ceding to the browser's validation popover. The dialog title + verb adapt to the comm-block type (mute / gag / silence). On success, the row flips in place via the existing flipRowToUnbanned / flipRowToUnmuted helpers and a success toast fires.
  • New bans.unban JSON action mirrors the legacy GET handler's per-row precision check (Owner / Unban-all / Unban-own / Unban-group), rejects empty ureason server-side, archives matching protests, fans sm_unban to enabled servers, and records the reason in the audit log. The existing comms.unblock gains the same empty-reason guard.
  • Legacy GET fallbacks bounce empty reasons too — ?p=banlist&a=unban and ?p=commslist&a=ungag / &a=unmute short-circuit with ShowBox when ureason is empty, so a hand-edited URL can't slip through the back door.
  • All four Log::add(LogType::Message, ...) audit-trail calls (legacy GET unban, legacy GET ungag/unmute, JSON bans.unban, JSON comms.unblock) now carry Reason: <ureason> in the message column, restoring v1.x parity.
  • Docs — AGENTS.md gets a "Where to find what" row for the confirm + reason modal pattern, and two new anti-patterns: reason-less / no-confirm row-lifts AND the native required on a <dialog> form's textarea.

Test plan

Gate Result
./sbpp.sh phpstan pass (No errors)
./sbpp.sh ts-check pass (no output)
./sbpp.sh composer api-contract regenerated (69 actions, 32 perms, 14 typedefs)
./sbpp.sh test --filter="Bans|Comms|PermissionMatrix" pass (140 tests, 565 assertions)
./sbpp.sh test (full suite) pass (413 tests, 1809 assertions, 1 PHPUnit-deprecation)
./sbpp.sh e2e --workers=1 --grep "comms|banlist|ban" --project=chromium pass (30 tests, 30 skipped — unrelated mobile/screenshot variants)

Specifically covered by new PHPUnit tests:

  • bans.unban: anonymous reject, missing/bad bid, empty ureason reject, unknown bid (404), success path (state persists in :prefix_bans.RemoveType + RemovedBy), already-lifted reject (409), audit-log message contains Reason: ….
  • comms.unblock (additions on top of the existing tests): empty ureason reject, audit-log message contains Reason: ….
  • PermissionMatrixTest: locks bans.unban's dispatcher gate to ADMIN_OWNER | ADMIN_UNBAN | ADMIN_UNBAN_OWN_BANS | ADMIN_UNBAN_GROUP_BANS (mirrors the legacy GET).

Specifically covered by updated E2E specs:

  • admin-ban-lifecycle.spec.ts: drives the new #bans-unban-dialog modal (empty submit surfaces inline [data-testid="bans-unban-error"], filled submit flips row's data-state from permanent to unbanned in place + success toast).
  • comms-affordances.spec.ts (desktop + mobile variants): drives the new #comms-unblock-dialog modal before asserting the in-place unmuted flip.

Anti-patterns avoided

  • No reintroduction of sourcebans.js, xajax, or ADOdb.
  • No new top-level (non-Sbpp\…) PHP class.
  • No magic letter codes — LogType::Message / BanRemoval::Unbanned->value at every bind site.
  • No inline :prefix_* literal — Sbpp\Db\Database::query() rewrites the placeholder.
  • No CSS-class-chain primary selector or visible-text-only selector in any new E2E assertion.
  • No setTimeout waits in E2E specs — the modal's data-testid hooks + the row's data-state attribute are the deterministic settle signals.
  • No native required on the dialog form's textarea — the JS submit handler is the inline-error UX, the server is the load-bearing gate.
  • No onclick="event.stopPropagation()" on the trigger button (document.addEventListener('click') is how the dialog opener picks the click up — stopPropagation would silently swallow it; the action button isn't inside any [data-drawer-href] ancestor anyway).

…unmute / ungag (#1301)

v1.x had two safeguards before lifting a ban or comm block: a confirm
modal and a non-empty unblock-reason prompt. Both lived in the deleted
`web/scripts/sourcebans.js` (UnbanBan / UnMuteBan / UnGagBan), and the
v2.0 cutover left the row's affordance as a single click that silently
sent an empty `ureason`. The audit log lost the *why* behind every block
lift for ~18 months.

This restores both safeguards on the v2.0 chrome:

- New `<dialog id="bans-unban-dialog">` and `<dialog id="comms-unblock-dialog">`
  carrying a confirm prompt + required-reason textarea (`aria-required`
  rather than the native `required`, so the JS submit handler owns the
  empty-reason inline-error UX rather than ceding to the browser's
  validation popover).
- New `bans.unban` JSON action mirrors the legacy GET handler's
  per-row precision check (Owner / Unban-all / Unban-own / Unban-group)
  and rejects empty `ureason` server-side. The existing `comms.unblock`
  gains the same empty-reason guard. Both legacy GET fallbacks
  (`?p=banlist&a=unban` / `?p=commslist&a=ungag` / `&a=unmute`) bounce
  empty reasons too so a hand-edited URL can't slip through the back
  door.
- All four `Log::add(LogType::Message, ...)` audit-trail calls now
  carry the unblock reason in the message column, restoring v1.x parity.

Tests: new PHPUnit coverage for `bans.unban` (anonymous reject, missing
bid, empty reason, unknown bid, success path, already-lifted, audit log
contents) + `comms.unblock` empty-reason rejection + audit log contents.
PermissionMatrix locks the new dispatcher gate. The
`admin-ban-lifecycle` E2E spec now exercises the modal (empty submit
shows inline error, filled submit flips row in place + success toast);
`comms-affordances` adopts the same modal interaction.
The handler was tightened in #1301 to reject empty `ureason` server-side
but the docblock (and therefore the regenerated api-contract.js JSDoc)
still claimed `(string, optional)`. Sync the prose with the runtime
contract so the api-contract reader and any future caller doesn't
miss the validation gate.
@rumblefrog
rumblefrog added this pull request to the merge queue May 10, 2026
Merged via the queue into main with commit dd07beb May 10, 2026
5 checks passed
@rumblefrog
rumblefrog deleted the fix/issue-1301-unban-confirm branch May 10, 2026 20:23
rumblefrog pushed a commit that referenced this pull request May 10, 2026
Resolve AGENTS.md conflict by keeping both new anti-pattern entries:
the #1308 stopPropagation-on-data-copy + unconditional-success-toast
entries from this PR, AND the #1301 reason-less-unban + native-required
entries from #1323 (merged to main). Both pairs append to the same
"Anti-patterns" list and don't overlap semantically.
rumblefrog added a commit that referenced this pull request May 10, 2026
…ble advanced search (#1315)

The v2.0 redesign collapsed the sourcebans.js-driven legacy public lists
into Smarty-only `page_bans.tpl` / `page_comms.tpl` and dropped three
power-user surfaces along the way. This PR restores them.

  1. Inline unban-meta line on admin-lifted rows. The reason cell on the
     desktop table emits "Unbanned by <admin>: <reason>"
     (`[data-testid="ban-unban-meta"]`) when `state == 'unbanned'`;
     mobile cards mirror with the `-mobile` suffix. Same shape on the
     commslist for `state == 'unmuted'` — higher priority there because
     no drawer fallback exists. Both are gated on `!$hideadminname` so
     anonymous viewers under a hidden-admins config don't get the admin
     name leaked. Read-side render only — no overlap with #1301 / #1323.
  2. Re-apply (Reban) icon affordance for expired / unbanned rows on
     the banlist desktop, gated on `ADMIN_OWNER | ADMIN_ADD_BAN`.
     Deep-links the smart-default `?p=admin&c=bans&section=add-ban&rebanid=<bid>`
     URL the existing `BansPrepareReban` JSON action already
     pre-populates. Mobile parity deferred — drawer is canonical.
  3. Advanced-search disclosure on both lists. Default-collapsed
     `<details class="filters-details">` wrapping the legacy multi-
     criterion search form (loaded via `{load_template
     file="admin.bans.search"}`). Auto-opens on a post-submit
     `?advType=&advSearch=` paint via a new
     `BanListView::$is_advanced_search_open` /
     `CommsListView::$is_advanced_search_open` boolean. Reuses the
     `.filters-details` chrome #1303 introduced for admin-admins
     (CSS rules ship in this PR; will dedupe harmlessly when #1318
     merges).

Tests:

  - PHPUnit: `PublicBanListRegressionTest` (7 cases — disclosure
    default-closed, auto-open, unban-meta render, Re-apply gating
    + URL shape, banlist + commslist parity). RunInSeparateProcess
    per method to dodge the page-handler `setPostKey()` redeclare
    trap, mirroring `Php82DeprecationsTest`'s pattern.
  - Playwright: `flows/public-banlist-regressions.spec.ts` (4 cases
    across chromium + mobile-chromium — disclosure default-closed,
    click-to-open, post-submit auto-open via URL navigation;
    closed-state axe coverage). Re-apply / unban-meta DB-state
    assertions live in the PHPUnit test where seeding is cleaner.

AGENTS.md "Where to find what" gains three rows for the new patterns.
rumblefrog added a commit that referenced this pull request May 10, 2026
…ble advanced search (#1315)

The v2.0 redesign collapsed the sourcebans.js-driven legacy public lists
into Smarty-only `page_bans.tpl` / `page_comms.tpl` and dropped three
power-user surfaces along the way. This PR restores them.

  1. Inline unban-meta line on admin-lifted rows. The reason cell on the
     desktop table emits "Unbanned by <admin>: <reason>"
     (`[data-testid="ban-unban-meta"]`) when `state == 'unbanned'`;
     mobile cards mirror with the `-mobile` suffix. Same shape on the
     commslist for `state == 'unmuted'` — higher priority there because
     no drawer fallback exists. Both are gated on `!$hideadminname` so
     anonymous viewers under a hidden-admins config don't get the admin
     name leaked. Read-side render only — no overlap with #1301 / #1323.
  2. Re-apply (Reban) icon affordance for expired / unbanned rows on
     the banlist desktop, gated on `ADMIN_OWNER | ADMIN_ADD_BAN`.
     Deep-links the smart-default `?p=admin&c=bans&section=add-ban&rebanid=<bid>`
     URL the existing `BansPrepareReban` JSON action already
     pre-populates. Mobile parity deferred — drawer is canonical.
  3. Advanced-search disclosure on both lists. Default-collapsed
     `<details class="filters-details">` wrapping the legacy multi-
     criterion search form (loaded via `{load_template
     file="admin.bans.search"}`). Auto-opens on a post-submit
     `?advType=&advSearch=` paint via a new
     `BanListView::$is_advanced_search_open` /
     `CommsListView::$is_advanced_search_open` boolean. Reuses the
     `.filters-details` chrome #1303 introduced for admin-admins
     (CSS rules ship in this PR; will dedupe harmlessly when #1318
     merges).

Tests:

  - PHPUnit: `PublicBanListRegressionTest` (7 cases — disclosure
    default-closed, auto-open, unban-meta render, Re-apply gating
    + URL shape, banlist + commslist parity). RunInSeparateProcess
    per method to dodge the page-handler `setPostKey()` redeclare
    trap, mirroring `Php82DeprecationsTest`'s pattern.
  - Playwright: `flows/public-banlist-regressions.spec.ts` (4 cases
    across chromium + mobile-chromium — disclosure default-closed,
    click-to-open, post-submit auto-open via URL navigation;
    closed-state axe coverage). Re-apply / unban-meta DB-state
    assertions live in the PHPUnit test where seeding is cleaner.

AGENTS.md "Where to find what" gains three rows for the new patterns.
Rushaway pushed a commit to srcdslab/sourcebans-pp that referenced this pull request May 15, 2026
…ban-meta + Re-apply + collapsible adv search) (sbpp#1330)

* fix(banlist+commslist): restore unban-meta line + Re-apply + collapsible advanced search (sbpp#1315)

The v2.0 redesign collapsed the sourcebans.js-driven legacy public lists
into Smarty-only `page_bans.tpl` / `page_comms.tpl` and dropped three
power-user surfaces along the way. This PR restores them.

  1. Inline unban-meta line on admin-lifted rows. The reason cell on the
     desktop table emits "Unbanned by <admin>: <reason>"
     (`[data-testid="ban-unban-meta"]`) when `state == 'unbanned'`;
     mobile cards mirror with the `-mobile` suffix. Same shape on the
     commslist for `state == 'unmuted'` — higher priority there because
     no drawer fallback exists. Both are gated on `!$hideadminname` so
     anonymous viewers under a hidden-admins config don't get the admin
     name leaked. Read-side render only — no overlap with sbpp#1301 / sbpp#1323.
  2. Re-apply (Reban) icon affordance for expired / unbanned rows on
     the banlist desktop, gated on `ADMIN_OWNER | ADMIN_ADD_BAN`.
     Deep-links the smart-default `?p=admin&c=bans&section=add-ban&rebanid=<bid>`
     URL the existing `BansPrepareReban` JSON action already
     pre-populates. Mobile parity deferred — drawer is canonical.
  3. Advanced-search disclosure on both lists. Default-collapsed
     `<details class="filters-details">` wrapping the legacy multi-
     criterion search form (loaded via `{load_template
     file="admin.bans.search"}`). Auto-opens on a post-submit
     `?advType=&advSearch=` paint via a new
     `BanListView::$is_advanced_search_open` /
     `CommsListView::$is_advanced_search_open` boolean. Reuses the
     `.filters-details` chrome sbpp#1303 introduced for admin-admins
     (CSS rules ship in this PR; will dedupe harmlessly when sbpp#1318
     merges).

Tests:

  - PHPUnit: `PublicBanListRegressionTest` (7 cases — disclosure
    default-closed, auto-open, unban-meta render, Re-apply gating
    + URL shape, banlist + commslist parity). RunInSeparateProcess
    per method to dodge the page-handler `setPostKey()` redeclare
    trap, mirroring `Php82DeprecationsTest`'s pattern.
  - Playwright: `flows/public-banlist-regressions.spec.ts` (4 cases
    across chromium + mobile-chromium — disclosure default-closed,
    click-to-open, post-submit auto-open via URL navigation;
    closed-state axe coverage). Re-apply / unban-meta DB-state
    assertions live in the PHPUnit test where seeding is cleaner.

AGENTS.md "Where to find what" gains three rows for the new patterns.

* fix(tests): update integration tests after rebase on main

Two test-only fixes surfaced when the PR rebased on top of sbpp#1320 +

- `BanListIpColumnTest::renderBanList` (from sbpp#1320) constructs a
  `BanListView` with positional + named arguments. This PR adds a 28th
  constructor parameter (`is_advanced_search_open`) which the test
  must now pass — default it to `false` so the IP-column suite stays
  exercising the column-render contract it was written for, not the
  disclosure state.
- `ServerMapImageRenderTest::testHandlerStillEmitsMapimgField` (from
  sbpp#1326) asserted a literal `"'mapimg'   => GetMapImage("` substring
  in `web/api/handlers/servers.php`. sbpp#1329's cache + debounce rewrite
  shifted the surrounding column alignment to 5 spaces, breaking the
  brittle match without changing the contract the test guards (the
  field is still emitted). Switch to a regex that tolerates any
  whitespace between the key and `=>` so a future alignment shift
  doesn't silently re-trigger the same false positive.

* fix(tests): login as admin in BanListIpColumnTest hideplayerips=false case

sbpp#1315's `<details class="filters-details">` disclosure on the public
ban list `{load_template file="admin.bans.search"}`s the legacy
advanced-search partial. The partial's backing `admin.bans.search.php`
re-derives `hideplayerips` / `hideadminname` from
`Config::getBool('banlist.hideplayerips') && !$userbank->is_admin()`
and `Renderer::render`s the result into Smarty, overwriting whatever
the parent BanListView had assigned. In production this is a no-op
because the parent (`page.banlist.php`) computes the SAME formula —
both halves converge. But `BanListIpColumnTest` hand-crafts
`BanListView::$hideplayerips` to exercise the column-gating contract
in isolation, so the partial's overwrite collapses the test's
fixture-controlled value down to whatever the test environment's
Config + $userbank happen to evaluate to.

Fix by logging in as admin in the two test methods that pass
`hideplayerips: false`. With `is_admin()=true`, the partial's
formula reduces to `(…) && !true = false`, matching the View's
hand-crafted `false`. The suppressed-state test
(`hideplayerips: true`) stays anonymous so the formula reduces to
`Config && true` — `true` under `data.sql`'s
`banlist.hideplayerips=1` default — also matching the View's input.

The test pre-dates sbpp#1315; it was written against the v2.0 template
that didn't yet `{load_template}` the partial, so the parent's
assigns were never overwritten. The new disclosure brings the
partial back into the render tree and the test has to maintain
the production invariant ("BanListView's hideplayerips matches
AdminBansSearchView's recomputed value") explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Banlist unban (and commslist unmute/ungag) accept empty reasons with no confirmation — regression vs. 1.x

1 participant