Skip to content

fix(admin): rewire dead sourcebans.js helpers across admin surfaces (#1402) - #1408

Merged
rumblefrog merged 3 commits into
mainfrom
fix/1402-rewire-dead-js-helpers
May 17, 2026
Merged

fix(admin): rewire dead sourcebans.js helpers across admin surfaces (#1402)#1408
rumblefrog merged 3 commits into
mainfrom
fix/1402-rewire-dead-js-helpers

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Summary

Closes #1402.

The v2.0 cutover deletion of web/scripts/sourcebans.js (#1123 D1) left ~10 admin surfaces with dead JS handlers. This PR rewires them to sb.api.call(Actions.PascalName) or vanilla DOM following the #1352 canonical confirm-dialog shape.

Wired surfaces:

  • Admin → Mods: Delete (Actions.ModsDelete), Add (Actions.ModsAdd), Edit (icon upload window.icon callback)
  • Admin → Admins: Add (Actions.AdminsAdd + paired SystemRehashAdmins), Generate password, perm-mode selects (updateServer / updateWeb)
  • Admin → Bans: Group ban (Actions.BansGroupBanBansBanMemberOfGroup chain), Tick select-all
  • Banlist / Commslist / admin.bans: Comment delete trash icons (Actions.BansRemoveComment via shared comment-actions.js dispatcher)
  • admin.edit.comms: MooTools window.addEvent('domready', …) form-field error highlights → vanilla DOM + window.SBPP.showToast

Security-hardening in the same PR (caught by adversarial review):

  • api_admins_add now rejects mask & ADMIN_OWNER from non-owners (mirrors api_admins_edit_perms from origin/main; pre-fix the dead ProcessAddAdmin masked this)
  • UI: can_grant_owner gate on the OWNER checkbox so non-owners don't see the affordance
  • updateServer / updateWeb now clear dependent state when parent goes back to "no permissions", preventing stale-flag ride-through

Loading-state contract: every new sb.api.call button goes through window.SBPP.setBusy(...); .catch() arm on every chain so a network error doesn't leave the button stuck.

Test plan

  • PHPStan green
  • PHPUnit green (new tests: testAddBlocksGrantingOwnerWithoutOwner, testAddAllowsGrantingOwnerForOwner, plus the existing AdminsTest coverage)
  • ts-check green
  • API contract clean
  • E2E green at --workers=1:
    • mods-add-form.spec.ts (form-success / icon-upload callback / validation)
    • admins-add-form.spec.ts (perm-toggle clear, owner-gate hide, success → SystemRehashAdmins chain)
    • comment-delete-dispatcher.spec.ts (shared dispatcher fires per-site)
    • groupban-dispatcher.spec.ts (URL submit / bulk-from-friends / select-all)
  • Adversarial review pass (findings addressed in the second commit)

Related

…1402)

Sweeps the post-#1397 cluster of admin surfaces where the click chain
dead-ended at a helper deleted with `web/scripts/sourcebans.js` at
#1123 D1. Same shape as #1397's `RemoveMod` fix (silent / loud
`ReferenceError` on every click), applied to the rest of the cluster:

- **Mods → Add new MOD → Submit** (`ProcessMod`): the form's
  `onsubmit="return ProcessMod()"` referenced a v1.x helper that
  validated input + called `sb.api.call(Actions.ModsAdd)`. Replaced
  with an inline page-tail dispatcher that intercepts `submit`,
  runs the same client-side gates (name + URL + icon non-empty),
  then `sb.api.call(Actions.ModsAdd, …)` per the
  `page_admin_groups_add.tpl` reference shape. `setBusy` flips the
  three-attribute busy contract during the in-flight call.

- **Mods → Upload icon popup** (`window.opener.icon(...)`):
  `UploadHandler::handle()` emits `<script>window.opener.icon(filename)</script>`
  on successful upload, but `window.icon` was undefined in both
  `page_admin_mods_add.tpl` (new-mod flow) and `page_admin_edit_mod.tpl`
  (edit-mod flow), so the popup never closed and the icon filename
  never landed on the parent's `#icon_hid` hidden input. Both
  templates now define `window.icon = function (filename) { … }`
  inside a `{literal}…{/literal}` block that patches the hidden
  input + updates the visible affordances (preview / "Choose file"
  label). Same shape `window.demo` uses on the ban pages.

- **Admins → Add new admin → Submit** (`ProcessAddAdmin`): same
  shape as `ProcessMod`. Intercept submit, validate (username +
  password + SteamID + server-group + web-group), then
  `sb.api.call(Actions.AdminsAdd)`. `event.preventDefault()` blocks
  the native fallback POST so a JSON failure no longer races a
  full-page reload.

- **Admins → Add → Generate password button** (`LoadGeneratePassword`):
  rewired to `sb.api.call(Actions.AdminsGeneratePassword)`; success
  fills both `password` + `confirm` inputs in place. No CSRF
  surface (read-only call).

- **Admins → Add → server / web permissions `<select>` reveal**
  (`update_server` / `update_web`): the conditional dependent-input
  reveal (Custom permissions → flag picker; New admin group →
  new-name + SM flags) is pure client-side DOM. Replaced the two
  dead helpers with `data-action="adminadd-update-server" /
  "adminadd-update-web"` `<select>` change handlers that toggle
  the right `hidden` attributes per the original v1.x semantics.

- **Comms → Edit block → submit with validation error**
  (`window.addEvent('domready', …)`): the MooTools DOMready wrapper
  in `$errorScript` referenced an undefined global, so the
  validation-error toast never painted. Per scope guardrails on
  this issue, I dropped the MooTools wrapper and replaced its
  body with vanilla `document.addEventListener('DOMContentLoaded',
  …)` calling `window.SBPP.showToast` directly. The matching
  `$('id').innerHTML` / `setStyle` calls inside the
  `changeReason()` helper became
  `document.getElementById('id').textContent` /
  `el.style.display = 'block'`. The broader sweep of
  `<script>ShowBox(...)</script>` toast blobs across other pages
  stays for #1403.

- **Bans → Group ban — URL submit + bulk-from-friends**
  (`LoadGroupBan` / `ProcessGroupBan` / `CheckGroupBan`): chained
  through a page-tail dispatcher in `admin.bans.php` that picks
  up `data-action="groupban-submit" / "groupban-bulk-submit"`
  and walks the legacy two-step
  `Actions.BansGroupBan` → `Actions.BansBanMemberOfGroup` chain.
  The first step parses the URL into a group name, the second
  enumerates + bans members. Bulk path tracks the `last` checkbox
  sentinel so a 20-group bulk-ban only fires one success toast.
  No new `Actions.GroupbanCheck` was needed — `bans.group_ban`
  IS the URL-parse step `LoadGroupBan` used to perform first.

- **Bans → Group ban → Tick select-all** (`TickSelectAll`):
  rewired to `data-action="groupban-select-all"` (covers both
  the table-header button + the "Select all" link below).
  Toggle state is computed from the `chkb_<n>` checkboxes' live
  state per the v1.x semantics (any-unchecked → check all;
  all-checked → uncheck all).

- **Banlist / Commslist / admin.bans comment editor trash**
  (`RemoveComment`): the four call sites (page.banlist.php,
  page.commslist.php, admin.bans.php protests + submissions)
  used to inline the same dead helper. Replaced with a single
  shared `web/scripts/comment-actions.js` dispatcher loaded
  from `core/footer.tpl` (`<script src="./scripts/comment-actions.js" defer>`).
  Each trigger emits `data-action="comment-delete"` +
  `data-cid="<int>"` + `data-ctype="<B|C|S|P>"` + `data-page="<int>"`;
  the dispatcher `window.confirm`s the destructive intent, then
  `sb.api.call(Actions.BansRemoveComment, { cid, ctype, page })`.
  The `ctype` letter matches `:prefix_comments.type` (B=ban,
  C=comm-block, S=submission, P=protest); `api_bans_remove_comment`'s
  `ctype` arm consumes all four. Single mount point is the
  contract — don't duplicate inline per page.

E2E regression coverage
-----------------------
Four new specs under `web/tests/e2e/specs/flows/`:

- `mods-add-form.spec.ts` — happy-path submit + missing-icon
  client-side gate. Stubs `Actions.ModsAdd` via `page.route`
  to assert the wire format without mutating the e2e DB.

- `admins-add-form.spec.ts` — happy-path submit, Generate-password
  button fills both password inputs, "New admin group" /
  "Custom permissions" reveals the right dependent inputs.
  Pinned to chromium (form-shaped; mobile would just burn CI
  minutes).

- `comment-delete-dispatcher.spec.ts` — `data-action="comment-delete"`
  trigger fires `Actions.BansRemoveComment` with the right
  `cid` / `ctype` / `page`, `confirm()` cancel suppresses the
  API call, and the dispatcher loads globally (the
  `<script src="./scripts/comment-actions.js" defer>` include
  is in `core/footer.tpl`).

- `groupban-dispatcher.spec.ts` — empty URL → inline error +
  no API call, valid URL → chains
  `Actions.BansGroupBan` → `Actions.BansBanMemberOfGroup`,
  `data-action="groupban-select-all"` toggle flips synthetic
  `chkb_<n>` checkboxes.

The group-ban surface ships behind `config.enablegroupbanning`,
which `data.sql` defaults to 0. The spec flips it on in
`beforeAll` and reverts in `afterAll` via a new
`setSettingE2e(key, value)` helper in `fixtures/db.ts` that
shells out to `web/tests/e2e/scripts/set-setting-e2e.php`
(mirror of the `REPLACE INTO sb_settings` shape `BansTest.php`
uses for the same reason).

AGENTS.md updates
-----------------
- Extended the existing `onclick="<Helper>()"` / `onclick="if
  (typeof <Helper> === 'function') …"` anti-pattern bullet to
  cross-reference #1402 and enumerate the cluster (`ProcessMod`,
  `ProcessAddAdmin`, `LoadGeneratePassword`,
  `update_server` / `update_web`, `LoadGroupBan` /
  `ProcessGroupBan` / `CheckGroupBan` / `TickSelectAll`,
  `RemoveComment`, `window.opener.icon(...)`,
  `window.addEvent('domready', …)`).
