Skip to content

Commit fb13141

Browse files
committed
feat(admins): soft-retire, durable issuer names, and bulk actions (#1509)
Close deactivate/reactivate polish plus admins.bulk with list checkboxes, keeping ban/comm Admin cells on snapshot names after hard delete.
1 parent 6ea340d commit fb13141

18 files changed

Lines changed: 1321 additions & 10 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4888,6 +4888,7 @@ contributions without contacting every contributor individually.
48884888
| Edit a docs page or add a new one (the Astro + Starlight site published at sbpp.github.io) | `docs/src/content/docs/<group>/<slug>.md` (or `.mdx` when the page uses tabs / cards / asides — e.g. `getting-started/quickstart.mdx`, `setup/mariadb.mdx`). New pages also need a sidebar entry in `docs/astro.config.mjs` (the `sidebar:` array). Site config + theme tokens live in `docs/astro.config.mjs` + `docs/src/styles/sbpp.css`. The Starlight chrome ships from `@astrojs/starlight`; layout overrides land under `docs/src/components/` (see `ThemeProvider.astro` for the canonical override shape). Local dev: `cd docs && npm install && npm run dev`. CI gates: `.github/workflows/docs-build.yml` (per-PR build), `docs-deploy-trigger.yml` (main → repository_dispatch into sbpp.github.io), `docs-screenshots.yml` (gated on the `affects-ui` label, runs `docs/scripts/capture.mjs`). Source of truth is here; sbpp.github.io is the deploy shell only (#1333). |
48894889
| Refresh installer / panel screenshots used in docs pages | `docs/scripts/capture.mjs` (Playwright; `npm run capture` in `docs/`). Output lands under `docs/src/assets/auto/{install,panel}/<stable-slug>.png` so docs pages keep referencing the same path across runs. CI does this automatically on PRs labelled `affects-ui`; locally run after `./sbpp.sh up`. STEAM_API_KEY is the all-zero dummy `00000000000000000000000000000000`. |
48904890
| Add a JSON action | `web/api/handlers/_register.php` + `web/api/handlers/<topic>.php` |
4891+
| Soft-retire / hard-delete admins, keep ban+comm issuer names, or bulk-select on the admins list (#1509) | Soft-retire: `admins.enabled` + `admins.deactivate` / `admins.reactivate` in `web/api/handlers/admins.php` (Active/Inactive chips + dialogs in `page_admin_admins_list.tpl`). Hard delete still snapshots `bans.admin_name` / `comms.admin_name` before DELETE (migration `811.php`). Issuer display: `COALESCE(NULLIF(*.admin_name, ''), AD.user)` → template paints **Unknown**, never "deleted admin" on the Admin cell (comments still say "deleted admin" per #1500). Bulk: `admins.bulk` (`op` = `deactivate` \| `reactivate` \| `remove` \| `set_web_group` \| `set_srv_group`, partial `applied`/`skipped`) + checkbox column / sticky bar in `page_admin_admins_list.tpl`. Guards: no self on deactivate/remove; owners skipped. Tests: `AdminsTest` + `AdminEnabledAttributionTest` + `admin-deactivate-bulk.spec.ts`. |
48914892
| Add or audit a publicly-reachable, unauthenticated auth surface (anything in `web/api/handlers/auth.php` or sibling registered as `requireAuth: false`) without leaking per-account state | The reference shape is `api_auth_lost_password` + `_api_auth_lost_password_generic_response` in `web/api/handlers/auth.php` (#1456). All reachable branches MUST return the same envelope; operator-side toggles (e.g. `config.enablenormallogin`) MAY surface as a per-toggle error code because the value is the same for every caller. The pre-#1456 shape branched on `not_registered` / `mail_failed` and let an unauthenticated visitor enumerate registered admin emails one request at a time by reading the painted toast back. See "Public auth surfaces: response-shape uniformity" in Conventions for the full contract (audit-log discipline, DB-write gating, SMTP gating, the documented response-time residual risk) + the matching Anti-patterns entry. Regression guards: `web/tests/api/AuthTest.php::testLostPasswordResponseIsIdenticalForKnownAndUnknownEmail` (byte-for-byte wire assertion) + `web/tests/api/__snapshots__/auth/lost_password_generic.json` (locked envelope) + `web/tests/e2e/specs/flows/lostpassword-toast.spec.ts` (chrome-side parity: same painted toast for known + unknown emails). Sibling surfaces still subject to follow-up (documented under the convention): `api_auth_login` branches its `Api::redirect()` target on per-account state via `?m=…` flags. |
48924893
| Resolve / override the JSON-API endpoint URL the client-side `sb.api.call(...)` POSTs to | `web/scripts/api.js` (`resolveEndpoint()` — runs once at script-load, computes `new URL('../api.php', document.currentScript.src).href`). The script lives at `/scripts/api.js` regardless of which page loads it, so resolving `../api.php` against the script's own URL lands on the panel-root `/api.php` for top-level page renders, iframe-routed surfaces (`pages/admin.kickit.php` / `pages/admin.blockit.php`), AND subdir installs (`https://host/sourcebans/` → script at `…/scripts/api.js` → endpoint at `…/api.php`). The endpoint stays writable on `sb.api` so callers can swap it; do not edit the resolver to a bare `'./api.php'` literal — that's the pre-#1433 regression shape that 404s every iframe round-trip (`./api.php` resolves against the iframe's document URL `/pages/admin.kickit.php` → `/pages/api.php`, no such route). **Load via static `<script src="…">` only** — `document.currentScript` is `null` when the script is appended programmatically (`document.createElement('script')`, `<script>document.write(...)</script>`, 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). |
48934894
| 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`. |

web/api/handlers/_register.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
Api::register('admins.remove', 'api_admins_remove', ADMIN_OWNER | ADMIN_DELETE_ADMINS);
6161
Api::register('admins.deactivate', 'api_admins_deactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS);
6262
Api::register('admins.reactivate', 'api_admins_reactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS);
63+
Api::register('admins.bulk', 'api_admins_bulk', ADMIN_OWNER | ADMIN_DELETE_ADMINS | ADMIN_EDIT_ADMINS);
6364
Api::register('admins.edit_perms', 'api_admins_edit_perms', ADMIN_OWNER | ADMIN_EDIT_ADMINS);
6465
Api::register('admins.generate_password', 'api_admins_generate_password', 0, true);
6566

web/api/handlers/admins.php

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ function api_admins_remove(array $params): array
117117
* blocks panel login and SourceMod admin load via `enabled = 0`.
118118
*
119119
* @param array{aid?: int|string, ureason?: string} $params
120-
* @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}}
120+
* @return array{aid: int, enabled: int, rehash: string|null, message: array{title: string, body: string, kind: string}}
121121
*/
122122
function api_admins_deactivate(array $params): array
123123
{
@@ -169,7 +169,7 @@ function api_admins_deactivate(array $params): array
169169
* Restore a soft-retired admin (`enabled = 1`).
170170
*
171171
* @param array{aid?: int|string, ureason?: string} $params
172-
* @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}}
172+
* @return array{aid: int, enabled: int, rehash: string|null, message: array{title: string, body: string, kind: string}}
173173
*/
174174
function api_admins_reactivate(array $params): array
175175
{
@@ -214,6 +214,251 @@ function api_admins_reactivate(array $params): array
214214
];
215215
}
216216

217+
/**
218+
* Apply one lifecycle / group op to many admin ids. Partial success:
219+
* owner / self / not-found / already-* rows land in `skipped` and the
220+
* rest still commit. Per-op permission is re-checked inside so a caller
221+
* holding only EDIT_ADMINS cannot deactivate via this entry point.
222+
*
223+
* Inputs:
224+
* - `op` (string, required) — `deactivate` | `reactivate` | `remove`
225+
* | `set_web_group` | `set_srv_group`
226+
* - `aids` (list of int, required, max 100)
227+
* - `ureason` (string, optional) — deactivate / remove
228+
* - `gid` (int, required for set_web_group) — 0 clears web group
229+
* - `srv_group_id` (int, required for set_srv_group) — 0 clears SM group
230+
*
231+
* @param array{
232+
* op?: string,
233+
* aids?: list<int|string>,
234+
* ureason?: string,
235+
* gid?: int|string,
236+
* srv_group_id?: int|string
237+
* } $params
238+
* @return array{
239+
* op: string,
240+
* applied: list<int>,
241+
* skipped: list<array{aid: int, reason: string}>,
242+
* rehash: string|null,
243+
* message: array{title: string, body: string, kind: string}
244+
* }
245+
*/
246+
function api_admins_bulk(array $params): array
247+
{
248+
global $userbank;
249+
250+
$op = trim((string) ($params['op'] ?? ''));
251+
$allowedOps = ['deactivate', 'reactivate', 'remove', 'set_web_group', 'set_srv_group'];
252+
if (!in_array($op, $allowedOps, true)) {
253+
throw new ApiError('validation', 'Unknown bulk op.', 'op');
254+
}
255+
256+
$rawAids = $params['aids'] ?? null;
257+
if (!is_array($rawAids) || $rawAids === []) {
258+
throw new ApiError('validation', 'Select at least one admin.', 'aids');
259+
}
260+
if (count($rawAids) > 100) {
261+
throw new ApiError('validation', 'Bulk selection is limited to 100 admins.', 'aids');
262+
}
263+
264+
$aids = [];
265+
foreach ($rawAids as $raw) {
266+
$aid = (int) $raw;
267+
if ($aid > 0 && !in_array($aid, $aids, true)) {
268+
$aids[] = $aid;
269+
}
270+
}
271+
if ($aids === []) {
272+
throw new ApiError('validation', 'Select at least one admin.', 'aids');
273+
}
274+
275+
$isLifecycle = in_array($op, ['deactivate', 'reactivate', 'remove'], true);
276+
if ($isLifecycle) {
277+
if (!$userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::DeleteAdmins))) {
278+
throw new ApiError('forbidden', 'You do not have permission to deactivate or delete admins.');
279+
}
280+
} else {
281+
if (!$userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::EditAdmins))) {
282+
throw new ApiError('forbidden', 'You do not have permission to edit admin groups.');
283+
}
284+
}
285+
286+
$ureason = trim((string) ($params['ureason'] ?? ''));
287+
$actorAid = (int) $userbank->GetAid();
288+
$gid = (int) ($params['gid'] ?? 0);
289+
$srvGroupId = (int) ($params['srv_group_id'] ?? 0);
290+
291+
if ($op === 'set_web_group' && !array_key_exists('gid', $params)) {
292+
throw new ApiError('validation', 'Web group is required.', 'gid');
293+
}
294+
if ($op === 'set_srv_group' && !array_key_exists('srv_group_id', $params)) {
295+
throw new ApiError('validation', 'Server group is required.', 'srv_group_id');
296+
}
297+
298+
$applied = [];
299+
$skipped = [];
300+
$rehashSids = [];
301+
302+
foreach ($aids as $aid) {
303+
if ($isLifecycle && $aid === $actorAid && in_array($op, ['deactivate', 'remove'], true)) {
304+
$skipped[] = ['aid' => $aid, 'reason' => 'self'];
305+
continue;
306+
}
307+
308+
try {
309+
$result = match ($op) {
310+
'deactivate' => api_admins_deactivate(['aid' => $aid, 'ureason' => $ureason]),
311+
'reactivate' => api_admins_reactivate(['aid' => $aid, 'ureason' => $ureason]),
312+
'remove' => api_admins_remove(['aid' => $aid, 'ureason' => $ureason]),
313+
'set_web_group' => _api_admins_set_web_group($aid, $gid),
314+
'set_srv_group' => _api_admins_set_srv_group($aid, $srvGroupId),
315+
};
316+
$applied[] = $aid;
317+
if (!empty($result['rehash']) && is_string($result['rehash'])) {
318+
foreach (explode(',', $result['rehash']) as $sid) {
319+
$sid = (int) $sid;
320+
if ($sid > 0 && !in_array($sid, $rehashSids, true)) {
321+
$rehashSids[] = $sid;
322+
}
323+
}
324+
}
325+
} catch (ApiError $e) {
326+
$skipped[] = ['aid' => $aid, 'reason' => $e->errorCode];
327+
}
328+
}
329+
330+
$appliedN = count($applied);
331+
$skippedN = count($skipped);
332+
$titles = [
333+
'deactivate' => 'Admins deactivated',
334+
'reactivate' => 'Admins reactivated',
335+
'remove' => 'Admins deleted',
336+
'set_web_group' => 'Web group updated',
337+
'set_srv_group' => 'Server group updated',
338+
];
339+
$verbs = [
340+
'deactivate' => 'deactivated',
341+
'reactivate' => 'reactivated',
342+
'remove' => 'deleted',
343+
'set_web_group' => 'updated',
344+
'set_srv_group' => 'updated',
345+
];
346+
$body = $appliedN . ' ' . $verbs[$op];
347+
if ($skippedN > 0) {
348+
$body .= ', ' . $skippedN . ' skipped';
349+
}
350+
$body .= '.';
351+
352+
return [
353+
'op' => $op,
354+
'applied' => $applied,
355+
'skipped' => $skipped,
356+
'rehash' => $rehashSids ? implode(',', $rehashSids) : null,
357+
'message' => [
358+
'title' => $titles[$op],
359+
'body' => $body,
360+
'kind' => $appliedN > 0 ? 'green' : 'red',
361+
],
362+
];
363+
}
364+
365+
/**
366+
* @return array{rehash: string|null}
367+
*/
368+
function _api_admins_set_web_group(int $aid, int $gid): array
369+
{
370+
$admin = $GLOBALS['PDO']->query(
371+
"SELECT aid, user, password, email, extraflags FROM `:prefix_admins` WHERE aid = :aid"
372+
);
373+
$GLOBALS['PDO']->bind(':aid', $aid);
374+
$admin = $GLOBALS['PDO']->single();
375+
if (!$admin) {
376+
throw new ApiError('not_found', 'Admin not found.');
377+
}
378+
379+
if ($gid > 0) {
380+
$group = $GLOBALS['PDO']->query(
381+
"SELECT gid FROM `:prefix_groups` WHERE gid = :gid AND type != 3"
382+
);
383+
$GLOBALS['PDO']->bind(':gid', $gid);
384+
if (!$GLOBALS['PDO']->single()) {
385+
throw new ApiError('validation', 'Unknown web group.', 'gid');
386+
}
387+
$password = (string) ($admin['password'] ?? '');
388+
$email = (string) ($admin['email'] ?? '');
389+
if ($password === '' || $email === '') {
390+
throw new ApiError(
391+
'missing_credentials',
392+
'Admins need a password and email before you can give them web permissions.',
393+
);
394+
}
395+
}
396+
397+
$persistGid = max(0, $gid);
398+
$GLOBALS['PDO']->query('UPDATE `:prefix_admins` SET `gid` = :gid WHERE `aid` = :aid');
399+
$GLOBALS['PDO']->bind(':gid', $persistGid);
400+
$GLOBALS['PDO']->bind(':aid', $aid);
401+
$GLOBALS['PDO']->execute();
402+
403+
$allservers = _api_admins_rehash_sids($aid);
404+
Log::add(
405+
LogType::Message,
406+
"Admin's Groups Updated",
407+
"Admin ({$admin['user']}) web group has been updated.",
408+
);
409+
410+
return ['rehash' => $allservers ? implode(',', $allservers) : null];
411+
}
412+
413+
/**
414+
* @return array{rehash: string|null}
415+
*/
416+
function _api_admins_set_srv_group(int $aid, int $srvGroupId): array
417+
{
418+
$admin = $GLOBALS['PDO']->query(
419+
"SELECT aid, user, extraflags FROM `:prefix_admins` WHERE aid = :aid"
420+
);
421+
$GLOBALS['PDO']->bind(':aid', $aid);
422+
$admin = $GLOBALS['PDO']->single();
423+
if (!$admin) {
424+
throw new ApiError('not_found', 'Admin not found.');
425+
}
426+
427+
$resolvedGroupName = '';
428+
$persistId = 0;
429+
if ($srvGroupId > 0) {
430+
$GLOBALS['PDO']->query('SELECT id, name FROM `:prefix_srvgroups` WHERE id = :id');
431+
$GLOBALS['PDO']->bind(':id', $srvGroupId);
432+
$row = $GLOBALS['PDO']->single();
433+
if (!$row) {
434+
throw new ApiError('validation', 'Unknown server group.', 'srv_group_id');
435+
}
436+
$resolvedGroupName = (string) ($row['name'] ?? '');
437+
$persistId = (int) $row['id'];
438+
}
439+
440+
$GLOBALS['PDO']->query('UPDATE `:prefix_admins` SET `srv_group` = :name WHERE `aid` = :aid');
441+
$GLOBALS['PDO']->bind(':name', $resolvedGroupName);
442+
$GLOBALS['PDO']->bind(':aid', $aid);
443+
$GLOBALS['PDO']->execute();
444+
445+
$GLOBALS['PDO']->query(
446+
'UPDATE `:prefix_admins_servers_groups` SET `group_id` = :gid WHERE `admin_id` = :aid'
447+
);
448+
$GLOBALS['PDO']->bind(':gid', $persistId > 0 ? $persistId : -1);
449+
$GLOBALS['PDO']->bind(':aid', $aid);
450+
$GLOBALS['PDO']->execute();
451+
452+
$allservers = _api_admins_rehash_sids($aid);
453+
Log::add(
454+
LogType::Message,
455+
"Admin's Groups Updated",
456+
"Admin ({$admin['user']}) server group has been updated.",
457+
);
458+
459+
return ['rehash' => $allservers ? implode(',', $allservers) : null];
460+
}
461+
217462
/**
218463
* Server SIDs that need `sm_rehash` after an admin access change.
219464
*

web/includes/View/AdminAdminsListView.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ final class AdminAdminsListView extends View
3636
* @param string $active_view One of `active` / `inactive` / `all`
3737
* @param string $chip_base_link Base href for the Active/Inactive/All chips
3838
* (search filters preserved; `view=` appended per chip)
39+
* @param list<array{gid: int|string, name: string}> $web_groups
40+
* @param list<array{id: int|string, name: string}> $srv_groups
3941
*/
4042
public function __construct(
4143
public readonly bool $can_list_admins,
@@ -46,6 +48,9 @@ public function __construct(
4648
public readonly array $admins,
4749
public readonly string $active_view = 'active',
4850
public readonly string $chip_base_link = 'index.php?p=admin&c=admins&section=admins',
51+
public readonly array $web_groups = [],
52+
public readonly array $srv_groups = [],
53+
public readonly int $current_aid = 0,
4954
) {
5055
}
5156
}

web/pages/admin.admins.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@
451451
$admin['server_flag_string'] = SmFlagsToSb((string) ($userbank->GetProperty("srv_flags", $admin['aid']) ?? ''));
452452
$admin['web_flag_string'] = BitToString((int) ($userbank->GetProperty("extraflags", $admin['aid']) ?? 0));
453453
$admin['enabled'] = (int) ($admin['enabled'] ?? 1);
454+
$admin['is_owner'] = (((int) ($userbank->GetProperty("extraflags", $admin['aid']) ?? 0)) & ADMIN_OWNER) !== 0;
454455

455456
$lastvisit = $userbank->GetProperty("lastvisit", $admin['aid']);
456457
if (!$lastvisit) {
@@ -513,6 +514,9 @@
513514

514515
$chipBase = 'index.php?p=admin&c=admins&section=admins' . $advSearchString;
515516

517+
$bulkWebGroups = $GLOBALS['PDO']->query('SELECT gid, name FROM `:prefix_groups` WHERE type != 3 ORDER BY name')->resultset();
518+
$bulkSrvGroups = $GLOBALS['PDO']->query('SELECT id, name FROM `:prefix_srvgroups` ORDER BY name')->resultset();
519+
516520
\Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminAdminsListView(
517521
// We pass the can_* gates explicitly rather than splatting
518522
// ...Perms::for($userbank): the helper's @return array<string,bool>
@@ -529,4 +533,7 @@
529533
admins: $admin_list,
530534
active_view: $view,
531535
chip_base_link: $chipBase,
536+
web_groups: $bulkWebGroups,
537+
srv_groups: $bulkSrvGroups,
538+
current_aid: (int) $userbank->GetAid(),
532539
));

0 commit comments

Comments
 (0)