From c6503d0b2d74f5c2d8feb707eda4076300210225 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 19:38:49 -0300 Subject: [PATCH 01/10] fix(admin): "Edit Admins" permission not working --- web/includes/View/EditAdminDetailsView.php | 5 +++-- web/pages/admin.admins.php | 9 +++++++-- web/pages/admin.edit.admindetails.php | 2 +- web/themes/default/page_admin_edit_admins_details.tpl | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/includes/View/EditAdminDetailsView.php b/web/includes/View/EditAdminDetailsView.php index 2d63b7c3f..c0b0150a8 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). + * mirrors that gate: true when the current user may edit the target + * (including setting their 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/pages/admin.admins.php b/web/pages/admin.admins.php index b3b918d7e..c10cbde06 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', ], diff --git a/web/pages/admin.edit.admindetails.php b/web/pages/admin.edit.admindetails.php index 54efed399..b71f920bb 100644 --- a/web/pages/admin.edit.admindetails.php +++ b/web/pages/admin.edit.admindetails.php @@ -49,7 +49,7 @@ return; } -$canEditPasswords = $isOwnerEditor || $isSelfEdit; +$canEditPasswords = $canEditTarget; $webBitmask = (int) $userbank->GetProperty('extraflags', $adminId); $webGroupId = (int) $userbank->GetProperty('gid', $adminId); $hasWebPermissions = $webBitmask !== 0 || $webGroupId > 0; diff --git a/web/themes/default/page_admin_edit_admins_details.tpl b/web/themes/default/page_admin_edit_admins_details.tpl index 044a20902..0818ecbc8 100644 --- a/web/themes/default/page_admin_edit_admins_details.tpl +++ b/web/themes/default/page_admin_edit_admins_details.tpl @@ -5,9 +5,9 @@ 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 mirrors that + gate: 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; From 43e0ae9199ca1c9f7ba0b35e3b633c53eb57f1e9 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 19:58:06 -0300 Subject: [PATCH 02/10] revive tooltip UI component --- web/includes/system-functions.php | 12 +- web/scripts/sb.js | 250 ++++++++++++++++++++++------- web/themes/default/core/footer.tpl | 6 +- web/themes/default/css/theme.css | 22 +++ 4 files changed, 225 insertions(+), 65 deletions(-) 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/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/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..6b85592c6 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -1361,6 +1361,28 @@ html.dark .table thead tr { background: rgb(255 255 255 / 0.02); } /* ---- Avatar ---- */ .avatar { display: inline-grid; place-items: center; border-radius: 50%; color: white; font-weight: 600; flex-shrink: 0; } +/* ---- Themed tooltip (sb.js) ---- + Trigger with `data-tooltip="Label"` (preferred) or legacy + `class="tip" title="…"`. Floating tip is position:fixed; z-index + sits above the drawer (51) and below toasts (80). */ +.sb-tooltip { + position: fixed; + z-index: 70; + max-width: min(16rem, calc(100vw - 1rem)); + padding: 0.3125rem 0.5rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text); + font-size: var(--fs-xs); + font-weight: 500; + line-height: 1.3; + box-shadow: var(--shadow-lg); + pointer-events: none; + white-space: normal; +} +.sb-tooltip[hidden] { display: none !important; } + /* ---- Toast + drawer + palette ---- */ .toast-stack { position: fixed; top: 1rem; right: 1rem; z-index: 80; display: flex; flex-direction: column; gap: 0.5rem; width: min(22.5rem, calc(100vw - 2rem)); } .toast { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 0.75rem; display: flex; gap: 0.75rem; animation: slide-in .2s ease; } From ca7068de16d3b944476c34afb281025c6eec17b4 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 20:00:22 -0300 Subject: [PATCH 03/10] fix tables disappearing in mobile --- web/pages/admin.admins.php | 4 ++-- web/themes/default/css/theme.css | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index c10cbde06..8ad9f186d 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -475,10 +475,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/themes/default/css/theme.css b/web/themes/default/css/theme.css index 6b85592c6..66e72f448 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -1198,6 +1198,9 @@ html.dark .table thead tr { background: rgb(255 255 255 / 0.02); } column-gap: 0.25rem; justify-content: flex-end; } +.table .row-actions--icons { + flex-wrap: nowrap; +} /* PUB-1 (#1207): banlist column widths + horizontal-scroll fallback. With realistic per-row content the auto-layout previously @@ -1826,6 +1829,11 @@ details.queue-row > summary > .row-actions { .log-card__chevron { transition: none; } } +.admins-list-cards { display: none; } +.admins-list-card { border-bottom: 1px solid var(--border); } +.admins-list-card:last-child { border-bottom: none; } +.admins-list-card__body { padding: 0.75rem 1rem 0.25rem; } + /* ---- Responsive ---- */ [data-mobile-menu] { display: none; } @media (max-width: 1024px) { @@ -1840,6 +1848,7 @@ details.queue-row > summary > .row-actions { `.log-cards` block above for the full rationale). Same `display` dance as `.ban-cards` — hidden at desktop, block at mobile. */ .log-cards { display: block; } + .admins-list-cards { display: block; } /* #1181: filter chip rows wrap onto multiple lines on mobile instead of horizontal-scrolling, so every chip is reachable without a swipe. The .scroll-x desktop affordance is the @@ -1850,6 +1859,7 @@ details.queue-row > summary > .row-actions { @media (min-width: 769px) { .ban-cards { display: none; } .log-cards { display: none; } + .admins-list-cards { display: none; } } /* ---- Utility classes used by templates ---- */ From bcc840d213717ce0b8ca19da8dc861cb5fb9914e Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 20:01:15 -0300 Subject: [PATCH 04/10] refactor admins table, fix styles --- web/themes/default/page_admin_admins_list.tpl | 111 +++++++++++++++--- 1 file changed, 96 insertions(+), 15 deletions(-) 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.'); From 4ba2c3ec850b7cc4bb720b3eafa1ebfaf4585a1c Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 20:33:41 -0300 Subject: [PATCH 05/10] simplify admins search filters and multi-select Text filters on Advanced search always substring-match, so the Exact/Partial toggles are gone. Web and server permission fields use a themed multi-select over the real ` // 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/css/theme.css b/web/themes/default/css/theme.css index 66e72f448..2555fda5b 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); })(); From 123c5f8f73b9f2830b99bf143acd9000ace7cd55 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 28 Jul 2026 21:13:10 -0300 Subject: [PATCH 06/10] improve edit-admin permissions chrome and layout Add per-card select-all and clearer permission group separators. Drop the empty AdminTabs back strip that left dead space under the topbar, and put Back on the shared Details/Group/Servers/Permissions tab row instead. --- web/pages/admin.edit.admindetails.php | 2 - web/pages/admin.edit.admingroup.php | 2 - web/pages/admin.edit.adminperms.php | 61 ++++++- web/pages/admin.edit.adminservers.php | 2 - .../integration/EditAdminTabsChromeTest.php | 106 +++++++++++ web/themes/default/css/theme.css | 80 +++++++++ .../page_admin_edit_admins_details.tpl | 12 +- .../default/page_admin_edit_admins_group.tpl | 12 +- .../default/page_admin_edit_admins_perms.tpl | 165 +++++++++++------- .../page_admin_edit_admins_servers.tpl | 12 +- 10 files changed, 378 insertions(+), 76 deletions(-) create mode 100644 web/tests/integration/EditAdminTabsChromeTest.php diff --git a/web/pages/admin.edit.admindetails.php b/web/pages/admin.edit.admindetails.php index b71f920bb..121c293cb 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; 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/tests/integration/EditAdminTabsChromeTest.php b/web/tests/integration/EditAdminTabsChromeTest.php new file mode 100644 index 000000000..d0d4dae87 --- /dev/null +++ b/web/tests/integration/EditAdminTabsChromeTest.php @@ -0,0 +1,106 @@ + */ + private function editAdminTemplates(): array + { + return [ + 'page_admin_edit_admins_details.tpl', + 'page_admin_edit_admins_group.tpl', + 'page_admin_edit_admins_servers.tpl', + 'page_admin_edit_admins_perms.tpl', + ]; + } + + public function testEveryEditAdminTemplateShipsSharedTabStrip(): void + { + $required = [ + 'role="tablist"', + 'aria-label="Edit admin sections"', + 'data-testid="admin-tab-details"', + 'data-testid="admin-tab-group"', + 'data-testid="admin-tab-servers"', + 'data-testid="admin-tab-permissions"', + 'data-testid="admin-tab-back"', + 'admin-tabs__back', + 'o=editdetails', + 'o=editgroup', + 'o=editservers', + 'o=editpermissions', + ]; + + foreach ($this->editAdminTemplates() as $file) { + $path = self::THEME . $file; + $this->assertFileExists($path); + $src = (string) file_get_contents($path); + foreach ($required as $needle) { + $this->assertStringContainsString( + $needle, + $src, + "{$file} must include {$needle}", + ); + } + } + } + + public function testPermissionsTemplateMarksPermissionsTabCurrent(): void + { + $src = (string) file_get_contents(self::THEME . 'page_admin_edit_admins_perms.tpl'); + + $this->assertMatchesRegularExpression( + '/data-testid="admin-tab-permissions"[^>]*aria-current="page"|aria-current="page"[^>]*data-testid="admin-tab-permissions"/', + $src, + 'Permissions page must mark the Permissions tab as current', + ); + $this->assertStringContainsString('class="card-tab page-section"', $src); + $this->assertStringContainsString('Edit admin · {$admin_name|escape}', $src); + $this->assertStringNotContainsString('Permissions for {$admin_name|escape}', $src); + $this->assertSame( + 2, + substr_count($src, 'table--keep-mobile'), + 'both permission tables must opt out of the mobile .table hide', + ); + $this->assertStringContainsString('data-testid="edit-perms-web-select-all"', $src); + $this->assertStringContainsString('data-testid="edit-perms-server-select-all"', $src); + $this->assertStringContainsString('perms-group-head', $src); + $this->assertStringContainsString('perms-group-child', $src); + $this->assertStringContainsString('perms-group-sep', $src); + } + + public function testEditAdminHandlersSkipOrphanBackStrip(): void + { + $handlers = [ + 'admin.edit.admindetails.php', + 'admin.edit.admingroup.php', + 'admin.edit.adminservers.php', + 'admin.edit.adminperms.php', + ]; + foreach ($handlers as $file) { + $src = (string) file_get_contents(ROOT . 'pages/' . $file); + $this->assertStringNotContainsString( + 'new \\Sbpp\\View\\AdminTabs([],', + $src, + "{$file} must not mount the empty AdminTabs back strip (dead space under the topbar)", + ); + $this->assertStringNotContainsString( + 'new AdminTabs([],', + $src, + "{$file} must not mount the empty AdminTabs back strip (dead space under the topbar)", + ); + } + } +} diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 2555fda5b..448325c57 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -1037,6 +1037,7 @@ input[type="checkbox"]:disabled { .card__header h3 { margin: 0; font-size: var(--fs-base); font-weight: 600; color: var(--text); } .card__header p { margin: 0.25rem 0 0; font-size: var(--fs-xs); color: var(--text-muted); } .card__body { padding: 1.25rem; } +.card__body--compact { padding: 0.5rem 0.75rem; } /* ---- Collapsible filter disclosure (#1303 + #1315) ---- Reusable `
` shape that wraps a @@ -1301,6 +1302,82 @@ html.dark .table thead tr { background: rgb(255 255 255 / 0.02); } .table tbody tr:hover { background: var(--bg-muted); } .table.table--clickable-rows tbody tr { cursor: pointer; } .table td { padding: 0.75rem; vertical-align: middle; } +/* Dense row height for checkbox / permission grids where the default + 0.75rem cell padding leaves too much vertical whitespace. */ +.table.table--compact { + font-size: var(--fs-sm); + margin: 0; +} +.table.table--compact th { + padding-top: 0.35rem; + padding-bottom: 0.35rem; +} +.table.table--compact td { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} +.table.table--compact th:first-child, +.table.table--compact td:first-child { padding-left: 0.75rem; } +.table.table--compact th:last-child, +.table.table--compact td:last-child { padding-right: 0.75rem; } + +/* Edit-admin Permissions: Web + Server cards side-by-side when wide. */ +.edit-perms-columns { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} +@media (min-width: 1200px) { + .edit-perms-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .edit-perms-columns .table-scroll, + .edit-perms-columns .card__body { + overflow-x: auto; + } +} +.edit-perms-select-all { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: var(--fs-sm); + color: var(--text-muted); + white-space: nowrap; + cursor: pointer; + user-select: none; + flex-shrink: 0; +} +.edit-perms-select-all:hover { color: var(--text); } +.table tr.perms-group-head td { + background: var(--bg-muted); + border-top: 1px solid var(--border); + padding-top: 0.55rem; + padding-bottom: 0.45rem; +} +.table tbody > tr.perms-group-head:first-child td { + border-top: none; +} +.table tr.perms-group-child + tr.perms-group-head td { + border-top: 1px solid var(--border); + box-shadow: inset 0 0.35rem 0 0 var(--bg-page); +} +.table tr.perms-group-child td:first-child { + padding-left: 1.5rem; + color: var(--text-muted); +} +.table tr.perms-group-sep td { + padding: 0.65rem 0.75rem 0.3rem; + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-faint); + background: transparent; + border-top: 1px solid var(--border); +} + /* #1207 ADM-5: row actions are *always* visible — the previous `opacity: 0 → 1 on tr:hover` treatment was a discoverability dead end (no hover on touch, hostile to `prefers-reduced-motion: reduce`, @@ -1978,6 +2055,9 @@ details.queue-row > summary > .row-actions { } @media (max-width: 768px) { .table { display: none; } + /* Form / settings tables with no paired mobile-card surface must + opt out of the banlist-style hide, or the whole control vanishes. */ + .table.table--keep-mobile { display: table; } .ban-cards { display: block; } /* #1462: paired mobile surface for the System Log table (see `.log-cards` block above for the full rationale). Same `display` diff --git a/web/themes/default/page_admin_edit_admins_details.tpl b/web/themes/default/page_admin_edit_admins_details.tpl index 0818ecbc8..42651b775 100644 --- a/web/themes/default/page_admin_edit_admins_details.tpl +++ b/web/themes/default/page_admin_edit_admins_details.tpl @@ -22,7 +22,7 @@

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

-