- Added a new bullet for the MooTools `window.addEvent('domready', …)`
  DOMready idiom (silent no-op every time it surfaced since
  #1123 D1; replace with `document.addEventListener('DOMContentLoaded', …)`
  or drop the wrapper outright when the script tag lands after
  the elements it touches).
- New "Where to find what" rows for:
  - Wiring a `window.opener.<callback>(...)` slot on a parent
    template (the icon / demo / mapimg callback shape).
  - Wiring a comment-delete trash icon (the shared
    `comment-actions.js` dispatcher).
  - Flipping a `:prefix_settings` row from an E2E spec
    (`setSettingE2e` helper + its caller-cleanup contract).

Test plan
---------
- ./sbpp.sh phpstan                                    ✓
- ./sbpp.sh test                                       ✓ (677 tests)
- ./sbpp.sh ts-check                                   ✓
- ./sbpp.sh composer api-contract                      ✓ (no diff)
- ./sbpp.sh e2e --workers=1                            ✓ (279 passed)
- ./sbpp.sh e2e --grep "1402|mods-add|admins-add|comment-delete|LoadGroupBan" --workers=1
                                                       ✓ (12 passed)

Out of scope (deferred per the issue body)
-----------------------------------------
- `admin.admins.php` `$serverscript` blob (sister #1404, parallel
  worktree).
