diff --git a/web/includes/View/AdminAdminsSearchView.php b/web/includes/View/AdminAdminsSearchView.php index e78dd35cd..7c10b8f0a 100644 --- a/web/includes/View/AdminAdminsSearchView.php +++ b/web/includes/View/AdminAdminsSearchView.php @@ -22,16 +22,11 @@ * `{load_template file="admin.admins.search"}` Smarty plugin. * * The form submits as a plain `GET` to `?p=admin&c=admins` with one - * parameter per populated filter (`name`, `name_match`, `steamid`, - * `steam_match`, `admemail`, `admemail_match`, `webgroup`, - * `srvadmgroup`, `srvgroup`, `admwebflag[]`, `admsrvflag[]`, - * `server`). admin.admins.php AND-combines every non-empty filter — - * see #1207 ADM-4. No CSRF field — search is read-only. - * - * `name_match` / `admemail_match` were added in #1231 so Login and - * E-mail can be flipped between exact / partial mode the way SteamID - * already could; defaults are partial ('1') to preserve pre-#1231 - * substring behaviour for legacy URLs. + * parameter per populated filter (`name`, `steamid`, `admemail`, + * `webgroup`, `srvadmgroup`, `srvgroup`, `admwebflag[]`, `admsrvflag[]`, + * `server`). Text filters are always partial (`LIKE %…%`). + * admin.admins.php AND-combines every non-empty filter. No CSRF + * field — search is read-only. * * `$active_filter_*` mirror the corresponding $_GET keys so the * template can pre-fill the form without splattering @@ -40,14 +35,11 @@ * given $_GET shape came from a modern submit, a legacy * `advType=…&advSearch=…` URL, or nothing at all. * - * #1303 — collapsible disclosure - * ------------------------------ * The form is wrapped in a `
` * default-collapsed disclosure so the unfiltered admin list paints - * above the fold. `$has_active_filters` (derived from the nine - * `active_filter_*` value slots — match-mode toggles don't count - * because they always carry a default) drives the `[open]` attribute, - * so any post-submit page paints with the form expanded. The chrome + * above the fold. `$has_active_filters` (derived from the + * `active_filter_*` value slots) drives the `[open]` attribute, so + * any post-submit page paints with the form expanded. The chrome * mirrors `core/admin_sidebar.tpl`'s mobile `
` pattern * (chevron + label + `prefers-reduced-motion: reduce` override). The * count badge ("Filters · N active") rides `$active_filter_count`. @@ -88,10 +80,8 @@ final class AdminAdminsSearchView extends View * `admsrvflag[]` values. * @param int $active_filter_count Number of non-empty filter * value slots — drives the `` count badge ("Filters - * · N active") and `$has_active_filters`. Match-mode toggles - * (`name_match` / `steam_match` / `admemail_match`) are NOT - * counted: they always carry a default ('0' or '1') and only - * refine the matching filter, they don't filter on their own. + * · N active") and `$has_active_filters`. Empty multi-select + * arrays count as zero. * @param bool $has_active_filters Convenience boolean derived from * `$active_filter_count > 0`. The template uses it to decide * whether the disclosure paints `
` (post-submit @@ -107,11 +97,8 @@ public function __construct( public readonly array $admwebflag_list, public readonly array $admsrvflag_list, public readonly string $active_filter_name = '', - public readonly string $active_filter_name_match = '1', public readonly string $active_filter_steamid = '', - public readonly string $active_filter_steam_match = '0', public readonly string $active_filter_admemail = '', - public readonly string $active_filter_admemail_match = '1', public readonly string $active_filter_webgroup = '', public readonly string $active_filter_srvadmgroup = '', public readonly string $active_filter_srvgroup = '', diff --git a/web/includes/View/EditAdminDetailsView.php b/web/includes/View/EditAdminDetailsView.php index 2d63b7c3f..ff9227298 100644 --- a/web/includes/View/EditAdminDetailsView.php +++ b/web/includes/View/EditAdminDetailsView.php @@ -10,8 +10,9 @@ * The page handler (`admin.edit.admindetails.php`) gates entry on * `ADMIN_OWNER | ADMIN_EDIT_ADMINS` (or self-edit) before reaching the * template, so the View doesn't carry its own access boolean. `$change_pass` - * is a per-request capability flag from the handler — true when the current - * user is allowed to set the target admin's password (root or self). + * is always true here: anyone allowed to open this page may also set + * the target's password. Edit-admins callers cannot open owner targets; + * that block lives in the page handler. * * The property set is intentionally identical to the legacy handler's * `$theme->assign(...)` calls so the existing `$theme->display(...)` path diff --git a/web/includes/system-functions.php b/web/includes/system-functions.php index 4030a6e57..858789c01 100644 --- a/web/includes/system-functions.php +++ b/web/includes/system-functions.php @@ -36,10 +36,12 @@ * still build link strings server-side. * * NOTE: the `$tooltip`-bearing arm picks up the `tip` / `perm` CSS - * class (legacy default theme); the bare arm has no class. The HTML - * is whitespace-padded between the opening and closing tag for - * legacy-template compatibility — the v1.x consumer relied on the - * leading + trailing space when concatenating links inline. + * class (legacy default theme) and emits `data-tooltip` for the + * themed tip in `sb.js` (not the native `title=` bubble). The bare + * arm has no class. The HTML is whitespace-padded between the + * opening and closing tag for legacy-template compatibility — the + * v1.x consumer relied on the leading + trailing space when + * concatenating links inline. */ function CreateLinkR(string $title, string $url, string $tooltip = '', string $target = '_self', bool $wide = false, string $onclick = ''): string { @@ -50,7 +52,7 @@ function CreateLinkR(string $title, string $url, string $tooltip = '', string $t ]; if ($hasTooltip) { $attrs['class'] = $wide ? 'perm' : 'tip'; - $attrs['title'] = $tooltip; + $attrs['data-tooltip'] = $tooltip; } else { $attrs['onclick'] = $onclick; } diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index b3b918d7e..0fa62bd78 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -56,7 +56,12 @@ */ /** @var bool $canListAdmins */ -$canListAdmins = $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::ListAdmins)); +$canListAdmins = $userbank->HasAccess(WebPermission::mask( + WebPermission::Owner, + WebPermission::ListAdmins, + WebPermission::EditAdmins, + WebPermission::DeleteAdmins, +)); /** @var bool $canAddAdmins */ $canAddAdmins = $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::AddAdmins)); /** @var bool $canEditAdmins */ @@ -79,7 +84,7 @@ [ 'slug' => 'admins', 'name' => 'Admins', - 'permission' => ADMIN_OWNER | ADMIN_LIST_ADMINS, + 'permission' => ADMIN_OWNER | ADMIN_LIST_ADMINS | ADMIN_EDIT_ADMINS | ADMIN_DELETE_ADMINS, 'url' => 'index.php?p=admin&c=admins§ion=admins', 'icon' => 'users', ], @@ -195,11 +200,12 @@ * combined filter form and AND the populated filters server-side. * * The new wire format reads each filter from its own query parameter - * (`name`, `steamid`, `steam_match`, `admemail`, `webgroup`, + * (`name`, `steamid`, `admemail`, `webgroup`, * `srvadmgroup`, `srvgroup`, `admwebflag[]`, `admsrvflag[]`, `server`) - * so a single GET submit carries the full filter snapshot. URL-shareable - * searches are preserved by the legacy-shim block below: any incoming - * `?advType=…&advSearch=…` is translated into the new shape so old + * so a single GET submit carries the full filter snapshot. Text filters + * always substring-match (`LIKE %…%`). URL-shareable searches are + * preserved by the legacy-shim block below: any incoming + * `?advType=…&advSearch=…` is translated into the modern shape so old * bookmarks and cross-page links keep working. * * Server-side filters are AND-combined: a request with two non-empty @@ -238,10 +244,9 @@ case 'steam': // The legacy form distinguished exact (`steamid`) from // partial (`steam`) matches as two distinct advTypes. The - // modern form folds both onto `steamid` + `steam_match`. + // modern form folds both onto `steamid` (always partial). if (!isset($_GET['steamid']) || $_GET['steamid'] === '') { - $_GET['steamid'] = $legacyValue; - $_GET['steam_match'] = '1'; + $_GET['steamid'] = $legacyValue; } break; case 'admwebflag': @@ -253,52 +258,26 @@ } } -// 1) Login name (exact or partial against ADM.user). -// `name_match` was added in #1231; default is partial ('1') so -// pre-#1231 URLs (`?name=alice` with no name_match) keep their -// substring semantics. `0` flips to exact. +// 1) Login name (partial against ADM.user). if (!empty($_GET['name']) && is_string($_GET['name'])) { - $partialName = !isset($_GET['name_match']) || (string) $_GET['name_match'] !== '0'; - if ($partialName) { - $where .= " AND ADM.user LIKE ?"; - $whereParams[] = '%' . $_GET['name'] . '%'; - } else { - $where .= " AND ADM.user = ?"; - $whereParams[] = $_GET['name']; - } - $activeFilters['name'] = (string) $_GET['name']; - $activeFilters['name_match'] = $partialName ? '1' : '0'; + $where .= " AND ADM.user LIKE ?"; + $whereParams[] = '%' . $_GET['name'] . '%'; + $activeFilters['name'] = (string) $_GET['name']; } -// 2) Steam ID (exact or partial against ADM.authid). +// 2) Steam ID (partial against ADM.authid). if (!empty($_GET['steamid']) && is_string($_GET['steamid'])) { - $partial = isset($_GET['steam_match']) && (string) $_GET['steam_match'] === '1'; - if ($partial) { - $where .= " AND ADM.authid LIKE ?"; - $whereParams[] = '%' . $_GET['steamid'] . '%'; - } else { - $where .= " AND ADM.authid = ?"; - $whereParams[] = $_GET['steamid']; - } - $activeFilters['steamid'] = (string) $_GET['steamid']; - $activeFilters['steam_match'] = $partial ? '1' : '0'; + $where .= " AND ADM.authid LIKE ?"; + $whereParams[] = '%' . $_GET['steamid'] . '%'; + $activeFilters['steamid'] = (string) $_GET['steamid']; } -// 3) E-mail (exact or partial; `admemail_match` was added in #1231, -// same default-partial shape as `name_match`). Gated on the same -// flag the search box gates the input field on so URL forgery -// can't bypass the visibility gate. +// 3) E-mail (partial). Gated on the same flag the search box gates +// the input field on so URL forgery can't bypass the visibility gate. if (!empty($_GET['admemail']) && is_string($_GET['admemail']) && $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::EditAdmins))) { - $partialEmail = !isset($_GET['admemail_match']) || (string) $_GET['admemail_match'] !== '0'; - if ($partialEmail) { - $where .= " AND ADM.email LIKE ?"; - $whereParams[] = '%' . $_GET['admemail'] . '%'; - } else { - $where .= " AND ADM.email = ?"; - $whereParams[] = $_GET['admemail']; - } - $activeFilters['admemail'] = (string) $_GET['admemail']; - $activeFilters['admemail_match'] = $partialEmail ? '1' : '0'; + $where .= " AND ADM.email LIKE ?"; + $whereParams[] = '%' . $_GET['admemail'] . '%'; + $activeFilters['admemail'] = (string) $_GET['admemail']; } // 4) Web group (`:prefix_groups.gid` -> `:prefix_admins.gid`). @@ -360,7 +339,9 @@ } } -// 8) Server permission flags (multi). +// 8) Server permission flags (multi). SM_* constants are single-char +// strings (`SM_ROOT` = `z`); pass them to HasAccess as strings so the +// srv_flags path runs. SM_ROOT implies every other server flag. $rawSrvFlags = $_GET['admsrvflag'] ?? null; if (is_string($rawSrvFlags)) { $rawSrvFlags = explode(',', $rawSrvFlags); @@ -369,27 +350,29 @@ /** @var list $srvFlagNames */ $srvFlagNames = []; foreach ($rawSrvFlags as $candidate) { - if (is_string($candidate) && preg_match('/^SM_[A-Z_]+$/', $candidate) && defined($candidate)) { + if (is_string($candidate) && preg_match('/^SM_[A-Z0-9_]+$/', $candidate) && defined($candidate)) { $srvFlagNames[] = $candidate; } } if (!empty($srvFlagNames)) { - $flagBits = array_map(fn(string $name): int => (int) constant($name), $srvFlagNames); - $alladmins = $GLOBALS['PDO']->query("SELECT aid, authid FROM `:prefix_admins` WHERE aid > 0")->resultset(); + /** @var list $flagChars */ + $flagChars = array_map(fn(string $name): string => (string) constant($name), $srvFlagNames); + $alladmins = $GLOBALS['PDO']->query("SELECT aid FROM `:prefix_admins` WHERE aid > 0")->resultset(); $accessAids = []; foreach ($alladmins as $row) { + $aid = (int) $row['aid']; $matched = false; - foreach ($flagBits as $fla) { - if ($userbank->HasAccess($fla, $row['authid'])) { + foreach ($flagChars as $fla) { + if ($userbank->HasAccess($fla, $aid)) { $matched = true; break; } } - if (!$matched && $userbank->HasAccess(SM_ROOT, $row['authid'])) { + if (!$matched && $userbank->HasAccess(SM_ROOT, $aid)) { $matched = true; } if ($matched) { - $accessAids[] = (int) $row['aid']; + $accessAids[] = $aid; } } if (empty($accessAids)) { @@ -470,10 +453,10 @@ $admin['web_group'] = $userbank->GetProperty("group_name", $admin['aid']); $admin['server_group'] = $userbank->GetProperty("srv_groups", $admin['aid']); if (empty($admin['web_group']) || $admin['web_group'] == " ") { - $admin['web_group'] = "No Group/Individual Permissions"; + $admin['web_group'] = "No groups"; } if (empty($admin['server_group']) || $admin['server_group'] == " ") { - $admin['server_group'] = "No Group/Individual Permissions"; + $admin['server_group'] = "No groups"; } $GLOBALS['PDO']->query("SELECT count(authid) AS num FROM `:prefix_bans` WHERE aid = :aid"); $GLOBALS['PDO']->bind(':aid', $admin['aid']); diff --git a/web/pages/admin.admins.search.php b/web/pages/admin.admins.search.php index fc3d2fb0f..f9fd84ebe 100644 --- a/web/pages/admin.admins.search.php +++ b/web/pages/admin.admins.search.php @@ -158,19 +158,12 @@ $activeSrvFlags = []; if (is_array($rawSrvFlag)) { foreach ($rawSrvFlag as $f) { - if (is_string($f) && preg_match('/^SM_[A-Z_]+$/', $f)) { + if (is_string($f) && preg_match('/^SM_[A-Z0-9_]+$/', $f)) { $activeSrvFlags[] = $f; } } } -// Match-mode defaults differ per filter (#1231): -// - steam_match defaults to '0' (exact) — typical SteamID -// queries are "find this one admin by their full ID". -// - name_match / admemail_match default to '1' (partial) so -// pre-#1231 URLs (`?name=alice`) keep their substring -// behaviour. Adding the toggle widens the UI without -// regressing the default. $activeFilterName = is_string($_GET['name'] ?? null) ? (string) $_GET['name'] : ''; $activeFilterSteamid = is_string($_GET['steamid'] ?? null) ? (string) $_GET['steamid'] : ''; $activeFilterAdmemail = is_string($_GET['admemail'] ?? null) ? (string) $_GET['admemail'] : ''; @@ -179,26 +172,20 @@ $activeFilterSrvgroup = is_scalar($_GET['srvgroup'] ?? null) ? (string) $_GET['srvgroup'] : ''; $activeFilterServer = is_scalar($_GET['server'] ?? null) ? (string) $_GET['server'] : ''; -// #1303 — the `admemail` filter is permission-gated by -// `$can_editadmin` in both the rendering template AND the page -// handler (`admin.admins.php` ignores `?admemail=` from a user without -// `EditAdmins | Owner`). For URL-forgery cases where a non-admin -// passes `?admemail=foo`, the input is hidden in the form and the -// server narrows nothing; the count must mirror that — otherwise the -// "N active" badge would say "1 active" while every visible filter -// row reads empty. Mirror the gate locally so the count stays an -// honest summary of what the visible form actually filters on. +// The `admemail` filter is permission-gated by `$can_editadmin` in +// both the rendering template AND the page handler (`admin.admins.php` +// ignores `?admemail=` from a user without `EditAdmins | Owner`). For +// URL-forgery cases where a non-admin passes `?admemail=foo`, the +// input is hidden in the form and the server narrows nothing; the +// count must mirror that — otherwise the "N active" badge would say +// "1 active" while every visible filter row reads empty. $canFilterByEmail = $userbank->HasAccess(WebPermission::mask(WebPermission::EditAdmins, WebPermission::Owner)); // #1303 — count populated filter slots so the disclosure can paint a // "Filters · N active" badge on the and auto-expand on -// post-submit. Match-mode selects (`name_match` / `steam_match` / -// `admemail_match`) deliberately don't count: they always carry a -// default ('0' or '1') and only refine the matching filter, they -// don't filter on their own. Empty multi-select arrays count as zero -// even though the array itself "exists" — the user hasn't picked a -// permission. The `admemail` slot only counts when the user can -// actually filter by it (see `$canFilterByEmail` above). +// post-submit. Empty multi-select arrays count as zero even though +// the array itself "exists". The `admemail` slot only counts when +// the user can actually filter by it (see `$canFilterByEmail`). $activeFilterCount = ($activeFilterName !== '' ? 1 : 0) + ($activeFilterSteamid !== '' ? 1 : 0) @@ -220,11 +207,8 @@ admwebflag_list: $webflag, admsrvflag_list: $serverflag, active_filter_name: $activeFilterName, - active_filter_name_match: is_scalar($_GET['name_match'] ?? null) ? (string) $_GET['name_match'] : '1', active_filter_steamid: $activeFilterSteamid, - active_filter_steam_match: is_scalar($_GET['steam_match'] ?? null) ? (string) $_GET['steam_match'] : '0', active_filter_admemail: $activeFilterAdmemail, - active_filter_admemail_match: is_scalar($_GET['admemail_match'] ?? null) ? (string) $_GET['admemail_match'] : '1', active_filter_webgroup: $activeFilterWebgroup, active_filter_srvadmgroup: $activeFilterSrvadmgroup, active_filter_srvgroup: $activeFilterSrvgroup, diff --git a/web/pages/admin.edit.admindetails.php b/web/pages/admin.edit.admindetails.php index 54efed399..dd0cc1aaa 100644 --- a/web/pages/admin.edit.admindetails.php +++ b/web/pages/admin.edit.admindetails.php @@ -12,8 +12,6 @@ global $userbank, $theme; -new \Sbpp\View\AdminTabs([], $userbank, $theme); - require_once __DIR__ . '/_admin_edit_helpers.php'; $adminId = isset($_GET['id']) ? (int) $_GET['id'] : 0; @@ -49,7 +47,6 @@ return; } -$canEditPasswords = $isOwnerEditor || $isSelfEdit; $webBitmask = (int) $userbank->GetProperty('extraflags', $adminId); $webGroupId = (int) $userbank->GetProperty('gid', $adminId); $hasWebPermissions = $webBitmask !== 0 || $webGroupId > 0; @@ -129,33 +126,32 @@ } // Passwords --------------------------------------------------------- + // Editable for anyone who passed the !$canEditTarget gate above. $passwordChanged = false; $serverPassChanged = false; - if ($canEditPasswords) { - if ($newPassword !== '') { - $passwordChanged = true; - if (strlen($newPassword) < MIN_PASS_LENGTH) { - $validationErrors['password'] = 'Your password must be at least ' - . MIN_PASS_LENGTH . ' characters long.'; - } elseif ($newPassword2 === '') { - $validationErrors['password2'] = 'You must confirm the password.'; - } elseif ($newPassword !== $newPassword2) { - $validationErrors['password2'] = "Your passwords don't match."; - } + if ($newPassword !== '') { + $passwordChanged = true; + if (strlen($newPassword) < MIN_PASS_LENGTH) { + $validationErrors['password'] = 'Your password must be at least ' + . MIN_PASS_LENGTH . ' characters long.'; + } elseif ($newPassword2 === '') { + $validationErrors['password2'] = 'You must confirm the password.'; + } elseif ($newPassword !== $newPassword2) { + $validationErrors['password2'] = "Your passwords don't match."; } + } - if ($useServerPass) { - if ($newServerPass !== '') { - $serverPassChanged = true; - } - $existingServerPass = (string) $userbank->GetProperty('srv_password', $adminId); - if ($newServerPass === '' && $existingServerPass === '') { - $validationErrors['a_serverpass'] = 'You must type a server password or uncheck the box.'; - } elseif ($newServerPass !== '' && strlen($newServerPass) < MIN_PASS_LENGTH) { - $validationErrors['a_serverpass'] = 'Your password must be at least ' - . MIN_PASS_LENGTH . ' characters long.'; - } + if ($useServerPass) { + if ($newServerPass !== '') { + $serverPassChanged = true; + } + $existingServerPass = (string) $userbank->GetProperty('srv_password', $adminId); + if ($newServerPass === '' && $existingServerPass === '') { + $validationErrors['a_serverpass'] = 'You must type a server password or uncheck the box.'; + } elseif ($newServerPass !== '' && strlen($newServerPass) < MIN_PASS_LENGTH) { + $validationErrors['a_serverpass'] = 'Your password must be at least ' + . MIN_PASS_LENGTH . ' characters long.'; } } @@ -228,7 +224,7 @@ authid: $authidValue, email: $emailDisplay, a_spass: $haveServerPw, - change_pass: $canEditPasswords, + change_pass: true, )); sbpp_admin_edit_emit_tail_script( diff --git a/web/pages/admin.edit.admingroup.php b/web/pages/admin.edit.admingroup.php index bbdf1385b..fad63d275 100644 --- a/web/pages/admin.edit.admingroup.php +++ b/web/pages/admin.edit.admingroup.php @@ -12,8 +12,6 @@ global $userbank, $theme; -new \Sbpp\View\AdminTabs([], $userbank, $theme); - require_once __DIR__ . '/_admin_edit_helpers.php'; $adminId = isset($_GET['id']) ? (int) $_GET['id'] : 0; diff --git a/web/pages/admin.edit.adminperms.php b/web/pages/admin.edit.adminperms.php index b0e6d57da..4774801e5 100644 --- a/web/pages/admin.edit.adminperms.php +++ b/web/pages/admin.edit.adminperms.php @@ -12,8 +12,6 @@ global $userbank, $theme; -new \Sbpp\View\AdminTabs([], $userbank, $theme); - require_once __DIR__ . '/_admin_edit_helpers.php'; $adminId = isset($_GET['id']) ? (int) $_GET['id'] : 0; @@ -159,6 +157,61 @@ function syncParentFromChildren() { syncParentFromChildren(); }); + function scopeBoxes(scope) { + var table = form.querySelector('table[data-perms-scope="' + scope + '"]'); + if (!table) return []; + if (scope === 'server') { + return Array.prototype.slice.call(table.querySelectorAll('input[data-sm-flag]')); + } + return Array.prototype.slice.call(table.querySelectorAll('input[type="checkbox"]')); + } + + function syncSelectAll(scope) { + var master = form.querySelector('input[data-select-all="' + scope + '"]'); + if (!master) return; + var boxes = scopeBoxes(scope); + var on = 0; + for (var i = 0; i < boxes.length; i++) { + if (boxes[i].checked) on++; + } + master.checked = boxes.length > 0 && on === boxes.length; + master.indeterminate = on > 0 && on < boxes.length; + } + + function wireSelectAll(scope) { + var master = form.querySelector('input[data-select-all="' + scope + '"]'); + if (!master) return; + master.addEventListener('change', function () { + var on = master.checked; + scopeBoxes(scope).forEach(function (c) { c.checked = on; }); + if (scope === 'web') { + form.querySelectorAll('input[data-parent]').forEach(function (parent) { + var name = parent.getAttribute('data-parent'); + var children = form.querySelectorAll('input[data-child="' + name + '"]'); + var anyOn = false; + for (var i = 0; i < children.length; i++) { + if (children[i].checked) { anyOn = true; break; } + } + parent.checked = anyOn; + }); + } + master.indeterminate = false; + syncSelectAll(scope); + }); + syncSelectAll(scope); + } + + wireSelectAll('web'); + wireSelectAll('server'); + + form.addEventListener('change', function (e) { + var t = e.target; + if (!t || t.type !== 'checkbox' || t.hasAttribute('data-select-all')) return; + var table = t.closest('table[data-perms-scope]'); + if (!table) return; + syncSelectAll(table.getAttribute('data-perms-scope')); + }); + // OWNER tick implies every other web flag — keep this passive // visual hint (the server still trusts the bitmask we send). var ownerCb = document.getElementById('p2'); @@ -168,6 +221,9 @@ function syncParentFromChildren() { form.querySelectorAll('input[data-child], input[data-parent]').forEach(function (c) { c.checked = true; }); + var settingsCb = document.getElementById('p26'); + if (settingsCb) settingsCb.checked = true; + syncSelectAll('web'); }); } @@ -180,6 +236,7 @@ function syncParentFromChildren() { form.querySelectorAll('input[data-sm-flag]').forEach(function (c) { if (c !== smRootCb) c.checked = true; }); + syncSelectAll('server'); }); } diff --git a/web/pages/admin.edit.adminservers.php b/web/pages/admin.edit.adminservers.php index 5e595ea1f..2bab9ef6a 100644 --- a/web/pages/admin.edit.adminservers.php +++ b/web/pages/admin.edit.adminservers.php @@ -12,8 +12,6 @@ global $userbank, $theme; -new \Sbpp\View\AdminTabs([], $userbank, $theme); - require_once __DIR__ . '/_admin_edit_helpers.php'; $adminId = isset($_GET['id']) ? (int) $_GET['id'] : 0; diff --git a/web/scripts/sb.js b/web/scripts/sb.js index 5154763a1..a548f95d8 100644 --- a/web/scripts/sb.js +++ b/web/scripts/sb.js @@ -262,67 +262,201 @@ }; // --------------------------------------------------------------- - // Tooltips (replaces MooTools `Tips`). - // Reads `title` of "Header::Body" (or just "Body") and shows a - // floating tooltip on hover. + // Tooltips — themed replacement for the MooTools Tips / native + // title= bubble. Event-delegated on document so dynamically added + // rows (and third-party themes that keep emitting `class="tip" + // title="…"`) pick it up without a per-page init call. + // + // Prefer `data-tooltip="Label"` on the trigger. Legacy + // `a.tip[title]` / `button.tip[title]` (CreateLinkR) still works: + // the first hover migrates `title` → `data-tooltip` so the browser + // bubble never races the themed tip. Keep `aria-label` for AT; + // the tip is visual-only (`role="tooltip"` + aria-describedby). // --------------------------------------------------------------- + const SB_TOOLTIP_ID = 'sb-tooltip'; + const SB_TOOLTIP_DELAY_MS = 350; + /** @type {HTMLDivElement | null} */ + let sbTooltipEl = null; + /** @type {Element | null} */ + let sbTooltipActive = null; + /** @type {ReturnType | null} */ + let sbTooltipTimer = null; + + /** + * @returns {HTMLDivElement} + */ + function sbTooltipEnsure() { + if (sbTooltipEl && document.body.contains(sbTooltipEl)) { + return sbTooltipEl; + } + const tip = document.createElement('div'); + tip.id = SB_TOOLTIP_ID; + tip.className = 'sb-tooltip'; + tip.setAttribute('role', 'tooltip'); + tip.hidden = true; + document.body.appendChild(tip); + sbTooltipEl = tip; + return tip; + } + + /** + * @param {Element} el + * @returns {string} + */ + function sbTooltipLabel(el) { + const data = el.getAttribute('data-tooltip'); + if (data !== null && data.trim() !== '') { + return data.trim(); + } + const title = el.getAttribute('title'); + return title ? title.trim() : ''; + } + + /** + * @param {Element} el + * @returns {void} + */ + function sbTooltipAdoptTitle(el) { + if (!el.hasAttribute('title')) { + return; + } + const title = el.getAttribute('title') || ''; + if (!el.hasAttribute('data-tooltip') && title.trim() !== '') { + el.setAttribute('data-tooltip', title); + } + el.removeAttribute('title'); + } + + /** + * @returns {void} + */ + function sbTooltipHide() { + if (sbTooltipTimer !== null) { + clearTimeout(sbTooltipTimer); + sbTooltipTimer = null; + } + if (sbTooltipEl) { + sbTooltipEl.hidden = true; + sbTooltipEl.textContent = ''; + } + if (sbTooltipActive) { + sbTooltipActive.removeAttribute('aria-describedby'); + sbTooltipActive = null; + } + } + + /** + * @param {Element} el + * @returns {void} + */ + function sbTooltipPlace(el) { + const tip = sbTooltipEnsure(); + const r = el.getBoundingClientRect(); + const tr = tip.getBoundingClientRect(); + let top = r.bottom + 6; + if (top + tr.height > window.innerHeight - 8) { + top = r.top - tr.height - 6; + } + let left = r.left + (r.width - tr.width) / 2; + left = Math.max(8, Math.min(left, window.innerWidth - tr.width - 8)); + tip.style.top = Math.round(top) + 'px'; + tip.style.left = Math.round(left) + 'px'; + } + + /** + * @param {Element} el + * @returns {void} + */ + function sbTooltipShow(el) { + sbTooltipAdoptTitle(el); + const text = sbTooltipLabel(el); + if (text === '') { + return; + } + const tip = sbTooltipEnsure(); + tip.textContent = text; + tip.hidden = false; + el.setAttribute('aria-describedby', SB_TOOLTIP_ID); + sbTooltipActive = el; + sbTooltipPlace(el); + } + + /** + * @param {EventTarget | null} target + * @returns {Element | null} + */ + function sbTooltipTriggerFrom(target) { + if (!(target instanceof Element)) { + return null; + } + return target.closest('[data-tooltip], a.tip[title], button.tip[title], a.tip[data-tooltip], button.tip[data-tooltip]'); + } + + document.addEventListener('pointerover', (e) => { + const el = sbTooltipTriggerFrom(e.target); + if (!el || el === sbTooltipActive) { + return; + } + sbTooltipHide(); + sbTooltipTimer = setTimeout(() => { + sbTooltipTimer = null; + sbTooltipShow(el); + }, SB_TOOLTIP_DELAY_MS); + }); + + document.addEventListener('pointerout', (e) => { + const el = sbTooltipTriggerFrom(e.target); + if (!el) { + return; + } + const related = /** @type {MouseEvent} */ (e).relatedTarget; + if (related instanceof Node && el.contains(related)) { + return; + } + sbTooltipHide(); + }); + + document.addEventListener('focusin', (e) => { + const el = sbTooltipTriggerFrom(e.target); + if (!el || !el.hasAttribute('data-tooltip')) { + return; + } + sbTooltipHide(); + sbTooltipShow(el); + }); + + document.addEventListener('focusout', () => { + sbTooltipHide(); + }); + + document.addEventListener('scroll', () => { + sbTooltipHide(); + }, true); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + sbTooltipHide(); + } + }); + + document.addEventListener('pointerdown', () => { + sbTooltipHide(); + }); + + /** + * Back-compat: migrate `title` → `data-tooltip` on a selector set. + * Delegation already covers `[data-tooltip]` and legacy `.tip[title]`; + * this is for callers that want an eager migrate (e.g. strip native + * bubbles before first hover). + * + * @param {string} selector + * @param {{ className?: string }} [opts] ignored; kept for call-site compat + * @returns {void} + */ sb.tooltip = function (selector, opts) { - opts = opts || {}; - const className = opts.className || 'tool-tip'; + void opts; sb.$qsa(selector).forEach((el) => { - const raw = el.getAttribute('title'); - if (!raw) return; - // Save original title and prevent the browser from showing it. - el.setAttribute('data-sb-title', raw); - el.removeAttribute('title'); - - const parts = raw.split('::'); - const head = parts.length > 1 ? parts[0] : ''; - const body = parts.length > 1 ? parts.slice(1).join('::') : raw; - - /** @type {HTMLDivElement | null} */ - let tip = null; - /** @param {MouseEvent} e */ - const show = (e) => { - if (tip) return; - tip = document.createElement('div'); - tip.className = className; - tip.style.position = 'absolute'; - tip.style.zIndex = '10000'; - tip.style.opacity = '0'; - tip.style.pointerEvents = 'none'; - if (head) { - const t = document.createElement('span'); - t.className = 'tool-title'; - t.textContent = head; - tip.appendChild(t); - } - const tx = document.createElement('span'); - tx.className = 'tool-text'; - tx.innerHTML = body; - tip.appendChild(tx); - document.body.appendChild(tip); - position(e); - requestAnimationFrame(() => { - if (!tip) return; - tip.style.transition = 'opacity 200ms'; - tip.style.opacity = '1'; - }); - }; - /** @param {MouseEvent} e */ - const move = (e) => { if (tip) position(e); }; - const hide = () => { if (tip) { tip.remove(); tip = null; } }; - /** @param {MouseEvent} e */ - const position = (e) => { - if (!tip) return; - tip.style.left = (e.pageX + 16) + 'px'; - tip.style.top = (e.pageY + 16) + 'px'; - }; - - el.addEventListener('mouseover', show); - el.addEventListener('mousemove', move); - el.addEventListener('mouseout', hide); - el.addEventListener('click', hide); + sbTooltipAdoptTitle(el); }); }; diff --git a/web/tests/Synthesizer.php b/web/tests/Synthesizer.php index 93199bbf3..b908210f4 100644 --- a/web/tests/Synthesizer.php +++ b/web/tests/Synthesizer.php @@ -70,49 +70,52 @@ final class Synthesizer */ public const SCALES = [ 'small' => [ - 'players' => 80, - 'bans' => 30, - 'comms' => 10, - 'servers' => 5, - 'admins' => 5, - 'groups' => 3, - 'banlog' => 50, - 'submissions' => 10, - 'protests' => 5, - 'comments' => 30, - 'demos' => 15, - 'notes' => 15, - 'audit' => 80, + 'players' => 80, + 'bans' => 30, + 'comms' => 10, + 'servers' => 5, + 'admins' => 5, + 'groups' => 3, + 'server_org_groups' => 3, + 'banlog' => 50, + 'submissions' => 10, + 'protests' => 5, + 'comments' => 30, + 'demos' => 15, + 'notes' => 15, + 'audit' => 80, ], 'medium' => [ - 'players' => 400, - 'bans' => 200, - 'comms' => 100, - 'servers' => 8, - 'admins' => 8, - 'groups' => 4, - 'banlog' => 400, - 'submissions' => 60, - 'protests' => 25, - 'comments' => 200, - 'demos' => 80, - 'notes' => 120, - 'audit' => 600, + 'players' => 400, + 'bans' => 200, + 'comms' => 100, + 'servers' => 8, + 'admins' => 8, + 'groups' => 4, + 'server_org_groups' => 4, + 'banlog' => 400, + 'submissions' => 60, + 'protests' => 25, + 'comments' => 200, + 'demos' => 80, + 'notes' => 120, + 'audit' => 600, ], 'large' => [ - 'players' => 3000, - 'bans' => 2000, - 'comms' => 800, - 'servers' => 12, - 'admins' => 12, - 'groups' => 5, - 'banlog' => 4000, - 'submissions' => 400, - 'protests' => 150, - 'comments' => 1500, - 'demos' => 800, - 'notes' => 800, - 'audit' => 5000, + 'players' => 3000, + 'bans' => 2000, + 'comms' => 800, + 'servers' => 12, + 'admins' => 12, + 'groups' => 5, + 'server_org_groups' => 5, + 'banlog' => 4000, + 'submissions' => 400, + 'protests' => 150, + 'comments' => 1500, + 'demos' => 800, + 'notes' => 800, + 'audit' => 5000, ], ]; @@ -153,8 +156,16 @@ final class Synthesizer private array $adminAids = []; /** @var list */ private array $groupGids = []; + /** Type=3 org groups in `:prefix_groups` (Server groups UI). @var list */ + private array $serverOrgGids = []; /** @var list */ private array $srvGroupIds = []; + /** Parallel to `$srvGroupIds` — `:prefix_srvgroups.name` for admins.srv_group. @var list */ + private array $srvGroupNames = []; + /** @var list */ + private array $srvGroupFlags = []; + /** Baseline `admin/admin` aid (usually 1). */ + private int $ownerAid = 0; /** @var list */ private array $serverSids = []; /** @var list */ @@ -247,6 +258,7 @@ private function execute(): array $counts = []; $counts['groups'] = $this->insertGroups(); + $counts['server_org_groups'] = $this->insertServerOrgGroups(); $counts['srvgroups'] = $this->insertSrvGroups(); $counts['admins'] = $this->insertAdmins(); $counts['servers'] = $this->insertServers(); @@ -328,6 +340,7 @@ private function truncateAndReseedBaseline(): void DB_PREFIX )); $stmt->execute(['admin', 'STEAM_0:0:0', $hash, 'admin@example.test', 16777216]); + $this->ownerAid = (int) $this->pdo->lastInsertId(); } private function loadModIds(): void @@ -554,30 +567,68 @@ private function insertGroups(): int return $count; } + private function insertServerOrgGroups(): int + { + // Server groups (`:prefix_groups` WHERE type = 3) — the + // "Server groups" admin UI section. Distinct from + // `:prefix_srvgroups` (SourceMod flag groups) and type=1 web + // groups. `servers_groups.group_id` points here. + $defs = [ + ['name' => 'EU Cluster', 'flags' => 0], + ['name' => 'NA Cluster', 'flags' => 0], + ['name' => 'Competitive', 'flags' => 0], + ['name' => 'Scrim / Pub', 'flags' => 0], + ['name' => 'Event / LAN', 'flags' => 0], + ]; + $want = min((int) ($this->scale['server_org_groups'] ?? 3), count($defs)); + $stmt = $this->pdo->prepare(sprintf( + 'INSERT INTO `%s_groups` (`type`, `name`, `flags`) VALUES (3, ?, ?)', + DB_PREFIX + )); + for ($i = 0; $i < $want; $i++) { + $stmt->execute([$defs[$i]['name'], $defs[$i]['flags']]); + $this->serverOrgGids[] = (int) $this->pdo->lastInsertId(); + } + return $want; + } + private function insertSrvGroups(): int { $defs = [ - ['name' => 'sm_root', 'flags' => 'z', 'immunity' => 100], - ['name' => 'sm_admin', 'flags' => 'bcdefijklmpq', 'immunity' => 50], - ['name' => 'sm_mod', 'flags' => 'bdjkm', 'immunity' => 25], + ['name' => 'sm_root', 'flags' => 'z', 'immunity' => 100, 'immune' => ''], + ['name' => 'sm_admin', 'flags' => 'bcdefijklmpq', 'immunity' => 50, 'immune' => 'sm_mod'], + ['name' => 'sm_mod', 'flags' => 'bdjkm', 'immunity' => 25, 'immune' => ''], ]; $stmt = $this->pdo->prepare(sprintf( 'INSERT INTO `%s_srvgroups` (`immunity`, `flags`, `name`, `groups_immune`) VALUES (?, ?, ?, ?)', DB_PREFIX )); foreach ($defs as $g) { - $stmt->execute([$g['immunity'], $g['flags'], $g['name'], ' ']); - $this->srvGroupIds[] = (int) $this->pdo->lastInsertId(); + $immune = $g['immune'] !== '' ? $g['immune'] : ' '; + $stmt->execute([$g['immunity'], $g['flags'], $g['name'], $immune]); + $this->srvGroupIds[] = (int) $this->pdo->lastInsertId(); + $this->srvGroupNames[] = $g['name']; + $this->srvGroupFlags[] = $g['flags']; } return count($defs); } private function insertAdmins(): int { - // Synth admins beyond the seeded admin/admin row. Each one gets a - // tapered group + a small extraflags bonus so the admin list - // shows mixed perm masks. All passwords are bcrypt of "admin" - // so a dev can log in as any of them with the same password. + // Owner is always an actor so bans/comms/audit show the logged-in + // admin's name on a realistic share of rows. + if ($this->ownerAid > 0) { + $this->adminAids[] = $this->ownerAid; + if ($this->srvGroupNames !== []) { + $upd = $this->pdo->prepare(sprintf( + 'UPDATE `%s_admins` SET `srv_group` = ?, `srv_flags` = ?, `immunity` = 100 WHERE `aid` = ?', + DB_PREFIX + )); + $upd->execute([$this->srvGroupNames[0], $this->srvGroupFlags[0], $this->ownerAid]); + } + } + + // Synth admins beyond admin/admin. Passwords are bcrypt of "admin". $names = [ 'sentinel', 'fragmaster', @@ -596,8 +647,9 @@ private function insertAdmins(): int $count = min($this->scale['admins'], count($names)); $stmt = $this->pdo->prepare(sprintf( 'INSERT INTO `%s_admins` - (`user`, `authid`, `password`, `gid`, `email`, `validate`, `extraflags`, `immunity`, `lastvisit`) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)', + (`user`, `authid`, `password`, `gid`, `email`, `validate`, `extraflags`, + `immunity`, `srv_group`, `srv_flags`, `lastvisit`) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)', DB_PREFIX )); $hash = password_hash('admin', PASSWORD_BCRYPT); @@ -605,6 +657,13 @@ private function insertAdmins(): int for ($i = 0; $i < $count; $i++) { $name = $names[$i]; $gid = $this->groupGids[$i % count($this->groupGids)]; + $sgIdx = $this->srvGroupNames === [] ? -1 : ($i % count($this->srvGroupNames)); + $srvGroup = $sgIdx >= 0 ? $this->srvGroupNames[$sgIdx] : null; + $srvFlags = $sgIdx >= 0 ? $this->srvGroupFlags[$sgIdx] : null; + // ~1 in 4 carry individual SM flags on top of the group. + if ($srvFlags !== null && mt_rand(0, 3) === 0) { + $srvFlags .= 'o'; + } $extra = mt_rand(0, 3) === 0 ? 16777216 : 0; // ~25% Owner-flagged $immunity = mt_rand(0, 99); $lastvisit = $this->now - mt_rand(60, 60 * 60 * 24 * 30); @@ -616,6 +675,8 @@ private function insertAdmins(): int "$name@example.test", $extra, $immunity, + $srvGroup, + $srvFlags, $lastvisit, ]); $this->adminAids[] = (int) $this->pdo->lastInsertId(); @@ -644,7 +705,7 @@ private function insertServers(): int private function insertServersGroups(): int { - if ($this->serverSids === [] || $this->srvGroupIds === []) { + if ($this->serverSids === [] || $this->serverOrgGids === []) { return 0; } $stmt = $this->pdo->prepare(sprintf( @@ -652,12 +713,23 @@ private function insertServersGroups(): int DB_PREFIX )); $n = 0; - foreach ($this->serverSids as $sid) { - // Each server lives in exactly one srvgroup so the admin - // server-group page renders multiple memberships. - $gid = $this->srvGroupIds[mt_rand(0, count($this->srvGroupIds) - 1)]; - $stmt->execute([$sid, $gid]); + foreach ($this->serverSids as $i => $sid) { + // Every server joins one org group; ~30% also join a second + // so Server groups cards show multi-server + multi-group. + $primary = $this->serverOrgGids[$i % count($this->serverOrgGids)]; + $stmt->execute([$sid, $primary]); $n++; + if (count($this->serverOrgGids) > 1 && mt_rand(0, 9) < 3) { + $secondary = $this->serverOrgGids[mt_rand(0, count($this->serverOrgGids) - 1)]; + if ($secondary !== $primary) { + try { + $stmt->execute([$sid, $secondary]); + $n++; + } catch (\PDOException) { + // UNIQUE (server_id, group_id) — ignore collision. + } + } + } } return $n; } @@ -672,14 +744,29 @@ private function insertAdminsServersGroups(): int DB_PREFIX )); $n = 0; - foreach ($this->adminAids as $aid) { - // Bind each admin to ~half the servers across a mix of srvgroups - // so the admin-server-group matrix isn't trivial. - $bindings = max(1, intdiv(count($this->serverSids), 2)); - for ($j = 0; $j < $bindings; $j++) { - $sid = $this->serverSids[mt_rand(0, count($this->serverSids) - 1)]; - $sgid = $this->srvGroupIds === [] ? 0 : $this->srvGroupIds[mt_rand(0, count($this->srvGroupIds) - 1)]; - $stmt->execute([$aid, 0, $sgid, $sid]); + foreach ($this->adminAids as $aidIdx => $aid) { + // Match api_admins_add shape: + // single server: (aid, srvgroups.id, -1, sid) + // org group: (aid, srvgroups.id, type3.gid, -1) + $smGroupId = $this->srvGroupIds === [] + ? -1 + : $this->srvGroupIds[$aidIdx % count($this->srvGroupIds)]; + + $singleBindings = max(1, intdiv(count($this->serverSids), 2)); + $usedSids = []; + for ($j = 0; $j < $singleBindings; $j++) { + $sid = $this->serverSids[mt_rand(0, count($this->serverSids) - 1)]; + if (isset($usedSids[$sid])) { + continue; + } + $usedSids[$sid] = true; + $stmt->execute([$aid, $smGroupId, -1, $sid]); + $n++; + } + + if ($this->serverOrgGids !== [] && mt_rand(0, 1) === 0) { + $orgGid = $this->serverOrgGids[mt_rand(0, count($this->serverOrgGids) - 1)]; + $stmt->execute([$aid, $smGroupId, $orgGid, -1]); $n++; } } @@ -723,11 +810,18 @@ private function insertSrvGroupsOverrides(): int private function insertOverrides(): int { $defs = [ - ['type' => 'command', 'name' => 'sm_kick', 'flags' => 'd'], - ['type' => 'command', 'name' => 'sm_slay', 'flags' => 'e'], - ['type' => 'command', 'name' => 'sm_ban', 'flags' => 'd'], - ['type' => 'command', 'name' => 'sm_admin', 'flags' => 'a'], - ['type' => 'group', 'name' => 'sm_root', 'flags' => 'z'], + ['type' => 'command', 'name' => 'sm_kick', 'flags' => 'd'], + ['type' => 'command', 'name' => 'sm_slay', 'flags' => 'e'], + ['type' => 'command', 'name' => 'sm_ban', 'flags' => 'd'], + ['type' => 'command', 'name' => 'sm_admin', 'flags' => 'a'], + ['type' => 'command', 'name' => 'sm_map', 'flags' => 'g'], + ['type' => 'command', 'name' => 'sm_cvar', 'flags' => 'h'], + ['type' => 'command', 'name' => 'sm_rcon', 'flags' => 'm'], + ['type' => 'command', 'name' => 'sm_reloadadmins', 'flags' => 'z'], + ['type' => 'command', 'name' => 'sm_who', 'flags' => 'b'], + ['type' => 'group', 'name' => 'sm_root', 'flags' => 'z'], + ['type' => 'group', 'name' => 'sm_ban', 'flags' => 'd'], + ['type' => 'group', 'name' => 'sm_chat', 'flags' => 'j'], ]; $stmt = $this->pdo->prepare(sprintf( 'INSERT INTO `%s_overrides` (`type`, `name`, `flags`) VALUES (?, ?, ?)', @@ -794,18 +888,17 @@ private function insertBans(): int $identityKey = $isIpOnly ? 'ip:' . $player['ip'] : 'auth:' . $player['steam']; $alreadyActive = isset($activeIdentities[$identityKey]); - // State distribution: 60% active (incl. permanent), 25% expired, - // 15% admin-removed (RemoveType U/D + RemovedBy + ureason). + // State distribution: 55% active, 20% naturally expired + // (RemoveType=E + RemovedBy=0, PruneBans shape), 10% expired + // pre-migration (RemoveType NULL), 15% admin-removed (U/D). // Subsequent active rolls for an already-banned identity - // collapse into the expired branch (forced timed + ends < now) - // so the player accumulates a realistic "lifted-then-rebanned" - // history without violating the at-most-one-active invariant. + // collapse into the expired branch. $roll = mt_rand(0, 99); $removeType = null; $removedBy = null; $removedOn = null; $ureason = ''; - if ($roll < 60 && !$alreadyActive) { + if ($roll < 55 && !$alreadyActive) { // Active. Force ends > now if timed. if ($length !== 0 && $ends < $this->now) { $created = $this->now - mt_rand(0, $length - 60 * 60 * 24); @@ -822,6 +915,13 @@ private function insertBans(): int $created = $this->now - $length - mt_rand(60, 60 * 60 * 24 * 7); $ends = $created + $length; } + // ~2/3 of expired rows carry the post-prune RemoveType=E + // shape so ?state=expired exercises both arms. + if (!$alreadyActive && mt_rand(0, 2) !== 0) { + $removeType = \BanRemoval::Expired->value; + $removedBy = 0; + $removedOn = $ends; + } } else { // Admin-removed (~15%). $removeType = mt_rand(0, 1) === 0 @@ -917,7 +1017,10 @@ private function insertComms(): int $length = mt_rand(0, 3) === 0 ? 0 : mt_rand(60 * 30, 60 * 60 * 24 * 14); $ends = $length === 0 ? 0 : $created + $length; - $commType = mt_rand(1, 2); // 1 = mute, 2 = gag + // 1=mute, 2=gag, 3=silence (~15% silence so the Silence chip + // and mute_kind export path are non-empty). + $typeRoll = mt_rand(0, 99); + $commType = $typeRoll < 15 ? 3 : ($typeRoll < 55 ? 1 : 2); $identityKey = 'auth:' . $player['steam'] . ':type:' . $commType; $alreadyActive = isset($activeIdentities[$identityKey]); @@ -995,7 +1098,9 @@ private function insertComments(): int $protestShare = max(0, $total - $banShare - $commShare - $subShare); $stmt = $this->pdo->prepare(sprintf( - 'INSERT INTO `%s_comments` (`bid`, `type`, `aid`, `commenttxt`, `added`) VALUES (?, ?, ?, ?, ?)', + 'INSERT INTO `%s_comments` + (`bid`, `type`, `aid`, `commenttxt`, `added`, `editaid`, `edittime`) + VALUES (?, ?, ?, ?, ?, ?, ?)', DB_PREFIX )); @@ -1029,7 +1134,15 @@ private function insertCommentsForType(\PDOStatement $stmt, array $bodies, strin $parent = $parentIds[mt_rand(0, count($parentIds) - 1)]; $aid = $this->randomAdminAid(); $body = $bodies[mt_rand(0, count($bodies) - 1)]; - $stmt->execute([$parent, $type, $aid, $body, $this->now - mt_rand(60, 60 * 60 * 24 * 60)]); + $added = $this->now - mt_rand(60, 60 * 60 * 24 * 60); + // ~10% edited so banlist/commslist "last edit" chrome paints. + $editaid = null; + $edittime = null; + if (mt_rand(0, 9) === 0) { + $editaid = $this->randomAdminAid(); + $edittime = min($this->now, $added + mt_rand(60, 60 * 60 * 24 * 7)); + } + $stmt->execute([$parent, $type, $aid, $body, $added, $editaid, $edittime]); $n++; } return $n; diff --git a/web/tests/e2e/specs/flows/admin-admins-density.spec.ts b/web/tests/e2e/specs/flows/admin-admins-density.spec.ts index 042a3b82b..7bc140833 100644 --- a/web/tests/e2e/specs/flows/admin-admins-density.spec.ts +++ b/web/tests/e2e/specs/flows/admin-admins-density.spec.ts @@ -124,12 +124,12 @@ test.describe('flow: admin/admins density rework (#1207 ADM-3, ADM-4 / #1275)', await p.searchToggle.click(); // Two filters at once: a deliberately not-matching login - // ("zzznoadminmatchesthis") + a steam_match=1 (partial). The - // login filter narrows the result list to zero — locking the + // ("zzznoadminmatchesthis") + a steamid substring. The login + // filter narrows the result list to zero — locking the // server-side AND contract — while still emitting both // filters on the wire so we can assert two-param submission. await p.searchInput('name').fill('zzznoadminmatchesthis'); - await page.locator('[data-testid="search-admins-steam-match"]').selectOption('1'); + await p.searchInput('steamid').fill('STEAM_0:0:0'); // Single submit → single document navigation. Capture every // document-level request kicked off after we click submit so @@ -146,7 +146,8 @@ test.describe('flow: admin/admins density rework (#1207 ADM-3, ADM-4 / #1275)', const url = new URL(page.url()); expect(url.searchParams.get('name')).toBe('zzznoadminmatchesthis'); - expect(url.searchParams.get('steam_match')).toBe('1'); + expect(url.searchParams.get('steamid')).toBe('STEAM_0:0:0'); + expect(url.searchParams.get('steam_match')).toBeNull(); // #1275 — the form carries `` // so the post-submit URL keeps the user on the admins section. expect(url.searchParams.get('section')).toBe('admins'); @@ -171,15 +172,14 @@ test.describe('flow: admin/admins density rework (#1207 ADM-3, ADM-4 / #1275)', test.skip(testInfo.project.name !== 'chromium', 'Pre-fill contract is project-agnostic; pinning to desktop for runtime.'); const p = new AdminAdminsPage(page); - await page.goto('/index.php?p=admin&c=admins§ion=admins&name=admin&steamid=STEAM_0:0:0&steam_match=1'); + await page.goto('/index.php?p=admin&c=admins§ion=admins&name=admin&steamid=STEAM_0:0:0'); await expect(p.pageMounted).toBeVisible(); // The form re-paints from `$active_filter_*` on the View // DTO; values land in the inputs without JS assistance. await expect(p.searchInput('name')).toHaveValue('admin'); await expect(p.searchInput('steamid')).toHaveValue('STEAM_0:0:0'); - const matchSelect = page.locator('[data-testid="search-admins-steam-match"]'); - await expect(matchSelect).toHaveValue('1'); + await expect(page.locator('[data-testid="search-admins-steam-match"]')).toHaveCount(0); }); // ----- ADM-4 — Clear filters resets form state -------------------- diff --git a/web/tests/e2e/specs/flows/admin-admins-search-disclosure.spec.ts b/web/tests/e2e/specs/flows/admin-admins-search-disclosure.spec.ts index f767f71da..ec6c32ee8 100644 --- a/web/tests/e2e/specs/flows/admin-admins-search-disclosure.spec.ts +++ b/web/tests/e2e/specs/flows/admin-admins-search-disclosure.spec.ts @@ -110,9 +110,7 @@ test.describe('flow: admin/admins advanced-search disclosure (#1303)', () => { test.skip(testInfo.project.name !== 'chromium', 'Count badge contract is project-agnostic; pinning to desktop for runtime.'); // Three populated value slots: `name`, `steamid`, `webgroup`. - // `name_match` / `steam_match` are refinements on `name` / - // `steamid` and must NOT lift the count. - await page.goto('/index.php?p=admin&c=admins§ion=admins&name=admin&name_match=0&steamid=STEAM_0:0:0&steam_match=1&webgroup=1'); + await page.goto('/index.php?p=admin&c=admins§ion=admins&name=admin&steamid=STEAM_0:0:0&webgroup=1'); const p = new AdminAdminsPage(page); await expect(p.pageMounted).toBeVisible(); diff --git a/web/tests/integration/AdminAdminsSearchTest.php b/web/tests/integration/AdminAdminsSearchTest.php index 67c636601..b2c3385c3 100644 --- a/web/tests/integration/AdminAdminsSearchTest.php +++ b/web/tests/integration/AdminAdminsSearchTest.php @@ -192,60 +192,27 @@ public function testLegacyShimDoesNotOverwriteModernFields(): void } /** - * Steam-ID exact / partial split. Modern shape uses - * `steamid=&steam_match=0|1`. The filter is exact when - * `steam_match` is unset or `0`, partial when `1`. + * Steam ID / login / e-mail always substring-match (`LIKE %…%`). + * Legacy `*_match` query params are ignored when present. */ - public function testSteamIdExactMatchSplit(): void + public function testTextFiltersAreAlwaysPartial(): void { $_GET = [ - 'p' => 'admin', - 'c' => 'admins', - 'steamid' => 'STEAM_0:0:1001', - 'steam_match' => '0', + 'p' => 'admin', + 'c' => 'admins', + 'steamid' => 'STEAM_0:0:1001', ]; - $exact = $this->renderAdminsPage(); - $this->assertSame(1, $this->extractAdminCount($exact), 'exact match on alice steamid'); + $exactShape = $this->renderAdminsPage(); + $this->assertSame(1, $this->extractAdminCount($exactShape), 'full steamid still matches alice'); $_GET = [ 'p' => 'admin', 'c' => 'admins', - 'steamid' => 'STEAM_0:0:10', // partial substring (matches 1001/1002/1003 → all 3) - 'steam_match' => '1', - ]; - $partial = $this->renderAdminsPage(); - $this->assertSame(3, $this->extractAdminCount($partial), 'partial match on STEAM_0:0:10 substring'); - } - - /** - * Login-name and E-mail exact / partial split (#1231). - * - * Pre-#1231, only SteamID shipped a ` - data-testid="search-admins-name-match" login exact / partial match (#1231) data-testid="search-admins-steamid" SteamID - data-testid="search-admins-steam-match" SteamID exact / partial match data-testid="search-admins-admemail" email (gated) - data-testid="search-admins-admemail-match" email exact / partial match (#1231; gated) data-testid="search-admins-webgroup" web-group data-testid="search-admins-srvgroup" server-group - - + +
- -
- - -
+ +
{if $can_editadmin}
- -
- - -
+ +
{/if} @@ -269,9 +218,8 @@ id="search-admins-admwebflag" name="admwebflag[]" data-testid="search-admins-admwebflag" - size="6" - multiple - style="height:auto"> + data-multiselect + multiple> {foreach from=$admwebflag_list item="admwebflag"} {/foreach} @@ -286,9 +234,8 @@ id="search-admins-admsrvflag" name="admsrvflag[]" data-testid="search-admins-admsrvflag" - size="6" - multiple - style="height:auto"> + data-multiselect + multiple> {foreach from=$admsrvflag_list item="admsrvflag"} {/foreach} diff --git a/web/themes/default/core/footer.tpl b/web/themes/default/core/footer.tpl index 4a33b3090..a56e49839 100644 --- a/web/themes/default/core/footer.tpl +++ b/web/themes/default/core/footer.tpl @@ -53,8 +53,10 @@ live in web/scripts/sourcebans.js; the new theme drops that bulk file (#1123 D1) so the calls would error. B3 will re-implement the live-server widget via sb.api.call. - - sb.ready/tabs.init/tooltip: legacy MooTools-flavored helpers - replaced by theme.js's vanilla wiring. + - sb.ready/tabs.init: legacy MooTools-flavored helpers + replaced by theme.js's vanilla wiring. Tooltips live in + sb.js (`data-tooltip` / legacy `.tip`) and boot via event + delegation — no footer init call. Footer credits ($version + $git) are kept — pure display, no JS. *} {* /.page *} diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 58745e385..e8a014052 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -804,6 +804,141 @@ html.dark .admin-tabs > [aria-current="page"] { border-bottom-color: var(--brand .textarea { height: auto; padding: 0.625rem 0.75rem; resize: vertical; min-height: 5rem; } .input--with-icon { padding-left: 2rem; } +/* Visually hide an element while keeping it in the accessibility / + form-submit tree. Used by the data-multiselect enhancer after it + builds the themed control around a real so GET + // submit and no-JS still work. After enhance, the native select is + // visually hidden (still in the form) and a trigger + checkbox + // panel mirrors option.selected. + /** + * @param {HTMLSelectElement} select + * @returns {void} + */ + function enhanceMultiselect(select) { + if (select.dataset.mselReady === '1') return; + if (!select.multiple) return; + select.dataset.mselReady = '1'; + + const parent = select.parentNode; + if (!parent) return; + + const wrap = document.createElement('div'); + wrap.className = 'msel'; + wrap.setAttribute('data-msel', 'true'); + parent.insertBefore(wrap, select); + wrap.appendChild(select); + + select.classList.add('visually-hidden'); + select.setAttribute('aria-hidden', 'true'); + select.tabIndex = -1; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'msel__trigger'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); + if (select.id) trigger.setAttribute('aria-controls', select.id + '-msel-panel'); + trigger.innerHTML = + '' + + ''; + + const panel = document.createElement('div'); + panel.className = 'msel__panel'; + panel.setAttribute('role', 'listbox'); + panel.setAttribute('aria-multiselectable', 'true'); + if (select.id) panel.id = select.id + '-msel-panel'; + panel.hidden = true; + + const chips = document.createElement('div'); + chips.className = 'msel__chips'; + + wrap.insertBefore(trigger, select); + wrap.appendChild(panel); + wrap.appendChild(chips); + + const labelEl = /** @type {HTMLElement} */ (trigger.querySelector('.msel__trigger-label')); + + /** @returns {HTMLOptionElement[]} */ + function optionList() { + return Array.prototype.slice.call(select.options); + } + + /** @returns {void} */ + function syncFromSelect() { + const selected = optionList().filter((o) => o.selected && o.value !== ''); + const n = selected.length; + wrap.setAttribute('data-has-value', n > 0 ? 'true' : 'false'); + if (labelEl) { + labelEl.textContent = n === 0 ? 'Any' : (n === 1 ? selected[0].textContent || selected[0].value : (n + ' selected')); + } + panel.querySelectorAll('input[type="checkbox"]').forEach((/** @type {Element} */ el) => { + const input = /** @type {HTMLInputElement} */ (el); + const opt = optionList().find((o) => o.value === input.value); + input.checked = !!(opt && opt.selected); + }); + chips.textContent = ''; + selected.forEach((opt) => { + const chip = document.createElement('span'); + chip.className = 'msel__chip'; + const text = document.createElement('span'); + text.className = 'msel__chip-label'; + text.textContent = opt.textContent || opt.value; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'msel__chip-remove'; + remove.setAttribute('aria-label', 'Remove ' + (opt.textContent || opt.value)); + remove.textContent = '\u00d7'; + remove.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + opt.selected = false; + select.dispatchEvent(new Event('change', { bubbles: true })); + syncFromSelect(); + }); + chip.appendChild(text); + chip.appendChild(remove); + chips.appendChild(chip); + }); + } + + /** @returns {void} */ + function buildPanel() { + panel.textContent = ''; + optionList().forEach((opt) => { + if (opt.value === '') return; + const row = document.createElement('label'); + row.className = 'msel__option'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = opt.value; + input.checked = opt.selected; + input.addEventListener('change', () => { + opt.selected = input.checked; + select.dispatchEvent(new Event('change', { bubbles: true })); + syncFromSelect(); + }); + const span = document.createElement('span'); + span.textContent = opt.textContent || opt.value; + row.appendChild(input); + row.appendChild(span); + panel.appendChild(row); + }); + } + + /** @param {boolean} open */ + function setOpen(open) { + wrap.setAttribute('data-open', open ? 'true' : 'false'); + trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + panel.hidden = !open; + } + + buildPanel(); + syncFromSelect(); + + trigger.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + setOpen(panel.hidden); + }); + + document.addEventListener('click', (e) => { + if (!wrap.contains(/** @type {Node} */ (e.target))) setOpen(false); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !panel.hidden) { + setOpen(false); + trigger.focus(); + } + }); + + select.addEventListener('change', syncFromSelect); + + if (window.lucide) window.lucide.createIcons(); + } + + /** @returns {void} */ + function initMultiselects() { + document.querySelectorAll('select[data-multiselect]').forEach((el) => { + if (el instanceof HTMLSelectElement) enhanceMultiselect(el); + }); + } + if (document.readyState !== 'loading') initMultiselects(); + else document.addEventListener('DOMContentLoaded', initMultiselects); })(); diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index d92b013a2..c6c82f7d4 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -66,6 +66,7 @@
+
@@ -75,7 +76,7 @@ - + @@ -99,37 +100,37 @@ {$admin.nodemocount} w/o demo - - + + -
Web group Immunity Last visit
{$admin.server_group|escape}{$admin.web_group|escape}{$admin.server_group|escape}{$admin.web_group|escape} {$admin.immunity} {$admin.lastvisit|escape} -
+
+
+
+ + {* Mobile cards — paired surface for the global + `@media (max-width: 768px) { .table { display: none } }` + rule. Same display dance as `.ban-cards` / `.log-cards`. *} +
+ {foreach $admins as $admin} +
+
+
+ {$admin.user|truncate:1:'':true|upper|escape} +
+
+
{$admin.user|escape}
+
+ {$admin.web_group|escape} + · {$admin.server_group|escape} + · imm {$admin.immunity} +
+
+ {$admin.bancount} bans + · {$admin.lastvisit|escape} +
+
+
+ {if $can_edit_admins || $can_delete_admins} +
+ {if $can_edit_admins} + + + + + + + + + + + + + {/if} + {if $can_delete_admins} + + {/if} +
+ {/if} +
+ {/foreach} +
@@ -282,10 +357,13 @@ /** * @param {string} aid - * @returns {Element|null} + * @returns {NodeListOf} */ - function rowForAid(aid) { - return document.querySelector('[data-testid="admin-row"][data-id="' + aid + '"]'); + function rowsForAid(aid) { + return document.querySelectorAll( + '[data-testid="admin-row"][data-id="' + aid + '"],' + + '[data-testid="admins-list-card"][data-id="' + aid + '"]' + ); } /** @@ -421,8 +499,11 @@ toast('error', 'Delete failed', msg); return; } - var row = rowForAid(ctx.aid); - if (row && row.parentNode) row.parentNode.removeChild(row); + var rows = rowsForAid(ctx.aid); + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } decrementCount(); closeDeleteDialog(); toast('success', 'Admin deleted', ctx.name + ' has been removed.'); diff --git a/web/themes/default/page_admin_edit_admins_details.tpl b/web/themes/default/page_admin_edit_admins_details.tpl index 044a20902..efdc30329 100644 --- a/web/themes/default/page_admin_edit_admins_details.tpl +++ b/web/themes/default/page_admin_edit_admins_details.tpl @@ -5,9 +5,10 @@ web/includes/View/EditAdminDetailsView.php. The handler gates entry on ADMIN_OWNER | ADMIN_EDIT_ADMINS (or - self-edit) before reaching this template. $change_pass narrows the - in-template form: when the editor lacks password-edit rights (e.g. - a non-owner editing someone else), the password rows hide. + self-edit) before reaching this template. $change_pass is always + true on that path: anyone allowed to edit the target can also set + their password. Edit-admins callers still cannot reach owner + targets (handler-side). The cross-page tab nav (Details / Group / Servers / Permissions) lifts the four legacy admin-edit handlers into a single tabbed UX; @@ -22,7 +23,7 @@

Update identity, login credentials, and the in-game admin password.

-