Skip to content

Commit 05659f2

Browse files
authored
fix(admin): rewire dead sourcebans.js helpers across admin surfaces (#1402) (#1408)
* fix(admin): rewire dead sourcebans.js helpers across admin surfaces (#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). * fix(admin): address adversarial review findings on #1402 rewire 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
1 parent cd65fca commit 05659f2

22 files changed

Lines changed: 2553 additions & 109 deletions

AGENTS.md

Lines changed: 49 additions & 1 deletion
Large diffs are not rendered by default.

web/api/handlers/admins.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,29 @@ function api_admins_add(array $params): array
134134

135135
if ($serverName === '0' || $serverName === '') $serverName = null;
136136

137+
// #1402 (adversarial review HIGH 1) — OWNER-flag privilege-escalation
138+
// guard. Mirrors api_admins_edit_perms's check (see below in this
139+
// file). Two ways an attacker could land `ADMIN_OWNER` on disk via
140+
// this handler:
141+
// 1. `web_group === 'c'` (Custom permissions) + `mask & ADMIN_OWNER`
142+
// → goes straight onto `:prefix_admins.extraflags`.
143+
// 2. `web_group === 'n'` (New admin group) + `mask & ADMIN_OWNER`
144+
// → baked into the new `:prefix_groups.flags`, after which any
145+
// future admin assigned to that group inherits it.
146+
// Both shapes carry the OWNER bit in the inbound `mask` param, so a
147+
// single check on `$mask & ADMIN_OWNER` covers both. The bare check
148+
// does NOT close the existing-group escalation surface (assigning a
149+
// pre-existing OWNER-bearing group to a new admin via `web_group` =
150+
// an integer > 0); that path was pre-existing pre-#1402 and is
151+
// tracked separately. The UI side mirrors this by gating the OWNER
152+
// checkbox itself on `can_grant_owner` so non-owners don't see the
153+
// affordance — defense in depth.
154+
if (!$userbank->HasAccess(WebPermission::Owner) && ($mask & ADMIN_OWNER)) {
155+
Log::add(LogType::Warning, 'Hacking Attempt',
156+
$userbank->GetProperty('user') . ' tried to grant OWNER while adding an admin, but doesnt have access.');
157+
return Api::redirect('index.php?p=login&m=no_access');
158+
}
159+
137160
// Validation -------------------------------------------------------
138161
if (empty($name)) {
139162
throw new ApiError('validation', 'You must type a name for the admin.', 'name');

web/includes/View/AdminAdminsAddView.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,20 @@ final class AdminAdminsAddView extends View
2525
* rows for the SourceMod admin-group dropdown.
2626
* @param list<array<string,mixed>> $server_group_list `:prefix_groups`
2727
* rows (type != 3) for the web admin-group dropdown.
28+
* @param bool $can_grant_owner Mirrors `$userbank->HasAccess(WebPermission::Owner)`
29+
* — gates the OWNER web-flag checkbox so a non-owner with
30+
* `ADMIN_ADD_ADMINS` can't see/tick it (the server-side check in
31+
* `api_admins_add` is the load-bearing gate; this is the
32+
* visible-affordance half of the defense-in-depth pair from
33+
* #1402's adversarial review).
2834
*/
2935
public function __construct(
3036
public readonly bool $can_add_admins,
3137
public readonly array $group_list,
3238
public readonly array $server_list,
3339
public readonly array $server_admin_group_list,
3440
public readonly array $server_group_list,
41+
public readonly bool $can_grant_owner,
3542
) {
3643
}
3744
}

web/pages/admin.admins.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@
153153
server_list: $server_list,
154154
server_admin_group_list: $server_admin_group_list,
155155
server_group_list: $server_group_list,
156+
// #1402 adversarial review HIGH 1: gate the OWNER checkbox so a
157+
// non-owner with ADMIN_ADD_ADMINS can't see/tick it. Paired with
158+
// the server-side guard in api_admins_add (the load-bearing
159+
// half; this is the visible-affordance half).
160+
can_grant_owner: $userbank->HasAccess(WebPermission::Owner),
156161
));
157162
echo '</div></div><!-- /.admin-sidebar-content + /.admin-sidebar-shell — opened by new AdminTabs(...) above -->';
158163
return;

web/pages/admin.bans.php