- `admin.groups.php` `LoadServerHostPlayersList` echo (sister
  #1404).
- The remaining `<script>ShowBox(...)</script>` toast blobs on
  lostpassword / protest / banlist / commslist /
  admin.edit.comms (sister #1403).
Second commit on the same branch addressing the eight defects the
adversarial review surfaced after the initial #1402 rewire landed.
Kept as a separate commit so the audit trail stays visible.

HIGH (must-fix)
---------------
1. Owner-flag privilege escalation through the rewired Add-admin
   form. The pre-existing api_admins_add handler had no
   HasAccess(WebPermission::Owner) check (vs api_admins_edit_perms
   which does), and the rewire exposed the OWNER checkbox to every
   admin with ADMIN_ADD_ADMINS — full panel takeover with one click.
     * api_admins_add: mirror api_admins_edit_perms's guard at the
       top of the handler (Log::add + Api::redirect to no_access).
       Covers both the `wg='c'` (direct mask) and `wg='n'` (new
       group inherits mask) escalation paths.
     * AdminAdminsAddView: new `can_grant_owner` View prop.
     * admin.admins.php: pass
       `$userbank->HasAccess(WebPermission::Owner)`.
     * page_admin_admins_add.tpl: gate the OWNER checkbox with
       `{if \$can_grant_owner}`.
     * web/tests/api/AdminsTest.php: two new tests —
       testAddBlocksGrantingOwnerWithoutOwner (non-owner is
       redirected, no row landed) + testAddAllowsGrantingOwnerForOwner
       (positive case so the guard isn't over-zealous).

2. Add-admin success path silently dropped Actions.SystemRehashAdmins.
   The handler returns `rehash` in the envelope (sid CSV) — the
   legacy ProcessAddAdmin chained it; the rewrite navigated away
   without firing. config.enableadminrehashing defaults to '1', so
   without the chain a new admin can log in to the panel but can't
   moderate on game servers until the next restart.
     * page_admin_admins_add.tpl: thread `data.rehash` into a
       SystemRehashAdmins call before the navigate timer. Mirrors
       _admin_edit_helpers.php:fireRehash's .then/.catch shape.
     * admins-add-form.spec.ts: new e2e arm that stubs both
       Actions.AdminsAdd (returning rehash:'1,2') and
       Actions.SystemRehashAdmins, asserts the call sequence
       and that servers=1,2 was forwarded.

3. Stale flags from hidden #web-flags-block / #server-flags rode
   into Actions.AdminsAdd after a dropdown flip. updateWeb /
   updateServer toggled `hidden` but left checkbox state +
   text-input values intact; collectWebFlags walked the unscoped
   #web-flags-block. Repro: "Custom permissions" → tick Owner →
   "No permissions" → submit → mask: ADMIN_OWNER. Second route
   to accidental OWNER grant.
     * page_admin_admins_add.tpl: updateServer / updateWeb now
       clear dependent state (uncheck flag checkboxes, clear name
       inputs, clear srv flags string) when the dropdown swings
       back to a non-revealing value. collectWebFlags is also
       scoped to `:not([hidden])` as defense-in-depth on top of
       the clear; sibling collectServerFlags / collect*NewName
       helpers ride the same hidden-ancestor guard.
     * admins-add-form.spec.ts: new e2e arm flips the dropdown
       through the trap and asserts the API receives mask: 0.

MEDIUM (concerns)
-----------------
4. Missing .catch() arms on every new sb.api.call chain — sb.api.call
   doesn't reject on network failures (it synthesises an envelope),
   but a throw escaping the success callback would leave the button
   stuck in setBusy forever. Per AGENTS.md "Loading state on action
   buttons" — setBusy(btn, false) on every non-navigating response
   branch.
     * page_admin_admins_add.tpl: AdminsAdd + AdminsGeneratePassword.
     * page_admin_mods_add.tpl: ModsAdd (sibling surface, same
       rewire wave).
     * admin.bans.php loadGroupBan: both outer BansGroupBan and
       inner BansBanMemberOfGroup.
     * comment-actions.js: BansRemoveComment.

5. Generated password left visible (type='text') indefinitely. The
   rewrite flipped both password fields to type=text "to match v1.x
   UX", but v1.x LoadGeneratePassword never changed .type. Privacy /
   shoulder-surf / screenshot leak.
     * page_admin_admins_add.tpl: keep the fields at type='password'
       (matching legacy).
     * admins-add-form.spec.ts: update the type assertion to pin
       'password' (was 'text').

LOW (nits)
----------
6. Drop unused data-action="groupban-submit-form" attribute on
   page_admin_bans_groups.tpl's <form> — no dispatcher wires it.

7. Drop page.waitForTimeout(200/300) negative-assertion settles from
   three e2e specs (comment-delete / groupban / mods-add). The inline
   error renders / dispatcher returns synchronously — once the
   positive state is visible, the negative assertion can land.

8. Drop `defer` from the body-tail comment-actions.js include.
   `defer` is a no-op on a script that lives at the body tail (the
   parser is already past the body), so the markup matched what the
   runtime did, just less misleadingly.

Reviewer-flagged concerns kept out of scope (preserved from
origin/main, not introduced by this PR): the delcomlink permission
asymmetry (Owner-only delete vs. anyone-can-edit-their-own) and the
per-template `window.icon` callback duplication. Both tracked
separately if they need addressing.

Quality gates (all green at workers=1):
  * PHPStan: 240/240 files, no errors
  * PHPUnit: 679 tests, 2703 assertions
  * ts-check: clean
  * API contract: regenerated, no diff
  * E2E: 187 passed / 100 skipped (mobile), no failures
Conflicts:
- AGENTS.md: auto-merged, no manual resolution (both #1402 and #1404
  added rows in the same anti-patterns block; git's three-way merger
  correctly interleaved them).
- web/includes/View/AdminAdminsAddView.php: drop the `server_script`
  ctor param (gone at #1404), keep `can_grant_owner` (added during
  #1402's adversarial review for the owner-flag privilege escalation
  defense-in-depth).
- web/pages/admin.admins.php: drop `server_script:` from the
  AdminAdminsAddView() call, keep `can_grant_owner:`. The pre-loop
  `$serverscript` variable wasn't built on this branch anyway (it
  went at #1404's first commit) so removing the kwarg is a pure
  syntactic resolution.
- web/themes/default/page_admin_admins_add.tpl: replace the legacy
  `{$server_script nofilter}` echo with #1404's explanatory `{* *}`
  comment block, keep #1402's massive new submit-handler wiring
  block in full (lines 338+).
@rumblefrog
rumblefrog added this pull request to the merge queue May 17, 2026
Merged via the queue into main with commit 05659f2 May 17, 2026
8 checks passed
@rumblefrog
rumblefrog deleted the fix/1402-rewire-dead-js-helpers branch May 17, 2026 23:40
@github-actions github-actions Bot locked and limited conversation to collaborators May 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit follow-up: hard-broken admin surfaces from removed sourcebans.js helpers (#1397 cluster)

1 participant