diff --git a/AGENTS.md b/AGENTS.md index 4a07e6b20..b5c7be7c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,25 @@ Quick rules: Run from the repo root. All commands are idempotent. +**Host shell:** `./sbpp.sh` is a bash wrapper for Linux / macOS / Git +Bash / WSL. On **Windows PowerShell**, do **not** invoke `./sbpp.sh`. +Read the matching arm in `sbpp.sh` and run the underlying +`docker compose` / `docker compose exec` line directly. Agents on a +`win32` / PowerShell host MUST follow that rule. + +Critical PowerShell translations (env overrides are load-bearing; +without `-e DB_NAME=sourcebans_test`, PHPUnit hits the seeded +`sourcebans` panel DB because the web container's compose env wins +over `phpunit.xml`): + +```powershell +# PHPUnit (sourcebans_test only) +docker compose exec -e DB_HOST=db -e DB_PORT=3306 -e DB_NAME=sourcebans_test -e DB_USER=sourcebans -e DB_PASS=sourcebans -e DB_PREFIX=sb -e DB_CHARSET=utf8mb4 web includes/vendor/bin/phpunit -c /var/www/html/web/phpunit.xml --testdox + +# Dev seed (sourcebans) +docker compose exec -e DB_HOST=db -e DB_PORT=3306 -e DB_NAME=sourcebans -e DB_USER=sourcebans -e DB_PASS=sourcebans -e DB_PREFIX=sb -e DB_CHARSET=utf8mb4 web php /var/www/html/web/tests/scripts/seed-dev-db.php +``` + ```sh ./sbpp.sh up # build + start (panel at :8080, admin/admin) ./sbpp.sh down # stop, keep volumes @@ -127,8 +146,10 @@ URLs after `up`: panel `http://localhost:8080` (admin/admin), Adminer The web container bind-mounts `./web`, so PHP edits land on the next request — no restart. Restart only when: -- `composer.json` changed → `./sbpp.sh composer install` -- anything in `docker/` changed → `./sbpp.sh rebuild` +- `composer.json` changed → `./sbpp.sh composer install` (or the + PowerShell `docker compose exec … composer` equivalent on Windows) +- anything in `docker/` changed → `./sbpp.sh rebuild` (or + `docker compose build --no-cache web` on Windows) ## Parallel stacks (subagents / multiple worktrees) @@ -3573,12 +3594,11 @@ contributions without contacting every contributor individually. `
` hydration slot; `DeadJsCallSitesTest` is the per-file forbidden-substring gate so a fork pasting back the v1.x shape gets caught at PR time. Hydration follow-up for the - Add Admin per-server access list LANDED at #1405 — it wires the - per-server checkbox grid onto the shared - `web/scripts/server-tile-hydrate.js` helper (the same one driving - the dashboard Servers widget); no new View property, no new JSON - action, no new cache layer. Sister surface still tracked: #1406 - (Server Groups per-group server cards). + Add Admin Individual servers access later moved from that + checkbox grid onto a `data-multiselect` with page-tail + `option[data-server-host]` hostname hydrate (search-form shape). + Sister surface #1406 (Server Groups per-group server cards) still + rides `server-tile-hydrate.js`. - Raw `` (or `` / ``, async loaders, ES-module `import()`), and a null `currentScript` collapses `SCRIPT_SRC` to the empty string and silently falls back to the bare-relative `./api.php` — i.e. the exact pre-#1433 bug. The three static load sites in the default theme are `core/header.tpl` (top-level panel chrome → `./scripts/api.js`), `page_kickit.tpl`, and `page_blockit.tpl` (iframe surfaces → `../scripts/api.js`); a theme fork that wants to lazy-load needs its own paired endpoint resolver. Pinned by `web/tests/integration/ApiJsEndpointResolutionTest.php` (static) + `web/tests/e2e/specs/flows/kickit-iframe.spec.ts` (runtime). | | Stamp `SB_VERSION` / `MAJOR_REVISION` before compiling SourceMod plugins | `game/addons/sourcemod/scripting/scripts/resolve-plugin-version.sh` → `include/sbpp_version.inc` (included from `sourcebanspp.inc` + `sbpp_checker.sp`). Tiers: `SBPP_RELEASE_VERSION` (release tag in `release.yml`) → `web/configs/version.json` → `git describe` → `dev`. Checked-in `sbpp_version.inc` is the direct-compile fallback. Regression: `web/tests/integration/PluginVersionResolveTest.php`. | @@ -4901,6 +4922,7 @@ contributions without contacting every contributor individually. | Reuse the moderation-queue card layout (admin submissions / protests, mobile-stacked summary rows) | `web/themes/default/css/theme.css` (`.queue-row`, `.queue-row__body`, `.queue-row__date` — #1207 PUB-2). Apply by adding `class="queue-row …"` to the outer `
` and dropping the inline `flex` / `flex-shrink:0` styles from the summary children. | | Add visible row actions to a table-rendered admin list (Edit / Unmute / Remove buttons + responsive mobile-card mirror) | `web/themes/default/page_admin_admins_list.tpl` is the density reference: icon-only `` / ` + +
+ {/if} + {if $can_edit_admins} +
+ + +
+ {/if} +
+ {if $can_delete_admins} + + {/if} + +
+ + + {/if} +
+ {if $can_delete_admins || $can_edit_admins} + + {/if} @@ -77,14 +146,39 @@ {foreach $admins as $admin} - + + {if $can_delete_admins || $can_edit_admins} + + {/if} {if !$hideadminname} {/if} {* #1363: title= surfaces the full SecondsToString breakdown diff --git a/web/themes/default/page_comms.tpl b/web/themes/default/page_comms.tpl index cecbe6127..48803245e 100644 --- a/web/themes/default/page_comms.tpl +++ b/web/themes/default/page_comms.tpl @@ -301,7 +301,7 @@ {if $comm.admin} {$comm.admin|escape} {else} - + Unknown {/if}
+ + Name Bans Server group
+ {if (!empty($admin.is_owner)) || ($admin.aid == $current_aid)} + + {else} + + {/if} +
{$admin.user|truncate:1:'':true|upper|escape}
-
{$admin.user|escape}
+
+ {$admin.user|escape} + {if isset($admin.enabled) && $admin.enabled == 0} + Inactive + {/if} +
aid {$admin.aid}
@@ -133,20 +227,27 @@ {/if} {if $can_delete_admins} - {* #1352: data-action wires the delete button to the inline - page-tail script below, which opens the - `#admins-delete-dialog` for a confirm + reason - prompt, then calls `Actions.AdminsRemove` with the - trimmed reason. The pre-fix `onclick="if (typeof - RemoveAdmin === 'function') RemoveAdmin(...)"` was a - silent no-op since #1123 D1 deleted sourcebans.js (which - was the only definer of `RemoveAdmin`). The fallback - href lands on the admins list — there is no legacy GET - handler for `o=remove` (RemoveAdmin always went through - the JSON dispatcher), and adding one would expand scope - beyond the bug; the fallback is a graceful degradation - for the rare case where the JSON dispatcher itself is - missing (e.g. third-party theme that stripped api.js). *} + {if isset($admin.enabled) && $admin.enabled == 0} + + {else} + + {/if} + {else} + + {/if} + + + + + + + + + + {* ============================================================ #1352 — admins-delete row-action wiring (inline page-tail JS). @@ -337,12 +629,8 @@ } } /** - * Flip the busy / loading state on a triggered action button. Calls - * window.SBPP.setBusy when present (theme.js owns the spinner CSS - * contract) and falls back to plain `disabled` so third-party themes - * that strip theme.js still gate against double-clicks. * @param {Element|null} btn - * @param {boolean} [busy] defaults to true + * @param {boolean} [busy] */ function setBusy(btn, busy) { if (!btn) return; @@ -362,13 +650,7 @@ ); } - /** - * Drop one from the count badge. Reads the parenthesised number out - * of the badge's textContent so a third-party theme that wraps the - * count differently still works as long as the testid points at a - * node whose text contains the digits. - * @returns {void} - */ + /** @returns {void} */ function decrementCount() { var el = document.querySelector('[data-testid="admin-count"]'); if (!el) return; @@ -377,66 +659,375 @@ el.textContent = '(' + (n - 1).toLocaleString() + ')'; } - /** @returns {HTMLDialogElement|null} */ - function dialog() { - return /** @type {HTMLDialogElement|null} */ (document.getElementById('admins-delete-dialog')); + /** @returns {number[]} */ + function selectedAids() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:checked'); + var aids = []; + for (var i = 0; i < boxes.length; i++) { + var aid = Number(/** @type {HTMLElement} */ (boxes[i]).getAttribute('data-aid') || 0); + if (aid > 0 && aids.indexOf(aid) === -1) aids.push(aid); + } + return aids; } - /** @returns {HTMLTextAreaElement|null} */ - function reasonInput() { - return /** @type {HTMLTextAreaElement|null} */ (document.getElementById('admins-delete-reason')); + + /** @returns {number[]} */ + function enabledAids() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); + var aids = []; + for (var i = 0; i < boxes.length; i++) { + var aid = Number(/** @type {HTMLElement} */ (boxes[i]).getAttribute('data-aid') || 0); + if (aid > 0 && aids.indexOf(aid) === -1) aids.push(aid); + } + return aids; } - /** @returns {HTMLElement|null} */ - function errorEl() { - var d = dialog(); - return d ? /** @type {HTMLElement|null} */ (d.querySelector('[data-testid="admins-delete-error"]')) : null; + + /** + * Keep desktop-table and mobile-card checkboxes for the same + * aid in lockstep (both surfaces stay in the DOM; only one is + * visible per viewport). + * @param {string} aid + * @param {boolean} on + * @returns {void} + */ + function setRowChecked(aid, on) { + if (!aid) return; + var boxes = document.querySelectorAll('[data-action="admins-select-row"][data-aid="' + aid + '"]'); + for (var i = 0; i < boxes.length; i++) { + var box = /** @type {HTMLInputElement} */ (boxes[i]); + if (box.disabled) continue; + box.checked = on; + } } - /** @param {string} msg */ - function showError(msg) { var e = errorEl(); if (!e) return; e.textContent = msg; e.hidden = false; } - function clearError() { var e = errorEl(); if (!e) return; e.textContent = ''; e.hidden = true; } - /** @type {{aid: string, name: string, fallback: string}|null} */ + /** @returns {void} */ + function syncBulkBar() { + var bar = document.querySelector('[data-testid="admins-bulk-bar"]'); + var countEl = document.querySelector('[data-testid="admins-bulk-count"]'); + var aids = selectedAids(); + if (countEl) countEl.textContent = aids.length + ' selected'; + if (!bar) return; + if (aids.length > 0) { + bar.removeAttribute('hidden'); + /** @type {HTMLElement} */ (bar).style.display = 'flex'; + } else { + bar.setAttribute('hidden', ''); + /** @type {HTMLElement} */ (bar).style.display = 'none'; + } + var enabled = enabledAids(); + var allOn = enabled.length > 0 && aids.length === enabled.length; + var allSome = aids.length > 0 && aids.length < enabled.length; + var allBoxes = document.querySelectorAll('[data-action="admins-select-all"]'); + for (var ai = 0; ai < allBoxes.length; ai++) { + /** @type {HTMLInputElement} */ (allBoxes[ai]).checked = allOn; + /** @type {HTMLInputElement} */ (allBoxes[ai]).indeterminate = allSome; + } + } + + /** + * Chain system.rehash_admins when the handler returned SIDs + * (config.enableadminrehashing). Same shape as Add Admin / + * _admin_edit_helpers fireRehash — never block the UI toast on + * a flaky rehash. + * @param {any} data + * @param {() => void} [then] + * @returns {void} + */ + function fireRehashIfNeeded(data, then) { + var done = typeof then === 'function' ? then : function () {}; + var a = api(), A = actions(); + var rehashSids = ((data && data.rehash) || '').toString(); + if (!a || !A || !A.SystemRehashAdmins || !rehashSids) { + done(); + return; + } + a.call(A.SystemRehashAdmins, { servers: rehashSids }) + .then(done) + .catch(done); + } + + /** @returns {void} */ + function clearSelection() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]'); + for (var i = 0; i < boxes.length; i++) { + /** @type {HTMLInputElement} */ (boxes[i]).checked = false; + } + syncBulkBar(); + } + + /** @type {{aid: string, name: string, fallback: string, mode: string}|null} */ var pending = null; + /** @type {string|null} */ + var pendingBulkOp = null; - /** @param {{aid: string, name: string, fallback: string}} ctx */ - function openDeleteDialog(ctx) { + /** + * @param {string} prefix + * @returns {HTMLDialogElement|null} + */ + function dialogBy(prefix) { + return /** @type {HTMLDialogElement|null} */ (document.getElementById(prefix + '-dialog')); + } + /** + * @param {string} prefix + * @returns {HTMLTextAreaElement|null} + */ + function reasonBy(prefix) { + return /** @type {HTMLTextAreaElement|null} */ (document.getElementById(prefix + '-reason')); + } + /** + * @param {string} prefix + * @returns {HTMLElement|null} + */ + function errorBy(prefix) { + var d = dialogBy(prefix); + return d ? /** @type {HTMLElement|null} */ (d.querySelector('[data-testid="' + prefix + '-error"]')) : null; + } + /** @param {string} prefix @param {string} msg */ + function showError(prefix, msg) { + var e = errorBy(prefix); + if (!e) return; + e.textContent = msg; + e.hidden = false; + } + /** @param {string} prefix */ + function clearError(prefix) { + var e = errorBy(prefix); + if (!e) return; + e.textContent = ''; + e.hidden = true; + } + + /** + * @param {string} prefix + * @param {{aid: string, name: string, fallback: string, mode: string}} ctx + */ + function openDialog(prefix, ctx) { pending = ctx; - var d = dialog(); + var d = dialogBy(prefix); if (!d) { - // Dialog markup missing (third-party theme that stripped - // the partial). Fall back to the admins list landing — - // there's no legacy GET handler for `o=remove`, so we - // can't perform the delete from this code path. Loud no-op - // is preferable to a silent no-op. if (ctx.fallback) window.location.href = ctx.fallback; return; } - var target = d.querySelector('[data-testid="admins-delete-target"]'); + var target = d.querySelector('[data-testid="' + prefix + '-target"]'); if (target) target.textContent = ctx.name || ('admin #' + ctx.aid); - var input = reasonInput(); + var input = reasonBy(prefix); if (input) input.value = ''; - clearError(); + clearError(prefix); d.removeAttribute('hidden'); try { d.showModal(); } catch (_e) { d.setAttribute('open', ''); } - if (input) { try { input.focus(); } catch (_e) { /* focus may throw if hidden */ } } + if (input) { try { input.focus(); } catch (_e2) { /* ignore */ } } } - function closeDeleteDialog() { - var d = dialog(); + /** @param {string} prefix */ + function closeDialog(prefix) { + var d = dialogBy(prefix); if (!d) return; - try { d.close(); } catch (_e) { /* not opened modally */ } + try { d.close(); } catch (_e) { /* ignore */ } d.setAttribute('hidden', ''); pending = null; } + /** + * @param {string} prefix + * @param {string} op + * @param {string} label + */ + function openBulkDialog(prefix, op, label) { + var aids = selectedAids(); + if (!aids.length) return; + pendingBulkOp = op; + var d = dialogBy(prefix); + if (!d) return; + var target = d.querySelector('[data-testid="' + prefix + '-target"]'); + if (target) target.textContent = aids.length + ' ' + label; + var input = reasonBy(prefix); + if (input) input.value = ''; + clearError(prefix); + d.removeAttribute('hidden'); + try { d.showModal(); } + catch (_e) { d.setAttribute('open', ''); } + } + + /** @param {string} prefix */ + function closeBulkDialog(prefix) { + var d = dialogBy(prefix); + if (!d) return; + try { d.close(); } catch (_e) { /* ignore */ } + d.setAttribute('hidden', ''); + pendingBulkOp = null; + } + + /** + * @param {string} op + * @param {Record} extra + * @param {HTMLButtonElement|null} submitBtn + * @param {string} prefix + */ + function runBulk(op, extra, submitBtn, prefix) { + var a = api(), A = actions(); + var aids = selectedAids(); + if (!a || !A || !aids.length) { + setBusy(submitBtn, false); + return; + } + /** @type {Record} */ + var params = { op: op, aids: aids }; + Object.keys(extra || {}).forEach(function (k) { params[k] = extra[k]; }); + setBusy(submitBtn, true); + a.call(A.AdminsBulk, params).then(function (r) { + setBusy(submitBtn, false); + if (!r || r.ok === false) { + var msg = (r && r.error && r.error.message) || 'Unknown error'; + if (prefix) showError(prefix, msg); + toast('error', 'Bulk action failed', msg); + return; + } + var data = r.data || {}; + var applied = data.applied || []; + for (var i = 0; i < applied.length; i++) { + if (op === 'remove' || op === 'deactivate') { + var rows = rowsForAid(String(applied[i])); + for (var j = 0; j < rows.length; j++) { + var row = rows[j]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } + decrementCount(); + } + } + if (prefix) closeBulkDialog(prefix); + clearSelection(); + var title = (data.message && data.message.title) || 'Done'; + var body = (data.message && data.message.body) || ''; + toast(applied.length ? 'success' : 'error', title, body); + fireRehashIfNeeded(data, function () { + if (op === 'set_web_group' || op === 'set_srv_group' || op === 'reactivate') { + window.location.reload(); + } + }); + }); + } + + document.addEventListener('change', function (e) { + var t = /** @type {Element|null} */ (e.target); + if (!t || !t.closest) return; + if (t.matches('[data-action="admins-select-all"]')) { + var on = /** @type {HTMLInputElement} */ (t).checked; + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); + for (var i = 0; i < boxes.length; i++) { + /** @type {HTMLInputElement} */ (boxes[i]).checked = on; + } + syncBulkBar(); + return; + } + if (t.matches('[data-action="admins-select-row"]')) { + var row = /** @type {HTMLInputElement} */ (t); + setRowChecked(row.getAttribute('data-aid') || '', row.checked); + syncBulkBar(); + } + }); + document.addEventListener('click', function (e) { var t = /** @type {Element|null} */ (e.target); if (!t || !t.closest) return; - // Cancel button inside the dialog. if (t.closest('[data-testid="admins-delete-cancel"]')) { e.preventDefault(); - closeDeleteDialog(); + closeDialog('admins-delete'); + return; + } + if (t.closest('[data-testid="admins-deactivate-cancel"]')) { + e.preventDefault(); + closeDialog('admins-deactivate'); + return; + } + if (t.closest('[data-testid="admins-bulk-deactivate-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-deactivate'); + return; + } + if (t.closest('[data-testid="admins-bulk-delete-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-delete'); + return; + } + if (t.closest('[data-testid="admins-bulk-web-group-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-web-group'); + return; + } + if (t.closest('[data-testid="admins-bulk-srv-group-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-srv-group'); + return; + } + if (t.closest('[data-action="admins-bulk-clear"]')) { + e.preventDefault(); + clearSelection(); + return; + } + if (t.closest('[data-action="admins-bulk-deactivate"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-deactivate', 'deactivate', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-reactivate"]')) { + e.preventDefault(); + var aR = api(), AR = actions(); + if (!aR || !AR) return; + runBulk('reactivate', {}, /** @type {HTMLButtonElement|null} */ (t.closest('button')), ''); + return; + } + if (t.closest('[data-action="admins-bulk-delete"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-delete', 'remove', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-web-group"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-web-group', 'set_web_group', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-srv-group"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-srv-group', 'set_srv_group', 'admins'); + return; + } + + var reactivateBtn = /** @type {HTMLElement|null} */ (t.closest('[data-action="admins-reactivate"]')); + if (reactivateBtn) { + e.preventDefault(); + var rAid = reactivateBtn.getAttribute('data-aid') || ''; + var rName = reactivateBtn.getAttribute('data-name') || ('admin #' + rAid); + var a = api(), A = actions(); + if (!a || !A || !rAid) return; + setBusy(reactivateBtn, true); + a.call(A.AdminsReactivate, { aid: Number(rAid) }).then(function (r) { + setBusy(reactivateBtn, false); + if (!r || r.ok === false) { + var msg = (r && r.error && r.error.message) || 'Unknown error'; + toast('error', 'Reactivate failed', msg); + return; + } + var rows = rowsForAid(rAid); + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } + decrementCount(); + toast('success', 'Admin reactivated', rName + ' can log in again.'); + fireRehashIfNeeded(r.data || {}); + }); + return; + } + + var deactivateBtn = /** @type {HTMLElement|null} */ (t.closest('[data-action="admins-deactivate"]')); + if (deactivateBtn) { + e.preventDefault(); + var dAid = deactivateBtn.getAttribute('data-aid') || ''; + var dName = deactivateBtn.getAttribute('data-name') || ('admin #' + dAid); + var a2 = api(), A2 = actions(); + if (!a2 || !A2 || !dAid) return; + openDialog('admins-deactivate', { aid: dAid, name: dName, fallback: '', mode: 'deactivate' }); return; } @@ -447,33 +1038,66 @@ var aid = btn.getAttribute('data-aid') || ''; var name = btn.getAttribute('data-name') || ('admin #' + aid); var fallback = btn.getAttribute('data-fallback-href') || ''; - var a = api(), A = actions(); - if (!a || !A || !aid) { - // No JSON dispatcher available — fall back to the admins - // list (no legacy GET handler exists for `o=remove`). + var a3 = api(), A3 = actions(); + if (!a3 || !A3 || !aid) { if (fallback) window.location.href = fallback; return; } - openDeleteDialog({ aid: aid, name: name, fallback: fallback }); + openDialog('admins-delete', { aid: aid, name: name, fallback: fallback, mode: 'delete' }); }); document.addEventListener('submit', function (e) { var form = /** @type {Element|null} */ (e.target); if (!form || !(/** @type {Element} */ (form)).closest) return; - if (!form.matches('[data-testid="admins-delete-form"]')) return; + + if (form.matches('[data-testid="admins-bulk-deactivate-form"]')) { + e.preventDefault(); + var reasonB = reasonBy('admins-bulk-deactivate'); + var submitB = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-deactivate-submit"]')); + /** @type {Record} */ + var extraB = {}; + if (reasonB && reasonB.value.trim() !== '') extraB.ureason = reasonB.value.trim(); + runBulk('deactivate', extraB, submitB, 'admins-bulk-deactivate'); + return; + } + if (form.matches('[data-testid="admins-bulk-delete-form"]')) { + e.preventDefault(); + var reasonD = reasonBy('admins-bulk-delete'); + var submitD = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-delete-submit"]')); + /** @type {Record} */ + var extraD = {}; + if (reasonD && reasonD.value.trim() !== '') extraD.ureason = reasonD.value.trim(); + runBulk('remove', extraD, submitD, 'admins-bulk-delete'); + return; + } + if (form.matches('[data-testid="admins-bulk-web-group-form"]')) { + e.preventDefault(); + var selW = /** @type {HTMLSelectElement|null} */ (document.getElementById('admins-bulk-web-group-select')); + var submitW = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-web-group-submit"]')); + runBulk('set_web_group', { gid: Number(selW ? selW.value : 0) }, submitW, 'admins-bulk-web-group'); + return; + } + if (form.matches('[data-testid="admins-bulk-srv-group-form"]')) { + e.preventDefault(); + var selS = /** @type {HTMLSelectElement|null} */ (document.getElementById('admins-bulk-srv-group-select')); + var submitS = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-srv-group-submit"]')); + runBulk('set_srv_group', { srv_group_id: Number(selS ? selS.value : 0) }, submitS, 'admins-bulk-srv-group'); + return; + } + + var isDelete = form.matches('[data-testid="admins-delete-form"]'); + var isDeactivate = form.matches('[data-testid="admins-deactivate-form"]'); + if (!isDelete && !isDeactivate) return; e.preventDefault(); if (!pending) return; - var input = reasonInput(); - // Reason is optional for the delete-admin surface (server-side - // handler accepts empty `ureason` and omits the audit suffix). - // Trim whitespace so the audit-log "Reason: " prefix doesn't - // get a blank tail when the operator typed only spaces. + var prefix = isDelete ? 'admins-delete' : 'admins-deactivate'; + var input = reasonBy(prefix); var reason = input ? input.value.trim() : ''; - clearError(); + clearError(prefix); var ctx = pending; - var submitBtn = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-delete-submit"]')); + var submitBtn = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="' + prefix + '-submit"]')); setBusy(submitBtn, true); var a = api(), A = actions(); @@ -487,12 +1111,13 @@ var params = { aid: Number(ctx.aid) }; if (reason !== '') params.ureason = reason; - a.call(A.AdminsRemove, params).then(function (r) { + var action = isDelete ? A.AdminsRemove : A.AdminsDeactivate; + a.call(action, params).then(function (r) { setBusy(submitBtn, false); if (!r || r.ok === false) { var msg = (r && r.error && r.error.message) || 'Unknown error'; - showError(msg); - toast('error', 'Delete failed', msg); + showError(prefix, msg); + toast('error', isDelete ? 'Delete failed' : 'Deactivate failed', msg); return; } var rows = rowsForAid(ctx.aid); @@ -501,17 +1126,41 @@ if (row && row.parentNode) row.parentNode.removeChild(row); } decrementCount(); - closeDeleteDialog(); - toast('success', 'Admin deleted', ctx.name + ' has been removed.'); + closeDialog(prefix); + if (isDelete) { + toast('success', 'Admin deleted', ctx.name + ' has been removed.'); + } else { + toast('success', 'Admin deactivated', ctx.name + ' can no longer log in.'); + } + fireRehashIfNeeded(r.data || {}); }); }); document.addEventListener('cancel', function (e) { var t = /** @type {Element|null} */ (e.target); - if (!t || t.id !== 'admins-delete-dialog') return; - pending = null; - clearError(); + if (!t) return; + if (t.id === 'admins-delete-dialog') { + pending = null; + clearError('admins-delete'); + } else if (t.id === 'admins-deactivate-dialog') { + pending = null; + clearError('admins-deactivate'); + } else if (t.id === 'admins-bulk-deactivate-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-deactivate'); + } else if (t.id === 'admins-bulk-delete-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-delete'); + } else if (t.id === 'admins-bulk-web-group-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-web-group'); + } else if (t.id === 'admins-bulk-srv-group-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-srv-group'); + } }); + + syncBulkBar(); })(); {/literal} diff --git a/web/themes/default/page_admin_bans_protests_archiv.tpl b/web/themes/default/page_admin_bans_protests_archiv.tpl index 55cc04149..a9bf15f59 100644 --- a/web/themes/default/page_admin_bans_protests_archiv.tpl +++ b/web/themes/default/page_admin_bans_protests_archiv.tpl @@ -252,21 +252,30 @@ if (archiv === '2') msg = 'Restore the ban protest for "' + key + '" from the archive?'; else if (archiv === '1') msg = 'Move the ban protest for "' + key + '" to the archive?'; else msg = 'Delete the ban protest for "' + key + '"?'; - if (!window.confirm(msg)) return; - var a = api(), A = actions(); - if (!a || !A || !Number.isFinite(pid)) return; - setBusy(btn, true); - a.call(A.ProtestsRemove, { pid: pid, archiv: archiv }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); - return; - } - var node = document.getElementById('apid_' + pid); - if (node && node.parentNode) node.parentNode.removeChild(node); - var counter = document.getElementById('protcountarchiv'); - if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); - toast('success', 'Done', 'Archive updated.'); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: archiv === '0' ? 'Delete protest' : (archiv === '2' ? 'Restore protest' : 'Archive protest'), + body: msg, + confirmLabel: archiv === '0' ? 'Delete' : (archiv === '2' ? 'Restore' : 'Archive'), + danger: archiv === '0', + }).then(function (ok) { + if (!ok) return; + var a = api(), A = actions(); + if (!a || !A || !Number.isFinite(pid)) return; + setBusy(btn, true); + a.call(A.ProtestsRemove, { pid: pid, archiv: archiv }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); + return; + } + var node = document.getElementById('apid_' + pid); + if (node && node.parentNode) node.parentNode.removeChild(node); + var counter = document.getElementById('protcountarchiv'); + if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); + toast('success', 'Done', 'Archive updated.'); + }); }); }); })(); diff --git a/web/themes/default/page_admin_bans_submissions_archiv.tpl b/web/themes/default/page_admin_bans_submissions_archiv.tpl index 5a8a4c661..c9a2456c3 100644 --- a/web/themes/default/page_admin_bans_submissions_archiv.tpl +++ b/web/themes/default/page_admin_bans_submissions_archiv.tpl @@ -263,21 +263,30 @@ if (archiv === '2') msg = 'Restore the ban submission for "' + name + '" from the archive?'; else if (archiv === '1') msg = 'Move the ban submission for "' + name + '" to the archive?'; else msg = 'Delete the ban submission for "' + name + '"?'; - if (!window.confirm(msg)) return; - var a = api(), A = actions(); - if (!a || !A || !Number.isFinite(sid)) return; - setBusy(btn, true); - a.call(A.SubmissionsRemove, { sid: sid, archiv: archiv }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); - return; - } - var node = document.getElementById('asid_' + sid); - if (node && node.parentNode) node.parentNode.removeChild(node); - var counter = document.getElementById('subcountarchiv'); - if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); - toast('success', 'Done', 'Archive updated.'); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: archiv === '0' ? 'Delete submission' : (archiv === '2' ? 'Restore submission' : 'Archive submission'), + body: msg, + confirmLabel: archiv === '0' ? 'Delete' : (archiv === '2' ? 'Restore' : 'Archive'), + danger: archiv === '0', + }).then(function (ok) { + if (!ok) return; + var a = api(), A = actions(); + if (!a || !A || !Number.isFinite(sid)) return; + setBusy(btn, true); + a.call(A.SubmissionsRemove, { sid: sid, archiv: archiv }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); + return; + } + var node = document.getElementById('asid_' + sid); + if (node && node.parentNode) node.parentNode.removeChild(node); + var counter = document.getElementById('subcountarchiv'); + if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); + toast('success', 'Done', 'Archive updated.'); + }); }); }); })(); diff --git a/web/themes/default/page_admin_edit_admins_servers.tpl b/web/themes/default/page_admin_edit_admins_servers.tpl index d2501dc81..eebf79b7c 100644 --- a/web/themes/default/page_admin_edit_admins_servers.tpl +++ b/web/themes/default/page_admin_edit_admins_servers.tpl @@ -14,10 +14,11 @@ keeps the URL bar honest about which sub-page you're on; the data-testid hooks match the issue's edit-form-tabs contract. - The legacy template re-checked boxes via inline JS that drove - LoadServerHost(); the 2026 footer drops sourcebans.js, so this - template renders the persisted hostname server-side from $server_list - and pre-checks via Smarty {if} comparisons against $assigned_servers. + Server groups + individual servers ride data-multiselect (same + shape as page_admin_admins_add.tpl). Wire values stay g{gid} / + s{sid} so the native form POST to admin.edit.adminservers.php + is unchanged. Individual-server option labels hydrate via the + page-tail Actions.ServersHostPlayers loop. *}
@@ -65,17 +66,20 @@
-
+ + - {$group.name|escape} - + {/foreach} -
+
{/if} @@ -89,17 +93,24 @@
-
+ + - {$server.ip|escape}:{$server.port|escape} - + {/foreach} -
+
{/if} @@ -113,3 +124,43 @@ {/if} + + diff --git a/web/themes/default/page_admin_groups_list.tpl b/web/themes/default/page_admin_groups_list.tpl index 6ca57399c..102a83c6d 100644 --- a/web/themes/default/page_admin_groups_list.tpl +++ b/web/themes/default/page_admin_groups_list.tpl @@ -580,34 +580,52 @@ function SbppGroupsSave(event) { } function SbppGroupsDelete(gid, name, btn) { - if (!confirm('Delete group "' + name + '"?')) return; - SbppGroupsSetBusy(btn, true); - sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: 'web' }) - .then(function (r) { - // Leave the button busy on success — the apply handler reloads / - // navigates within 1.5s and re-enabling it would let the operator - // queue a second delete on the now-stale row. - if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete group', + body: 'Delete group "' + name + '"?', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + SbppGroupsSetBusy(btn, true); + sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: 'web' }) + .then(function (r) { + // Leave the button busy on success — the apply handler reloads / + // navigates within 1.5s and re-enabling it would let the operator + // queue a second delete on the now-stale row. + if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); + return; + } + SbppGroupsSetBusy(btn, false); SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - return; - } - SbppGroupsSetBusy(btn, false); - SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - }); + }); + }); } function SbppServerGroupsDelete(gid, name, type, btn) { - if (!confirm('Delete group "' + name + '"?')) return; - SbppGroupsSetBusy(btn, true); - sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: String(type) }) - .then(function (r) { - if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete group', + body: 'Delete group "' + name + '"?', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + SbppGroupsSetBusy(btn, true); + sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: String(type) }) + .then(function (r) { + if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); + return; + } + SbppGroupsSetBusy(btn, false); SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - return; - } - SbppGroupsSetBusy(btn, false); - SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - }); + }); + }); } // --- Live bitmask preview (#1258) --- diff --git a/web/themes/default/page_admin_servers_list.tpl b/web/themes/default/page_admin_servers_list.tpl index 5d369bc33..a12bdb0ce 100644 --- a/web/themes/default/page_admin_servers_list.tpl +++ b/web/themes/default/page_admin_servers_list.tpl @@ -289,35 +289,42 @@ var sid = Number(btn.dataset.sid); var label = btn.dataset.label || ('Server #' + sid); if (!Number.isFinite(sid) || sid <= 0) return; - if (!window.confirm('Delete ' + label + '?\n\nThis removes the server entry and any group/admin mappings. Bans logged from it are retained.')) { - return; - } - var api = window.sb && window.sb.api; - if (!api || !window.Actions) return; - setBusy(btn, true); - api.call(window.Actions.ServersRemove, { sid: sid }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - if (r && r.error && window.SBPP && window.SBPP.showToast) { - window.SBPP.showToast({ kind: 'error', title: 'Delete failed', body: r.error.message || 'Unknown error' }); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete server', + body: 'Delete ' + label + '?\n\nThis removes the server entry and any group/admin mappings. Bans logged from it are retained.', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + var api = window.sb && window.sb.api; + if (!api || !window.Actions) return; + setBusy(btn, true); + api.call(window.Actions.ServersRemove, { sid: sid }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + if (r && r.error && window.SBPP && window.SBPP.showToast) { + window.SBPP.showToast({ kind: 'error', title: 'Delete failed', body: r.error.message || 'Unknown error' }); + } + return; + } + // The handler returns { remove: 'sid_', counter: { srvcount: } }; + // mirror what applyApiResponse does in sourcebans.js without + // dragging in the legacy module. + var d = (r && r.data) || {}; + if (d.remove) { + var node = document.getElementById(String(d.remove)); + if (node && node.parentNode) node.parentNode.removeChild(node); + } + if (d.counter && typeof d.counter.srvcount !== 'undefined') { + var counter = document.getElementById('srvcount'); + if (counter) counter.textContent = String(d.counter.srvcount); + } + if (window.SBPP && window.SBPP.showToast) { + window.SBPP.showToast({ kind: 'success', title: 'Server deleted', body: label }); } - return; - } - // The handler returns { remove: 'sid_', counter: { srvcount: } }; - // mirror what applyApiResponse does in sourcebans.js without - // dragging in the legacy module. - var d = (r && r.data) || {}; - if (d.remove) { - var node = document.getElementById(String(d.remove)); - if (node && node.parentNode) node.parentNode.removeChild(node); - } - if (d.counter && typeof d.counter.srvcount !== 'undefined') { - var counter = document.getElementById('srvcount'); - if (counter) counter.textContent = String(d.counter.srvcount); - } - if (window.SBPP && window.SBPP.showToast) { - window.SBPP.showToast({ kind: 'success', title: 'Server deleted', body: label }); - } + }); }); }); })(); diff --git a/web/themes/default/page_admin_settings_logs.tpl b/web/themes/default/page_admin_settings_logs.tpl index a8fd983b2..756334b8e 100644 --- a/web/themes/default/page_admin_settings_logs.tpl +++ b/web/themes/default/page_admin_settings_logs.tpl @@ -283,11 +283,20 @@ * Truncate the log table by hitting the legacy `?log_clear=true` * endpoint on this page (admin.settings.php's TRUNCATE branch). We * full-page nav so the freshly-empty list paints without a JSON dance. - * Confirm() so a misclick on the danger button doesn't nuke history. + * Confirm dialog so a misclick on the danger button doesn't nuke history. */ window.clearLogs = function () { - if (!window.confirm('Clear the entire system log? This cannot be undone.')) return; - window.location.href = 'index.php?p=admin&c=settings§ion=logs&log_clear=true'; + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Clear system log', + body: 'Clear the entire system log? This cannot be undone.', + confirmLabel: 'Clear log', + danger: true, + }).then(function (ok) { + if (!ok) return; + window.location.href = 'index.php?p=admin&c=settings§ion=logs&log_clear=true'; + }); }; })(); {/literal} diff --git a/web/themes/default/page_admin_settings_settings.tpl b/web/themes/default/page_admin_settings_settings.tpl index d84ef6b27..18f9bd64a 100644 --- a/web/themes/default/page_admin_settings_settings.tpl +++ b/web/themes/default/page_admin_settings_settings.tpl @@ -617,7 +617,9 @@ window.clearCacheBtn = function () { if (!window.sb || !window.sb.api || !window.Actions) return; window.sb.api.call(window.Actions.SystemClearCache, {}).then(function () { - window.alert('Cache cleared.'); + if (window.SBPP && typeof window.SBPP.showToast === 'function') { + window.SBPP.showToast({ kind: 'success', title: 'Cache cleared' }); + } }); }; diff --git a/web/themes/default/page_admin_settings_themes.tpl b/web/themes/default/page_admin_settings_themes.tpl index b6f4cae2e..508e5ddc1 100644 --- a/web/themes/default/page_admin_settings_themes.tpl +++ b/web/themes/default/page_admin_settings_themes.tpl @@ -150,12 +150,20 @@ */ window.applyTheme = function (theme) { if (!theme) return; - if (!window.confirm('Switch the panel theme to "' + theme + '"? Every visitor will see the new theme on their next request.')) return; - if (!window.sb || !window.sb.api || !window.Actions) return; - window.sb.api.callOrAlert(window.Actions.SystemApplyTheme, { theme: theme }).then(function (env) { - if (env && env.ok && env.data && env.data.reload) { - window.location.reload(); - } + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Switch theme', + body: 'Switch the panel theme to "' + theme + '"? Every visitor will see the new theme on their next request.', + confirmLabel: 'Switch theme', + }).then(function (ok) { + if (!ok) return; + if (!window.sb || !window.sb.api || !window.Actions) return; + window.sb.api.callOrAlert(window.Actions.SystemApplyTheme, { theme: theme }).then(function (env) { + if (env && env.ok && env.data && env.data.reload) { + window.location.reload(); + } + }); }); }; })(); diff --git a/web/themes/default/page_bans.tpl b/web/themes/default/page_bans.tpl index 26eb7abb8..1d9d949b2 100644 --- a/web/themes/default/page_bans.tpl +++ b/web/themes/default/page_bans.tpl @@ -432,7 +432,7 @@
{$ban.sname|escape} - {if empty($ban.aname)}deleted{else}{$ban.aname|escape}{/if} + {if empty($ban.aname)}Unknown{else}{$ban.aname|escape}{/if} dbs` +// reads below are suppressed inline. + +/** + * Add a column only when it isn't already present. + * + * @param callable(string, string, string): void $ensure + */ +$ensureColumn = static function (\Database $dbs, string $tableSuffix, string $column, string $alterSql): void { + $dbs->query( + 'SELECT COUNT(*) AS c FROM information_schema.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() ' + . 'AND TABLE_NAME = :table ' + . 'AND COLUMN_NAME = :column' + ); + $dbs->bind(':table', $dbs->getPrefix() . '_' . $tableSuffix); + $dbs->bind(':column', $column); + $row = $dbs->single(); + + if (is_array($row) && (int) ($row['c'] ?? 0) > 0) { + return; + } + + $dbs->query($alterSql); + $dbs->execute(); +}; + +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'admins', + 'enabled', + 'ALTER TABLE `:prefix_admins` ADD COLUMN `enabled` TINYINT(1) NOT NULL DEFAULT 1' +); +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'bans', + 'admin_name', + 'ALTER TABLE `:prefix_bans` ADD COLUMN `admin_name` VARCHAR(64) NOT NULL DEFAULT \'\'' +); +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'comms', + 'admin_name', + 'ALTER TABLE `:prefix_comms` ADD COLUMN `admin_name` VARCHAR(64) NOT NULL DEFAULT \'\'' +); + +// Backfill snapshots from live admin rows. Idempotent: only empty snapshots. +// @phpstan-ignore variable.undefined +$this->dbs->query( + 'UPDATE `:prefix_bans` AS BA' + . ' INNER JOIN `:prefix_admins` AS AD ON BA.aid = AD.aid' + . ' SET BA.admin_name = AD.user' + . ' WHERE BA.admin_name = \'\'' +); +// @phpstan-ignore variable.undefined +$this->dbs->execute(); + +// @phpstan-ignore variable.undefined +$this->dbs->query( + 'UPDATE `:prefix_comms` AS CO' + . ' INNER JOIN `:prefix_admins` AS AD ON CO.aid = AD.aid' + . ' SET CO.admin_name = AD.user' + . ' WHERE CO.admin_name = \'\'' +); +// @phpstan-ignore variable.undefined +$this->dbs->execute(); + +return true; diff --git a/web/updater/index.php b/web/updater/index.php index ec3f4944e..474375bee 100644 --- a/web/updater/index.php +++ b/web/updater/index.php @@ -10,6 +10,7 @@ require_once('Updater.php'); $updater = new Updater($GLOBALS['PDO']); +\Sbpp\Auth\AdminsSchema::clearCache(); \Sbpp\View\Renderer::render($theme, new \Sbpp\View\UpdaterView( updates: array_values(array_map('strval', $updater->getMessageStack())), diff --git a/web/updater/store.json b/web/updater/store.json index 10e643d19..0378c8f28 100644 --- a/web/updater/store.json +++ b/web/updater/store.json @@ -48,5 +48,6 @@ "807": "807.php", "808": "808.php", "809": "809.php", - "810": "810.php" + "810": "810.php", + "811": "811.php" }