+ {if $can_delete_admins || $can_edit_admins}
+
+ {if (!empty($admin.is_owner)) || ($admin.aid == $current_aid)}
+
+ {else}
+
+ {/if}
+
+ {/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}
+ {if $can_delete_admins || $can_edit_admins}
+
+
+
+ Select all on this page
+
+
+ {/if}
{foreach $admins as $admin}
-
+
+ {if $can_delete_admins || $can_edit_admins}
+ {if (!empty($admin.is_owner)) || ($admin.aid == $current_aid)}
+
+ {else}
+
+ {/if}
+ {/if}
{$admin.user|truncate:1:'':true|upper|escape}
@@ -222,6 +353,27 @@
{/if}
{if $can_delete_admins}
+ {if isset($admin.enabled) && $admin.enabled == 0}
+
+
+
+ {else}
+
+
+
+ {/if}
+
+
+
+
+
+
+ Deactivate selected admins
+
+ You're about to deactivate 0 admins .
+ They will lose panel login and in-game admin access.
+
+ Reason (optional)
+
+
+
+ Cancel
+ Deactivate
+
+
+
+
+
+
+ Delete selected admins
+
+ You're about to permanently delete 0 admins . This cannot be undone.
+
+ Reason (optional)
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
+ Assign web group
+
+ Set the web group for 0 admins .
+
+ Web group
+
+ No web group
+ {foreach $web_groups as $g}
+ {$g.name|escape}
+ {/foreach}
+
+
+
+ Cancel
+ Apply
+
+
+
+
+
+
+ Assign server group
+
+ Set the SourceMod server group for 0 admins .
+
+ Server group
+
+ No server group
+ {foreach $srv_groups as $g}
+ {$g.name|escape}
+ {/foreach}
+
+
+
+ Cancel
+ Apply
+
+
+
+
{* ============================================================
#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.
*}
-
+ Server groups
+
{foreach $group_list as $group}
-
-
- {$group.name|escape}
-
+ {$group.name|escape}
{/foreach}
-
+
{/if}
@@ -89,17 +93,24 @@
-
+ Individual servers
+
{foreach $server_list as $server}
-
-
- {$server.ip|escape}:{$server.port|escape}
-
+ Loading… ({$server.ip}:{$server.port})
{/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 !$hideadminname}
- {if empty($ban.aname)}deleted {else}{$ban.aname|escape}{/if}
+ {if empty($ban.aname)}Unknown {else}{$ban.aname|escape}{/if}
{/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}
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"
}