Lines changed: 226 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -895,44 +895,228 @@ function ProcessBan()
895895
// template can render the placeholder when wired up.
896896
player_name: '',
897897
));
898-
// Tail script: defines the legacy `ProcessGroupBan()` and
899-
// `CheckGroupBan()` globals that `page_admin_bans_groups.tpl`
900-
// binds via `onclick=`. Other supporting globals (`LoadGroupBan`,
901-
// `TickSelectAll`) used to live in `web/scripts/sourcebans.js`,
902-
// deleted at #1123 D1; the buttons reference them too. The
903-
// group-ban surface is therefore partially-broken on default
904-
// (the 'ban URL' submit calls into LoadGroupBan which is undefined)
905-
// — this is a pre-existing #1123 follow-up, tracked separately
906-
// from #1275. We preserve the legacy globals here so the
907-
// remaining flows (e.g. an external theme that ships LoadGroupBan)
908-
// keep working.
898+
// #1402: page-tail dispatcher for the group-ban surface. Replaces
899+
// the dead `ProcessGroupBan` / `CheckGroupBan` / `LoadGroupBan` /
900+
// `TickSelectAll` globals (the v1.x sourcebans.js helpers, deleted
901+
// at #1123 D1). The pre-fix `<script>` block here defined
902+
// `ProcessGroupBan` + `CheckGroupBan` but BOTH still called
903+
// `LoadGroupBan(...)` — which lived in the deleted sourcebans.js —
904+
// so every "Add group ban" click threw
905+
// `ReferenceError: LoadGroupBan is not defined`. Same shape on the
906+
// "Tick all" button (`TickSelectAll` was also in sourcebans.js).
907+
//
908+
// The replacement uses the existing `Actions.BansGroupBan` +
909+
// `Actions.BansBanMemberOfGroup` API pair (both already registered
910+
// in `_register.php`; the issue body's suggestion to ship a new
911+
// `Actions.GroupbanCheck` is unnecessary — the existing
912+
// `bans.group_ban` IS the URL-parse step the legacy `LoadGroupBan`
913+
// performed first, and `bans.ban_member_of_group` is the bulk
914+
// banning step it chained to). The page-tail dispatcher binds via
915+
// `data-action="groupban-*"` per the canonical-shape rule from
916+
// AGENTS.md "Add a confirm + reason modal …" — no more legacy
917+
// global helpers in scope.
909918
echo <<<'JS'
910919
<script type="text/javascript">
911-
function ProcessGroupBan()
912-
{
913-
if (!$('groupurl').value) {
914-
$('groupurl.msg').setHTML('You must enter the group link of the group you are banning');
915-
$('groupurl.msg').setStyle('display', 'block');
916-
} else {
917-
$('groupurl.msg').setHTML('');
918-
$('groupurl.msg').setStyle('display', 'none');
919-
LoadGroupBan($('groupurl').value, "no", "no", $('groupreason').value, "");
920+
(function () {
921+
'use strict';
922+
923+
function api() { return (window.sb && window.sb.api) || null; }
924+
function actions() { return window.Actions || null; }
925+
/**
926+
* Local wrapper around window.SBPP.setBusy with a `disabled`-only
927+
* fallback (mirror of SbppGroupsAddSetBusy in page_admin_groups_add.tpl).
928+
*/
929+
function setBusy(btn, busy) {
930+
if (!btn) return;
931+
var S = window.SBPP;
932+
if (S && typeof S.setBusy === 'function') S.setBusy(btn, busy);
933+
else btn.disabled = busy === undefined ? true : !!busy;
920934
}
921-
}
922-
function CheckGroupBan()
923-
{
924-
var last = 0;
925-
for (var i=0;$('chkb_' + i);i++) {
926-
if($('chkb_' + i).checked == true) {
927-
last = $('chkb_' + i).value;
935+
function toast(kind, title, body, redir) {
936+
var S = window.SBPP;
937+
if (S && typeof S.showToast === 'function') {
938+
S.showToast({ kind: kind, title: title, body: body || '' });
928939
}
929-
}
930-
for (var i=0;$('chkb_' + i);i++) {
931-
if($('chkb_' + i).checked == true) {
932-
LoadGroupBan($('chkb_' + i).value, "yes", "yes", $('groupreason').value, last);
940+
if (redir) {
941+
// Match the v1.x post-success redirect: the operator wants to
942+
// see the freshly-populated banlist, not the now-empty form.
943+
setTimeout(function () { window.location.href = redir; }, 1500);
933944
}
934945
}
935-
}
946+
function showMsg(id, msg, show) {
947+
var el = document.getElementById(id);
948+
if (!el) return;
949+
el.textContent = msg;
950+
el.style.display = show ? 'block' : 'none';
951+
}
952+
/**
953+
* Status indicator for the bulk-from-friends path: surfaces the
954+
* "Banning all members of <grp>" intermediate state while the API
955+
* fans out across multiple groups. Falls through to a no-op if the
956+
* #steamGroupStatus div is absent (single-URL submit path).
957+
*/
958+
function status(html) {
959+
var el = document.getElementById('steamGroupStatus');
960+
if (el) el.innerHTML = html;
961+
}
962+
963+
/**
964+
* Wraps the two-step chain that the v1.x `LoadGroupBan(uri, isgrp,
965+
* queue, reason, last)` helper performed: first parse the URL into
966+
* a group name (Actions.BansGroupBan), then enumerate + ban the
967+
* members (Actions.BansBanMemberOfGroup). The `queue` and `last`
968+
* args drive the post-bulk success toast — `queue==="yes" &&
969+
* grpurl===last` is the final tick of the bulk loop, so we redirect
970+
* the operator to the banlist; otherwise we just append to the
971+
* status panel and let the loop keep firing.
972+
*/
973+
function loadGroupBan(groupuri, isgrpurl, queue, reason, last, submitBtn) {
974+
var a = api(), A = actions();
975+
if (!a || !A) return Promise.resolve();
976+
// #1402 adversarial review MEDIUM 4: defensive .catch() arms on
977+
// BOTH the outer Actions.BansGroupBan and inner
978+
// Actions.BansBanMemberOfGroup chains so a throw inside either
979+
// success callback (or a sb.api.call internal failure) doesn't
980+
// leave the submit button busy forever. The legacy single-URL
981+
// path and the bulk-from-friends loop both share this helper,
982+
// so a flaky branch in either path is silently captured here.
983+
return a.call(A.BansGroupBan, {
984+
groupuri: groupuri,
985+
isgrpurl: isgrpurl,
986+
queue: queue,
987+
reason: reason,
988+
last: last,
989+
}).then(function (r) {
990+
if (!r || r.ok === false || !r.data || !r.data.grpname) {
991+
setBusy(submitBtn, false);
992+
var em = (r && r.error && r.error.message) || 'Error parsing the group url.';
993+
showMsg('groupurl.msg', em, true);
994+
toast('error', 'Group ban failed', em);
995+
return;
996+
}
997+
var d = r.data;
998+
// Surface the "Please wait…" toast the legacy helper used
999+
// to render via $('steamGroupStatus').setHTML(...).
1000+
status('<em>Banning all members of <strong>' +
1001+
String(d.grpname).replace(/[<>&"]/g, function (c) {
1002+
return { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c] || c;
1003+
}) + '</strong>&hellip;</em>');
1004+
1005+
return a.call(A.BansBanMemberOfGroup, {
1006+
grpurl: d.grpname,
1007+
queue: d.queue,
1008+
reason: d.reason,
1009+
last: d.last,
1010+
}).then(function (r2) {
1011+
if (!r2 || r2.ok === false || !r2.data) {
1012+
setBusy(submitBtn, false);
1013+
var em2 = (r2 && r2.error && r2.error.message) || 'Failed to ban group members.';
1014+
toast('error', 'Group ban failed', em2);
1015+
status('');
1016+
return;
1017+
}
1018+
var d2 = r2.data;
1019+
var amt = d2.amount || { total: 0, banned: 0, before: 0, failed: 0 };
1020+
// The legacy helper only emitted a final success toast on
1021+
// the last iteration of the bulk loop (and always on the
1022+
// single-URL path). Mirror that contract: queue==='no'
1023+
// is the single-URL path; queue==='yes' && grpurl===last
1024+
// is the final tick.
1025+
if (d2.queue === 'no' || (d2.queue === 'yes' && String(d2.grpurl) === String(d2.last))) {
1026+
setBusy(submitBtn, false);
1027+
var body = 'Banned ' + (amt.total - amt.before - amt.failed) + '/' +
1028+
amt.total + ' players. ' + amt.before +
1029+
' were already banned, ' + amt.failed + ' failed.';
1030+
toast('success', 'Group banned', body, 'index.php?p=banlist');
1031+
status('<strong>' + body + '</strong>');
1032+
}
1033+
}).catch(function (err2) {
1034+
// Inner-call defensive: release the button + surface
1035+
// the error so a bulk loop doesn't silently stall
1036+
// after one row throws.
1037+
setBusy(submitBtn, false);
1038+
toast('error', 'Group ban failed', String(err2 && err2.message ? err2.message : err2));
1039+
status('');
1040+
});
1041+
}).catch(function (err) {
1042+
// Outer-call defensive: same shape as the inner catch.
1043+
setBusy(submitBtn, false);
1044+
toast('error', 'Group ban failed', String(err && err.message ? err.message : err));
1045+
status('');
1046+
});
1047+
}
1048+
1049+
// ---- single-URL submit ----
1050+
document.addEventListener('click', function (e) {
1051+
var t = e.target;
1052+
if (!t || !t.closest) return;
1053+
var btn = t.closest('[data-action="groupban-submit"]');
1054+
if (!btn) return;
1055+
e.preventDefault();
1056+
var urlEl = document.getElementById('groupurl');
1057+
var reasonEl = document.getElementById('groupreason');
1058+
if (!urlEl || !urlEl.value.trim()) {
1059+
showMsg('groupurl.msg', 'You must enter the group link of the group you are banning', true);
1060+
return;
1061+
}
1062+
showMsg('groupurl.msg', '', false);
1063+
setBusy(btn, true);
1064+
loadGroupBan(urlEl.value.trim(), 'no', 'no', reasonEl ? reasonEl.value : '', '', btn);
1065+
});
1066+
1067+
// ---- bulk-from-friends submit ----
1068+
document.addEventListener('click', function (e) {
1069+
var t = e.target;
1070+
if (!t || !t.closest) return;
1071+
var btn = t.closest('[data-action="groupban-bulk-submit"]');
1072+
if (!btn) return;
1073+
e.preventDefault();
1074+
var reasonEl = document.getElementById('groupreason');
1075+
var reason = reasonEl ? reasonEl.value : '';
1076+
// Find the last-ticked checkbox; the legacy helper used this
1077+
// sentinel to know which API response should trigger the final
1078+
// success toast (so a 20-group bulk-ban doesn't fire 20 toasts).
1079+
var last = '';
1080+
for (var i = 0; document.getElementById('chkb_' + i); i++) {
1081+
var cb = document.getElementById('chkb_' + i);
1082+
if (cb.checked) last = cb.value;
1083+
}
1084+
if (!last) {
1085+
toast('error', 'No groups selected', 'Tick at least one group to ban.');
1086+
return;
1087+
}
1088+
setBusy(btn, true);
1089+
for (var j = 0; document.getElementById('chkb_' + j); j++) {
1090+
var cb2 = document.getElementById('chkb_' + j);
1091+
if (cb2.checked) {
1092+
loadGroupBan(cb2.value, 'yes', 'yes', reason, last, btn);
1093+
}
1094+
}
1095+
});
1096+
1097+
// ---- select-all toggle ----
1098+
document.addEventListener('click', function (e) {
1099+
var t = e.target;
1100+
if (!t || !t.closest) return;
1101+
var btn = t.closest('[data-action="groupban-select-all"]');
1102+
if (!btn) return;
1103+
e.preventDefault();
1104+
var allChecked = true;
1105+
var any = false;
1106+
for (var i = 0; document.getElementById('chkb_' + i); i++) {
1107+
any = true;
1108+
if (!document.getElementById('chkb_' + i).checked) { allChecked = false; break; }
1109+
}
1110+
if (!any) return;
1111+
for (var j = 0; document.getElementById('chkb_' + j); j++) {
1112+
document.getElementById('chkb_' + j).checked = !allChecked;
1113+
}
1114+
var tickBtn = document.getElementById('tickswitch');
1115+
var tickLink = document.getElementById('tickswitchlink');
1116+
if (tickBtn) tickBtn.textContent = allChecked ? '+' : '\u2212';
1117+
if (tickLink) tickLink.textContent = allChecked ? 'Select all' : 'Deselect all';
1118+
});
1119+
})();
9361120
</script>
9371121
JS;
9381122
echo '</div></div><!-- /.admin-sidebar-content + /.admin-sidebar-shell -->';
@@ -964,7 +1148,16 @@ function bansBuildComments(array $commentres, $userbank, int $rowId, string $typ
9641148
if ($crow['aid'] == $userbank->GetAid() || $userbank->HasAccess(WebPermission::Owner)) {
9651149
$cdata['editcomlink'] = CreateLinkR('<i class="fas fa-edit fa-lg"></i>', 'index.php?p=banlist&comment=' . $rowId . '&ctype=' . $type . '&cid=' . $crow['cid'], 'Edit Comment');
9661150
if ($userbank->HasAccess(WebPermission::Owner)) {
967-
$cdata['delcomlink'] = "<a href=\"#\" class=\"tip\" title=\"Delete Comment\" target=\"_self\" onclick=\"RemoveComment(" . $crow['cid'] . ",'" . $type . "',-1);\"><i class='fas fa-trash fa-lg'></i></a>";
1151+
// #1402: see web/scripts/comment-actions.js for the dispatcher.
1152+
// $type is the literal letter 'P' (protests) or 'S' (submissions);
1153+
// the api handler's `ctype` arm consumes both. No paginator on the
1154+
// moderation queues, so data-page is the sentinel -1.
1155+
$cdata['delcomlink'] = '<a href="#" class="tip" title="Delete Comment" target="_self"'
1156+
. ' data-action="comment-delete"'
1157+
. ' data-cid="' . (int) $crow['cid'] . '"'
1158+
. ' data-ctype="' . htmlspecialchars($type, ENT_QUOTES, 'UTF-8') . '"'
1159+
. ' data-page="-1"'
1160+
. '><i class="fas fa-trash fa-lg"></i></a>';
9681161
}
9691162
} else {
9701163
$cdata['editcomlink'] = "";

0 commit comments

Comments
 (0)