From cf4b2357688e56bca37affe3e26d5551271245ee Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Wed, 29 Jul 2026 23:00:42 -0300 Subject: [PATCH 01/19] progress towards activating/deactivating admins --- ARCHITECTURE.md | 6 +- .../content/docs/configuring/data-export.mdx | 12 +- .../content/docs/setup/admins-and-groups.md | 17 +- game/addons/sourcemod/scripting/sbpp_comms.sp | 25 +- game/addons/sourcemod/scripting/sbpp_main.sp | 54 ++-- web/api/handlers/_register.php | 2 + web/api/handlers/admins.php | 162 +++++++++- web/api/handlers/bans.php | 59 ++-- web/api/handlers/comms.php | 19 +- web/includes/Auth/AdminsSchema.php | 45 +++ .../Auth/Handler/NormalAuthHandler.php | 11 +- .../Auth/Handler/SteamAuthHandler.php | 5 +- web/includes/Auth/UserManager.php | 23 +- web/includes/Export/EntityExporter.php | 9 +- web/includes/Export/Manifest.php | 2 +- web/includes/View/AdminAdminsListView.php | 7 +- web/install/includes/sql/struc.sql | 3 + web/pages/admin.admins.php | 39 ++- web/pages/admin.bans.php | 10 +- web/pages/page.banlist.php | 6 +- web/pages/page.commslist.php | 6 +- web/pages/page.home.php | 4 +- web/scripts/api-contract.js | 15 + web/tests/api/AdminsTest.php | 93 ++++++ web/tests/api/PermissionMatrixTest.php | 2 + web/tests/e2e/specs/flows/data-export.spec.ts | 4 +- .../AdminEnabledAttributionTest.php | 219 ++++++++++++++ .../integration/AdminsDeleteDialogTest.php | 13 + web/tests/unit/EntityExporterTest.php | 5 + web/tests/unit/ManifestBuilderTest.php | 4 +- web/themes/default/page_admin_admins_list.tpl | 279 +++++++++++++----- web/themes/default/page_bans.tpl | 2 +- web/themes/default/page_comms.tpl | 2 +- web/updater/data/811.php | 90 ++++++ web/updater/index.php | 1 + web/updater/store.json | 3 +- 36 files changed, 1057 insertions(+), 201 deletions(-) create mode 100644 web/includes/Auth/AdminsSchema.php create mode 100644 web/tests/integration/AdminEnabledAttributionTest.php create mode 100644 web/updater/data/811.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c2f7ef3c9..fa9f61995 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -974,13 +974,13 @@ in dev/CI). Major tables: | Table | Purpose | | --------------------------- | --------------------------------------------- | -| `sb_admins` | Web admins + bcrypt password + lockout state. | +| `sb_admins` | Web admins + bcrypt password + lockout state + `enabled` soft-retire flag. | | `sb_groups` | Web admin groups (permission bitmasks). | | `sb_srvgroups` | SourceMod admin groups (char flags). | | `sb_admins_servers_groups` | Admin × server × group mapping. | | `sb_servers` / `sb_servers_groups` | Game servers + server-group membership. | -| `sb_bans` | The bans themselves. | -| `sb_comms` | Mutes / gags / blocks. | +| `sb_bans` | The bans themselves (+ `admin_name` issuer snapshot). | +| `sb_comms` | Mutes / gags / blocks (+ `admin_name` issuer snapshot). | | `sb_banlog` | Per-server enforcement events (dashboard). | | `sb_comments` | Threaded comments on a ban. | | `sb_demos` | Uploaded demo metadata. | diff --git a/docs/src/content/docs/configuring/data-export.mdx b/docs/src/content/docs/configuring/data-export.mdx index 589c6fa87..7c44f27d3 100644 --- a/docs/src/content/docs/configuring/data-export.mdx +++ b/docs/src/content/docs/configuring/data-export.mdx @@ -79,8 +79,10 @@ sbpp-export-.zip `manifest.json` is always the first central-directory entry, so consumers can read it without slurping the whole bundle. Fields: -- `format_version`: integer (currently `1`). Bumps trigger a - documented consumer-side migration. +- `format_version`: integer (currently `2`). Bumps trigger a + documented consumer-side migration. Version `2` adds + `admins.enabled` and `bans.admin_name` / `comms.admin_name` + (durable issuer snapshot). - `bundle_id`: UUIDv4 string. Unique per attempt. Logged in the audit trail. - `created_at`: integer unix seconds (UTC). @@ -144,6 +146,12 @@ A few entities carry derived fields: - `comms.mute_kind`: `"mute"`, `"gag"`, `"silence"`, or `"unknown"` (from `:prefix_comms.type` 1/2/3). `type_raw` is the source int. +- `admins.enabled`: `1` (active) or `0` (soft-retired). Inactive + admins keep their row so attribution JOINs still resolve. +- `bans.admin_name` / `comms.admin_name`: snapshot of the issuing + admin's username at insert time (and re-snapshotted on hard + delete when still empty). Prefer this over joining `admins.user` + when reconstructing history. - `bans.state`: `"active"`, `"expired"`, `"unbanned"`, `"deleted"`, or `"permanent"`. Same classifier the banlist page uses. `RemoveType` / `removed_by` / `removed_on` / diff --git a/docs/src/content/docs/setup/admins-and-groups.md b/docs/src/content/docs/setup/admins-and-groups.md index 2f23bc260..e15903ac2 100644 --- a/docs/src/content/docs/setup/admins-and-groups.md +++ b/docs/src/content/docs/setup/admins-and-groups.md @@ -157,6 +157,21 @@ records every admin action (bans, unbans, settings changes) with timestamp and admin name. It's the first place to look if something unexpected changed. +## Deactivate vs delete + +Under **Admin Panel → Admins**: + +- **Deactivate** retires access without removing the account. The admin + cannot log in or load as an in-game admin after the next rehash. Their + username stays on bans and blocks they issued. Use this when someone + leaves the team but you want history to stay readable. +- **Delete** permanently removes the account. Ban and block rows keep a + frozen copy of their username (`admin_name`). The list never shows + "admin deleted" for those rows. + +Deactivated accounts still reserve their username and SteamID. Free +those by deleting the row or editing the account details first. + ## Removing an admin Under **Admin Panel → Admins**, find the row and click the trash icon. @@ -164,7 +179,7 @@ You'll be asked for a reason. It goes into the audit log alongside the removal. Removed admins lose panel access immediately. In-game admin status -clears on the next map change. +clears on the next map change. Issuer names on existing bans stay. ## Common pitfalls diff --git a/game/addons/sourcemod/scripting/sbpp_comms.sp b/game/addons/sourcemod/scripting/sbpp_comms.sp index 01174bf71..eb19fae3c 100644 --- a/game/addons/sourcemod/scripting/sbpp_comms.sp +++ b/game/addons/sourcemod/scripting/sbpp_comms.sp @@ -1743,11 +1743,11 @@ public void Query_ProcessQueue(Database db, DBResultSet results, const char[] er // all blocks should be entered into db! FormatEx(query, sizeof(query), - "INSERT INTO %s_comms (authid, name, created, ends, length, reason, aid, adminIp, sid, type) \ + "INSERT INTO %s_comms (authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, type) \ VALUES ('%s', '%s', %d, %d, %d, '%s', \ IFNULL((SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '0'), \ - '%s', %d, %d)", - DatabasePrefix, sAuthEscaped, banName, startTime, (startTime + (time * 60)), (time * 60), banReason, DatabasePrefix, sAdmAuthEscaped, sAdmAuthYZEscaped, adminIp, serverID, type); + '%s', IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), %d, %d)", + DatabasePrefix, sAuthEscaped, banName, startTime, (startTime + (time * 60)), (time * 60), banReason, DatabasePrefix, sAdmAuthEscaped, sAdmAuthYZEscaped, adminIp, DatabasePrefix, sAdmAuthEscaped, sAdmAuthYZEscaped, serverID, type); #if defined LOG_QUERIES LogToFile(logQuery, "Query_ProcessQueue. QUERY: %s", query); #endif @@ -3180,19 +3180,24 @@ stock void SavePunishment(int admin = 0, int target, int type, int length = -1, "IFNULL((SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), 0)", DatabasePrefix, sAdminAuthIdEscaped, sAdminAuthIdYZEscaped); + char sQueryAdmName[512]; + FormatEx(sQueryAdmName, sizeof(sQueryAdmName), + "IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '')", + DatabasePrefix, sAdminAuthIdEscaped, sAdminAuthIdYZEscaped); + if (length >= 0) { - // authid name, created, ends, length, reason, aid, adminIp, sid + // authid name, created, ends, length, reason, aid, adminIp, admin_name, sid FormatEx(sQueryVal, sizeof(sQueryVal), - "'%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', %s, '%s', %d", - sAuthidEscaped, banName, length * 60, length * 60, banReason, sQueryAdm, adminIp, serverID); + "'%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', %s, '%s', %s, %d", + sAuthidEscaped, banName, length * 60, length * 60, banReason, sQueryAdm, adminIp, sQueryAdmName, serverID); } else // Session mutes { - // authid name, created, ends, length, reason, aid, adminIp, sid + // authid name, created, ends, length, reason, aid, adminIp, admin_name, sid FormatEx(sQueryVal, sizeof(sQueryVal), - "'%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', %s, '%s', %d", - sAuthidEscaped, banName, SESSION_MUTE_FALLBACK, -1, banReason, sQueryAdm, adminIp, serverID); + "'%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', %s, '%s', %s, %d", + sAuthidEscaped, banName, SESSION_MUTE_FALLBACK, -1, banReason, sQueryAdm, adminIp, sQueryAdmName, serverID); } switch (type) @@ -3210,7 +3215,7 @@ stock void SavePunishment(int admin = 0, int target, int type, int length = -1, // litle magic - one query for all actions (mute, gag or silence) FormatEx(sQuery, sizeof(sQuery), - "INSERT INTO %s_comms (authid, name, created, ends, length, reason, aid, adminIp, sid, type) VALUES %s%s%s", + "INSERT INTO %s_comms (authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, type) VALUES %s%s%s", DatabasePrefix, sQueryMute, type == TYPE_SILENCE ? ", " : "", sQueryGag); #if defined LOG_QUERIES diff --git a/game/addons/sourcemod/scripting/sbpp_main.sp b/game/addons/sourcemod/scripting/sbpp_main.sp index 95645ea97..75746aa6c 100644 --- a/game/addons/sourcemod/scripting/sbpp_main.sp +++ b/game/addons/sourcemod/scripting/sbpp_main.sp @@ -1119,7 +1119,7 @@ public void GotDatabase(Database db, const char[] error, any data) FormatEx(query, sizeof(query), "SELECT authid, srv_password, (SELECT name FROM %s_srvgroups WHERE name = srv_group AND flags != '') AS srv_group, srv_flags, user, immunity \ FROM %s_admins_servers_groups AS asg \ LEFT JOIN %s_admins AS a ON a.aid = asg.admin_id \ - WHERE %s (server_id = (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1) \ + WHERE a.enabled = 1 AND %s (server_id = (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1) \ OR srv_group_id = ANY (SELECT group_id FROM %s_servers_groups WHERE server_id = (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1))) \ GROUP BY aid, authid, srv_password, srv_group, srv_flags, user", DatabasePrefix, DatabasePrefix, DatabasePrefix, queryLastLogin, DatabasePrefix, ServerIp, ServerPort, DatabasePrefix, DatabasePrefix, ServerIp, ServerPort); @@ -1127,8 +1127,8 @@ public void GotDatabase(Database db, const char[] error, any data) FormatEx(query, sizeof(query), "SELECT authid, srv_password, (SELECT name FROM %s_srvgroups WHERE name = srv_group AND flags != '') AS srv_group, srv_flags, user, immunity \ FROM %s_admins_servers_groups AS asg \ LEFT JOIN %s_admins AS a ON a.aid = asg.admin_id \ - WHERE %s server_id = %d \ - OR srv_group_id = ANY (SELECT group_id FROM %s_servers_groups WHERE server_id = %d) \ + WHERE a.enabled = 1 AND %s (server_id = %d \ + OR srv_group_id = ANY (SELECT group_id FROM %s_servers_groups WHERE server_id = %d)) \ GROUP BY aid, authid, srv_password, srv_group, srv_flags, user", DatabasePrefix, DatabasePrefix, DatabasePrefix, queryLastLogin, serverID, DatabasePrefix, serverID); } @@ -1242,7 +1242,7 @@ public void VerifyInsert(Database db, DBResultSet results, const char[] error, D public void SelectBanIpCallback(Database db, DBResultSet results, const char[] error, DataPack dataPack) { int admin, minutes; - char adminAuth[MAX_AUTHID_LENGTH], adminIp[16], banReason[256], ip[16], reason[128], Query[512]; + char adminAuth[MAX_AUTHID_LENGTH], adminIp[16], banReason[256], ip[16], reason[128], Query[1536]; char targetName[MAX_NAME_LENGTH], sTEscapedName[MAX_NAME_LENGTH * 2 + 1], targetAuth[MAX_AUTHID_LENGTH]; dataPack.Reset(); @@ -1278,15 +1278,17 @@ public void SelectBanIpCallback(Database db, DBResultSet results, const char[] e } if (serverID == -1) { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (type, ip, authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (type, ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ (1, '%s', '%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1), ' ')", - DatabasePrefix, ip, targetAuth, sTEscapedName, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, ServerIp, ServerPort); + DatabasePrefix, ip, targetAuth, sTEscapedName, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], DatabasePrefix, ServerIp, ServerPort); } else { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (type, ip, authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (type, ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ (1, '%s', '%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ %d, ' ')", - DatabasePrefix, ip, targetAuth, sTEscapedName, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, serverID); + DatabasePrefix, ip, targetAuth, sTEscapedName, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], serverID); } db.Query(InsertBanIpCallback, Query, dataPack, DBPrio_High); @@ -1451,7 +1453,7 @@ public void InsertUnbanCallback(Database db, DBResultSet results, const char[] e public void SelectAddbanCallback(Database db, DBResultSet results, const char[] error, DataPack dataPack) { int admin, minutes; - char adminAuth[MAX_AUTHID_LENGTH], adminIp[16], authid[MAX_AUTHID_LENGTH], banReason[256], Query[512]; + char adminAuth[MAX_AUTHID_LENGTH], adminIp[16], authid[MAX_AUTHID_LENGTH], banReason[256], Query[1536]; char reason[128]; dataPack.Reset(); @@ -1485,15 +1487,17 @@ public void SelectAddbanCallback(Database db, DBResultSet results, const char[] } if (serverID == -1) { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ ('%s', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1), ' ')", - DatabasePrefix, authid, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, ServerIp, ServerPort); + DatabasePrefix, authid, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], DatabasePrefix, ServerIp, ServerPort); } else { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ ('%s', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ %d, ' ')", - DatabasePrefix, authid, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, serverID); + DatabasePrefix, authid, (minutes * 60), (minutes * 60), banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], serverID); } db.Query(InsertAddbanCallback, Query, dataPack, DBPrio_High); @@ -1548,7 +1552,7 @@ public void ProcessQueueCallback(Database db, DBResultSet results, const char[] char ip[16]; char adminAuth[MAX_AUTHID_LENGTH]; char adminIp[16]; - char query[1024]; + char query[1536]; char banName[MAX_NAME_LENGTH]; char banReason[256]; while (results.MoreRows) @@ -1574,18 +1578,20 @@ public void ProcessQueueCallback(Database db, DBResultSet results, const char[] if (serverID == -1) { FormatEx(query, sizeof(query), - "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, sid) VALUES \ + "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid) VALUES \ ('%s', '%s', '%s', %d, %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1))", - DatabasePrefix, ip, auth, banName, startTime, startTime + time * 60, time * 60, banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, ServerIp, ServerPort); + DatabasePrefix, ip, auth, banName, startTime, startTime + time * 60, time * 60, banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], DatabasePrefix, ServerIp, ServerPort); } else { FormatEx(query, sizeof(query), - "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, sid) VALUES \ + "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid) VALUES \ ('%s', '%s', '%s', %d, %d, %d, '%s', (SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ %d)", - DatabasePrefix, ip, auth, banName, startTime, startTime + time * 60, time * 60, banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, serverID); + DatabasePrefix, ip, auth, banName, startTime, startTime + time * 60, time * 60, banReason, DatabasePrefix, adminAuth, adminAuth[8], adminIp, DatabasePrefix, adminAuth, adminAuth[8], serverID); } DataPack authPack = new DataPack(); authPack.WriteString(auth); @@ -2552,20 +2558,22 @@ stock void UTIL_InsertBan(int time, const char[] Name, const char[] Authid, cons //PruneBans(dummy); char banName[MAX_NAME_LENGTH]; char banReason[256]; - char Query[1024]; + char Query[1536]; DB.Escape(Name, banName, sizeof(banName)); DB.Escape(Reason, banReason, sizeof(banReason)); if (serverID == -1) { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ ('%s', '%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', IFNULL((SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'),'0'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ (SELECT sid FROM %s_servers WHERE ip = '%s' AND port = '%s' LIMIT 0,1), ' ')", - DatabasePrefix, Ip, Authid, banName, (time * 60), (time * 60), banReason, DatabasePrefix, AdminAuthid, AdminAuthid[8], AdminIp, DatabasePrefix, ServerIp, ServerPort); + DatabasePrefix, Ip, Authid, banName, (time * 60), (time * 60), banReason, DatabasePrefix, AdminAuthid, AdminAuthid[8], AdminIp, DatabasePrefix, AdminAuthid, AdminAuthid[8], DatabasePrefix, ServerIp, ServerPort); } else { - FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, sid, country) VALUES \ + FormatEx(Query, sizeof(Query), "INSERT INTO %s_bans (ip, authid, name, created, ends, length, reason, aid, adminIp, admin_name, sid, country) VALUES \ ('%s', '%s', '%s', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + %d, %d, '%s', IFNULL((SELECT aid FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'),'0'), '%s', \ + IFNULL((SELECT user FROM %s_admins WHERE authid = '%s' OR authid REGEXP '^STEAM_[0-9]:%s$'), ''), \ %d, ' ')", - DatabasePrefix, Ip, Authid, banName, (time * 60), (time * 60), banReason, DatabasePrefix, AdminAuthid, AdminAuthid[8], AdminIp, serverID); + DatabasePrefix, Ip, Authid, banName, (time * 60), (time * 60), banReason, DatabasePrefix, AdminAuthid, AdminAuthid[8], AdminIp, DatabasePrefix, AdminAuthid, AdminAuthid[8], serverID); } DB.Query(VerifyInsert, Query, dataPack, DBPrio_High); } diff --git a/web/api/handlers/_register.php b/web/api/handlers/_register.php index 0938b471c..9c9e1267b 100644 --- a/web/api/handlers/_register.php +++ b/web/api/handlers/_register.php @@ -58,6 +58,8 @@ // ---- admins ----------------------------------------------------------- Api::register('admins.add', 'api_admins_add', ADMIN_OWNER | ADMIN_ADD_ADMINS); Api::register('admins.remove', 'api_admins_remove', ADMIN_OWNER | ADMIN_DELETE_ADMINS); +Api::register('admins.deactivate', 'api_admins_deactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS); +Api::register('admins.reactivate', 'api_admins_reactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS); Api::register('admins.edit_perms', 'api_admins_edit_perms', ADMIN_OWNER | ADMIN_EDIT_ADMINS); Api::register('admins.generate_password', 'api_admins_generate_password', 0, true); diff --git a/web/api/handlers/admins.php b/web/api/handlers/admins.php index b3131af78..5b23943af 100644 --- a/web/api/handlers/admins.php +++ b/web/api/handlers/admins.php @@ -50,25 +50,32 @@ function api_admins_remove(array $params): array throw new ApiError('cannot_delete_owner', 'Error: You cannot delete the owner.'); } + // Snapshot issuer names onto bans/comms before the admin row disappears + // so historical lists keep showing who issued the action (#1509). + if ($admin) { + $snapName = (string) ($admin['user'] ?? ''); + $GLOBALS['PDO']->query( + "UPDATE `:prefix_bans` SET admin_name = :user WHERE aid = :aid AND admin_name = ''" + ); + $GLOBALS['PDO']->bind(':user', $snapName); + $GLOBALS['PDO']->bind(':aid', $aid); + $GLOBALS['PDO']->execute(); + + $GLOBALS['PDO']->query( + "UPDATE `:prefix_comms` SET admin_name = :user WHERE aid = :aid AND admin_name = ''" + ); + $GLOBALS['PDO']->bind(':user', $snapName); + $GLOBALS['PDO']->bind(':aid', $aid); + $GLOBALS['PDO']->execute(); + } + $GLOBALS['PDO']->query("DELETE FROM `:prefix_admins` WHERE aid = :aid LIMIT 1"); $GLOBALS['PDO']->bind(':aid', $aid); $ok = $GLOBALS['PDO']->execute(); $allservers = []; if ($ok) { - if (Config::getBool('config.enableadminrehashing')) { - $rows = $GLOBALS['PDO']->query("SELECT s.sid FROM `:prefix_servers` s - LEFT JOIN `:prefix_admins_servers_groups` asg ON asg.admin_id = ? - LEFT JOIN `:prefix_servers_groups` sg ON sg.group_id = asg.srv_group_id - WHERE ((asg.server_id != '-1' AND asg.srv_group_id = '-1') - OR (asg.srv_group_id != '-1' AND asg.server_id = '-1')) - AND (s.sid IN(asg.server_id) OR s.sid IN(sg.server_id)) AND s.enabled = 1")->resultset([$aid]); - foreach ($rows as $r) { - if (!in_array($r['sid'], $allservers, true)) { - $allservers[] = $r['sid']; - } - } - } + $allservers = _api_admins_rehash_sids($aid); $GLOBALS['PDO']->query("DELETE FROM `:prefix_admins_servers_groups` WHERE admin_id = :aid"); $GLOBALS['PDO']->bind(':aid', $aid); @@ -105,6 +112,135 @@ function api_admins_remove(array $params): array ]; } +/** + * Soft-retire an admin: keeps the row (and ban/comm attribution) but + * blocks panel login and SourceMod admin load via `enabled = 0`. + * + * @param array{aid?: int|string, ureason?: string} $params + * @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}} + */ +function api_admins_deactivate(array $params): array +{ + $aid = (int)($params['aid'] ?? 0); + $ureason = trim((string)($params['ureason'] ?? '')); + + $admin = $GLOBALS['PDO']->query( + "SELECT user, extraflags, enabled FROM `:prefix_admins` WHERE aid = :aid" + ); + $GLOBALS['PDO']->bind(':aid', $aid); + $admin = $GLOBALS['PDO']->single(); + + if (!$admin) { + throw new ApiError('not_found', 'Admin not found.'); + } + if (((int) $admin['extraflags'] & ADMIN_OWNER) !== 0) { + throw new ApiError('cannot_deactivate_owner', 'Error: You cannot deactivate the owner.'); + } + if ((int) ($admin['enabled'] ?? 1) === 0) { + throw new ApiError('already_inactive', 'That admin is already inactive.'); + } + + $GLOBALS['PDO']->query("UPDATE `:prefix_admins` SET enabled = 0 WHERE aid = :aid LIMIT 1"); + $GLOBALS['PDO']->bind(':aid', $aid); + if (!$GLOBALS['PDO']->execute()) { + throw new ApiError('deactivate_failed', 'There was an error deactivating the admin.'); + } + + $allservers = _api_admins_rehash_sids($aid); + $logBody = "Admin ({$admin['user']}) has been deactivated."; + if ($ureason !== '') { + $logBody .= " Reason: {$ureason}"; + } + Log::add(LogType::Message, 'Admin Deactivated', $logBody); + + return [ + 'aid' => $aid, + 'enabled' => 0, + 'rehash' => $allservers ? implode(',', $allservers) : null, + 'message' => [ + 'title' => 'Admin deactivated', + 'body' => $admin['user'] . ' can no longer log in or use in-game admin. Ban history still shows their name.', + 'kind' => 'green', + ], + ]; +} + +/** + * Restore a soft-retired admin (`enabled = 1`). + * + * @param array{aid?: int|string, ureason?: string} $params + * @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}} + */ +function api_admins_reactivate(array $params): array +{ + $aid = (int)($params['aid'] ?? 0); + $ureason = trim((string)($params['ureason'] ?? '')); + + $admin = $GLOBALS['PDO']->query( + "SELECT user, enabled FROM `:prefix_admins` WHERE aid = :aid" + ); + $GLOBALS['PDO']->bind(':aid', $aid); + $admin = $GLOBALS['PDO']->single(); + + if (!$admin) { + throw new ApiError('not_found', 'Admin not found.'); + } + if ((int) ($admin['enabled'] ?? 1) === 1) { + throw new ApiError('already_active', 'That admin is already active.'); + } + + $GLOBALS['PDO']->query("UPDATE `:prefix_admins` SET enabled = 1 WHERE aid = :aid LIMIT 1"); + $GLOBALS['PDO']->bind(':aid', $aid); + if (!$GLOBALS['PDO']->execute()) { + throw new ApiError('reactivate_failed', 'There was an error reactivating the admin.'); + } + + $allservers = _api_admins_rehash_sids($aid); + $logBody = "Admin ({$admin['user']}) has been reactivated."; + if ($ureason !== '') { + $logBody .= " Reason: {$ureason}"; + } + Log::add(LogType::Message, 'Admin Reactivated', $logBody); + + return [ + 'aid' => $aid, + 'enabled' => 1, + 'rehash' => $allservers ? implode(',', $allservers) : null, + 'message' => [ + 'title' => 'Admin reactivated', + 'body' => $admin['user'] . ' can log in and use in-game admin again.', + 'kind' => 'green', + ], + ]; +} + +/** + * Server SIDs that need `sm_rehash` after an admin access change. + * + * @return list + */ +function _api_admins_rehash_sids(int $aid): array +{ + if (!Config::getBool('config.enableadminrehashing')) { + return []; + } + + $rows = $GLOBALS['PDO']->query("SELECT s.sid FROM `:prefix_servers` s + LEFT JOIN `:prefix_admins_servers_groups` asg ON asg.admin_id = ? + LEFT JOIN `:prefix_servers_groups` sg ON sg.group_id = asg.srv_group_id + WHERE ((asg.server_id != '-1' AND asg.srv_group_id = '-1') + OR (asg.srv_group_id != '-1' AND asg.server_id = '-1')) + AND (s.sid IN(asg.server_id) OR s.sid IN(sg.server_id)) AND s.enabled = 1")->resultset([$aid]); + + $allservers = []; + foreach ($rows as $r) { + if (!in_array($r['sid'], $allservers, true)) { + $allservers[] = $r['sid']; + } + } + return $allservers; +} + function api_admins_add(array $params): array { global $userbank; diff --git a/web/api/handlers/bans.php b/web/api/handlers/bans.php index 6eb3b69db..24a1b01b8 100644 --- a/web/api/handlers/bans.php +++ b/web/api/handlers/bans.php @@ -159,9 +159,20 @@ function api_bans_add(array $params): array } $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_bans`(created,type,ip,authid,name,ends,length,reason,aid,adminIp ) VALUES - (UNIX_TIMESTAMP(),?,?,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?)" - )->execute([$banType->value, $ip, $steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '']); + "INSERT INTO `:prefix_bans`(created,type,ip,authid,name,ends,length,reason,aid,adminIp,admin_name) VALUES + (UNIX_TIMESTAMP(),?,?,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?,?)" + )->execute([ + $banType->value, + $ip, + $steam, + $nickname, + $length * 60, + $len, + $reason, + $userbank->GetAid(), + $_SERVER['REMOTE_ADDR'] ?? '', + (string) $userbank->GetProperty('user'), + ]); $newId = (int)$GLOBALS['PDO']->lastInsertId(); if ($dname && $dfile && preg_match('/^[a-z0-9]*$/i', $dfile)) { @@ -533,18 +544,19 @@ function api_bans_ban_member_of_group(array $params): array continue; } $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp) - VALUES (UNIX_TIMESTAMP(), :type, :ip, :authid, :name, UNIX_TIMESTAMP(), :length, :reason, :aid, :adminIp)" + "INSERT INTO `:prefix_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), :type, :ip, :authid, :name, UNIX_TIMESTAMP(), :length, :reason, :aid, :adminIp, :admin_name)" ); $GLOBALS['PDO']->bindMultiple([ - ':type' => BanType::Steam->value, - ':ip' => '', - ':authid' => SteamID::toSteam2($player['steamid']), - ':name' => $player['personaname'], - ':length' => 0, - ':reason' => 'Steam Community Group Ban (' . $grpurl . '): ' . $reason, - ':aid' => $userbank->GetAid(), - ':adminIp' => $_SERVER['REMOTE_ADDR'] ?? '', + ':type' => BanType::Steam->value, + ':ip' => '', + ':authid' => SteamID::toSteam2($player['steamid']), + ':name' => $player['personaname'], + ':length' => 0, + ':reason' => 'Steam Community Group Ban (' . $grpurl . '): ' . $reason, + ':aid' => $userbank->GetAid(), + ':adminIp' => $_SERVER['REMOTE_ADDR'] ?? '', + ':admin_name' => (string) $userbank->GetProperty('user'), ]); if ($GLOBALS['PDO']->execute()) { $amount['banned']++; @@ -648,15 +660,16 @@ function api_bans_ban_friends(array $params): array continue; } $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp) - VALUES(UNIX_TIMESTAMP(), 0, '', :authid, :name, (UNIX_TIMESTAMP() + 0), 0, :reason, :aid, :admip)" + "INSERT INTO `:prefix_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES(UNIX_TIMESTAMP(), 0, '', :authid, :name, (UNIX_TIMESTAMP() + 0), 0, :reason, :aid, :admip, :admin_name)" ); $GLOBALS['PDO']->bindMultiple([ - ':authid' => $authid, - ':name' => $fname, - ':reason' => 'Steam Community Friend Ban (' . $name . ')', - ':aid' => $userbank->GetAid(), - ':admip' => $_SERVER['REMOTE_ADDR'] ?? '', + ':authid' => $authid, + ':name' => $fname, + ':reason' => 'Steam Community Friend Ban (' . $name . ')', + ':aid' => $userbank->GetAid(), + ':admip' => $_SERVER['REMOTE_ADDR'] ?? '', + ':admin_name' => (string) $userbank->GetProperty('user'), ]); if (!$GLOBALS['PDO']->execute()) { $error++; @@ -839,7 +852,7 @@ function api_bans_detail(array $params): array "SELECT BA.bid, BA.type, BA.ip, BA.authid, BA.name, BA.created, BA.ends, BA.length, BA.reason, BA.aid, BA.adminIp, BA.sid, BA.country, BA.RemovedOn, BA.RemovedBy, BA.RemoveType, BA.ureason, - AD.user AS admin_name, + COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, SE.ip AS server_ip, SE.port AS server_port, MO.icon AS mod_icon, MO.name AS mod_name, CAST(MID(BA.authid, 9, 1) AS UNSIGNED) @@ -1184,7 +1197,7 @@ function api_bans_player_history(array $params): array $rows = $GLOBALS['PDO']->query( "SELECT BA.bid, BA.type, BA.created, BA.ends, BA.length, BA.reason, BA.RemovedOn, BA.RemovedBy, BA.RemoveType, - AD.user AS admin_name, + COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, SE.ip AS server_ip, SE.port AS server_port FROM `:prefix_bans` AS BA LEFT JOIN `:prefix_servers` AS SE ON SE.sid = BA.sid @@ -1197,7 +1210,7 @@ function api_bans_player_history(array $params): array $rows = $GLOBALS['PDO']->query( "SELECT BA.bid, BA.type, BA.created, BA.ends, BA.length, BA.reason, BA.RemovedOn, BA.RemovedBy, BA.RemoveType, - AD.user AS admin_name, + COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, SE.ip AS server_ip, SE.port AS server_port FROM `:prefix_bans` AS BA LEFT JOIN `:prefix_servers` AS SE ON SE.sid = BA.sid diff --git a/web/api/handlers/comms.php b/web/api/handlers/comms.php index c82450f28..1a62d165d 100644 --- a/web/api/handlers/comms.php +++ b/web/api/handlers/comms.php @@ -97,17 +97,18 @@ function api_comms_add(array $params): array } } + $adminName = (string) $userbank->GetProperty('user'); if ($type === 1 || $type === 3) { $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp ) VALUES - (UNIX_TIMESTAMP(),1,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?)" - )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '']); + "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp,admin_name) VALUES + (UNIX_TIMESTAMP(),1,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?,?)" + )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '', $adminName]); } if ($type === 2 || $type === 3) { $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp ) VALUES - (UNIX_TIMESTAMP(),2,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?)" - )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '']); + "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp,admin_name) VALUES + (UNIX_TIMESTAMP(),2,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?,?)" + )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '', $adminName]); } Log::add(LogType::Message, 'Block Added', "Block against ($steam) has been added. Reason: $reason; Length: $length"); @@ -453,7 +454,7 @@ function api_comms_detail(array $params): array $row = $GLOBALS['PDO']->query( "SELECT C.bid AS cid, C.type, C.authid, C.name, C.created, C.ends, C.length, C.reason, C.aid, C.sid, C.RemovedOn, C.RemovedBy, C.RemoveType, C.ureason, - AD.user AS admin_name, + COALESCE(NULLIF(CO.admin_name, ''), AD.user) AS admin_name, SE.ip AS server_ip, SE.port AS server_port, MO.icon AS mod_icon, MO.name AS mod_name, CAST(MID(C.authid, 9, 1) AS UNSIGNED) @@ -730,7 +731,7 @@ function api_comms_player_history(array $params): array $rows = $GLOBALS['PDO']->query( "SELECT C.bid, C.type, C.created, C.ends, C.length, C.reason, C.RemovedOn, C.RemovedBy, C.RemoveType, - AD.user AS admin_name + COALESCE(NULLIF(C.admin_name, ''), AD.user) AS admin_name FROM `:prefix_comms` AS C LEFT JOIN `:prefix_admins` AS AD ON C.aid = AD.aid WHERE C.authid = ? AND C.bid <> ? @@ -741,7 +742,7 @@ function api_comms_player_history(array $params): array $rows = $GLOBALS['PDO']->query( "SELECT C.bid, C.type, C.created, C.ends, C.length, C.reason, C.RemovedOn, C.RemovedBy, C.RemoveType, - AD.user AS admin_name + COALESCE(NULLIF(C.admin_name, ''), AD.user) AS admin_name FROM `:prefix_comms` AS C LEFT JOIN `:prefix_admins` AS AD ON C.aid = AD.aid WHERE C.authid = ? diff --git a/web/includes/Auth/AdminsSchema.php b/web/includes/Auth/AdminsSchema.php new file mode 100644 index 000000000..8819b2d31 --- /dev/null +++ b/web/includes/Auth/AdminsSchema.php @@ -0,0 +1,45 @@ +query( + 'SELECT COUNT(*) AS c FROM information_schema.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() ' + . 'AND TABLE_NAME = :table ' + . 'AND COLUMN_NAME = \'enabled\'' + ); + $dbs->bind(':table', $dbs->getPrefix() . '_admins'); + $row = $dbs->single(); + self::$hasEnabledColumn = is_array($row) && (int) ($row['c'] ?? 0) > 0; + + return self::$hasEnabledColumn; + } + + /** Reset the probe cache (tests / same-request post-migration). */ + public static function clearCache(): void + { + self::$hasEnabledColumn = null; + } +} diff --git a/web/includes/Auth/Handler/NormalAuthHandler.php b/web/includes/Auth/Handler/NormalAuthHandler.php index 76a9f293a..81c46ec39 100644 --- a/web/includes/Auth/Handler/NormalAuthHandler.php +++ b/web/includes/Auth/Handler/NormalAuthHandler.php @@ -64,9 +64,16 @@ private function updatePasswordHash(string $password, int $aid): bool private function getInfosFromDatabase(string $username): mixed { - $this->dbs->query("SELECT aid, password FROM `:prefix_admins` WHERE user = :user"); + $enabledSelect = \Sbpp\Auth\AdminsSchema::hasEnabledColumn($this->dbs) + ? ', enabled' + : ''; + $this->dbs->query("SELECT aid, password{$enabledSelect} FROM `:prefix_admins` WHERE user = :user"); $this->dbs->bind(':user', $username); - return $this->dbs->single(); + $row = $this->dbs->single(); + if (!is_array($row) || (int) ($row['enabled'] ?? 1) === 0) { + return false; + } + return $row; } } diff --git a/web/includes/Auth/Handler/SteamAuthHandler.php b/web/includes/Auth/Handler/SteamAuthHandler.php index 8c9749a3a..ce8894258 100644 --- a/web/includes/Auth/Handler/SteamAuthHandler.php +++ b/web/includes/Auth/Handler/SteamAuthHandler.php @@ -77,7 +77,10 @@ private function check(string $steamid): void } $steamid = \SteamID\SteamID::toSteam2($steamid); - $this->dbs->query('SELECT aid FROM `:prefix_admins` WHERE authid = :authid'); + $enabledGate = \Sbpp\Auth\AdminsSchema::hasEnabledColumn($this->dbs) + ? ' AND enabled = 1' + : ''; + $this->dbs->query("SELECT aid FROM `:prefix_admins` WHERE authid = :authid{$enabledGate}"); $this->dbs->bind(':authid', $steamid); $result = $this->dbs->single(); diff --git a/web/includes/Auth/UserManager.php b/web/includes/Auth/UserManager.php index da6b176f3..c3cf62295 100644 --- a/web/includes/Auth/UserManager.php +++ b/web/includes/Auth/UserManager.php @@ -44,10 +44,14 @@ public function GetUserArray(?int $aid = null): array|false if (isset($this->admins[$aid]) && !empty($this->admins[$aid])) { return $this->admins[$aid]; } - // Not in the manager, so we need to get them from DB + // Not in the manager, so we need to get them from DB. + // `enabled` is optional until migration 811 — see AdminsSchema. + $enabledSelect = AdminsSchema::hasEnabledColumn($this->dbh) + ? ', adm.enabled enabled' + : ''; $this->dbh->query("SELECT adm.user user, adm.authid authid, adm.password password, adm.gid gid, adm.email email, adm.validate validate, adm.extraflags extraflags, adm.immunity admimmunity,sg.immunity sgimmunity, adm.srv_password srv_password, adm.srv_group srv_group, adm.srv_flags srv_flags,sg.flags sgflags, - wg.flags wgflags, wg.name wgname, adm.lastvisit lastvisit + wg.flags wgflags, wg.name wgname, adm.lastvisit lastvisit{$enabledSelect} FROM `:prefix_admins` AS adm LEFT JOIN `:prefix_groups` AS wg ON adm.gid = wg.gid LEFT JOIN `:prefix_srvgroups` AS sg ON adm.srv_group = sg.name @@ -59,6 +63,8 @@ public function GetUserArray(?int $aid = null): array|false return false; // ohnoes some type of db error } + // Always cache the row — admin list / GetProperty need inactive + // profiles (#1509). Permission gates refuse enabled=0 via HasAccess. $user = []; //$user['user'] = stripslashes($res[0]); $user['aid'] = $aid; //immediately obvious @@ -69,6 +75,7 @@ public function GetUserArray(?int $aid = null): array|false $user['email'] = $res['email']; $user['validate'] = $res['validate']; $user['extraflags'] = ((int) $res['extraflags'] | (int) $res['wgflags']); + $user['enabled'] = (int) ($res['enabled'] ?? 1); $user['srv_immunity'] = (int) $res['sgimmunity']; @@ -78,7 +85,7 @@ public function GetUserArray(?int $aid = null): array|false $user['srv_password'] = $res['srv_password']; $user['srv_groups'] = $res['srv_group']; - $user['srv_flags'] = $res['srv_flags'] . $res['sgflags']; + $user['srv_flags'] = (string) ($res['srv_flags'] ?? '') . (string) ($res['sgflags'] ?? ''); $user['group_name'] = $res['wgname']; $user['lastvisit'] = $res['lastvisit']; $this->admins[$aid] = $user; @@ -122,6 +129,16 @@ public function HasAccess(WebPermission|int|string $flags, ?int $aid = null): bo $this->GetUserArray($aid); } + if (!isset($this->admins[$aid])) { + return false; + } + + // Soft-retired admins keep a display profile but grant no flags + // (covers a still-valid JWT after admins.deactivate). + if ((int) ($this->admins[$aid]['enabled'] ?? 1) === 0) { + return false; + } + if (is_numeric($flags)) { return ((int) $this->admins[$aid]['extraflags'] & (int) $flags) !== 0; } diff --git a/web/includes/Export/EntityExporter.php b/web/includes/Export/EntityExporter.php index 91e4f4625..5f72e72c5 100644 --- a/web/includes/Export/EntityExporter.php +++ b/web/includes/Export/EntityExporter.php @@ -243,7 +243,7 @@ public function admins(): iterable // = "never"` attestation truthful. $this->dbs->query( "SELECT `aid`, `user`, `authid`, `gid`, `email`, `extraflags`, `immunity`, - `srv_group`, `srv_flags`, `lastvisit` + `srv_group`, `srv_flags`, `lastvisit`, `enabled` FROM `:prefix_admins` ORDER BY `aid`" ); @@ -261,6 +261,7 @@ public function admins(): iterable 'srv_group' => $this->asString($row['srv_group'] ?? null), 'srv_flags' => $this->asString($row['srv_flags'] ?? null), 'lastvisit' => $row['lastvisit'] !== null ? (int) $row['lastvisit'] : null, + 'enabled' => (int) ($row['enabled'] ?? 1), ]); } } @@ -334,7 +335,7 @@ public function bans(): iterable { $this->dbs->query( "SELECT B.`bid`, B.`ip`, B.`authid`, B.`name`, B.`created`, B.`ends`, B.`length`, - B.`reason`, B.`aid`, B.`adminIp`, B.`sid`, B.`country`, + B.`reason`, B.`aid`, B.`admin_name`, B.`adminIp`, B.`sid`, B.`country`, B.`RemovedBy`, B.`RemoveType`, B.`RemovedOn`, B.`type`, B.`ureason`, D.`filename` AS `demo_filename_raw`, A.`user` AS `removed_by_user`, A.`authid` AS `removed_by_authid` @@ -362,6 +363,7 @@ public function bans(): iterable 'length' => (int) ($row['length'] ?? 0), 'reason' => $this->asString($row['reason'] ?? null), 'aid' => (int) ($row['aid'] ?? 0), + 'admin_name' => $this->asString($row['admin_name'] ?? null), 'admin_ip' => $this->asString($row['adminIp'] ?? null), 'sid' => (int) ($row['sid'] ?? 0), 'country' => $this->asString($row['country'] ?? null), @@ -424,7 +426,7 @@ public function comms(): iterable { $this->dbs->query( "SELECT `bid`, `authid`, `name`, `created`, `ends`, `length`, `reason`, - `aid`, `adminIp`, `sid`, `RemovedBy`, `RemoveType`, `RemovedOn`, + `aid`, `admin_name`, `adminIp`, `sid`, `RemovedBy`, `RemoveType`, `RemovedOn`, `type`, `ureason` FROM `:prefix_comms` ORDER BY `bid`" @@ -444,6 +446,7 @@ public function comms(): iterable 'length' => (int) ($row['length'] ?? 0), 'reason' => $this->asString($row['reason'] ?? null), 'aid' => (int) ($row['aid'] ?? 0), + 'admin_name' => $this->asString($row['admin_name'] ?? null), 'admin_ip' => $this->asString($row['adminIp'] ?? null), 'sid' => (int) ($row['sid'] ?? 0), 'removed_by' => $row['RemovedBy'] !== null ? (int) $row['RemovedBy'] : null, diff --git a/web/includes/Export/Manifest.php b/web/includes/Export/Manifest.php index 475d1088e..75c116432 100644 --- a/web/includes/Export/Manifest.php +++ b/web/includes/Export/Manifest.php @@ -52,7 +52,7 @@ final class Manifest * Wire-format identifier. Bump on any breaking change to the * entity column layout, manifest shape, or per-field contracts. */ - public const FORMAT_VERSION = 1; + public const FORMAT_VERSION = 2; /** * S3 single-PUT object-size ceiling (5 GiB). Every S3-API diff --git a/web/includes/View/AdminAdminsListView.php b/web/includes/View/AdminAdminsListView.php index a61a2d488..18366837b 100644 --- a/web/includes/View/AdminAdminsListView.php +++ b/web/includes/View/AdminAdminsListView.php @@ -32,7 +32,10 @@ final class AdminAdminsListView extends View * `:prefix_admins` augmented by admin.admins.php with display * fields (`user`, `name`, `aid`, `bancount`, `nodemocount`, * `web_group`, `server_group`, `web_flag_string`, - * `server_flag_string`, `immunity`, `lastvisit`). + * `server_flag_string`, `immunity`, `lastvisit`, `enabled`). + * @param string $active_view One of `active` / `inactive` / `all` + * @param string $chip_base_link Base href for the Active/Inactive/All chips + * (search filters preserved; `view=` appended per chip) */ public function __construct( public readonly bool $can_list_admins, @@ -42,6 +45,8 @@ public function __construct( public readonly int $admin_count, public readonly string $admin_nav, public readonly array $admins, + public readonly string $active_view = 'active', + public readonly string $chip_base_link = 'index.php?p=admin&c=admins§ion=admins', ) { } } diff --git a/web/install/includes/sql/struc.sql b/web/install/includes/sql/struc.sql index 2b405b126..f35b39c4c 100644 --- a/web/install/includes/sql/struc.sql +++ b/web/install/includes/sql/struc.sql @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS `{prefix}_admins` ( `lastvisit` int(11) NULL, `attempts` int(11) NOT NULL default '0', `lockout_until` datetime default NULL, + `enabled` tinyint(1) NOT NULL default '1', PRIMARY KEY (`aid`), UNIQUE KEY `user` (`user`) ) ENGINE=InnoDB DEFAULT CHARSET={charset}; @@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS `{prefix}_bans` ( `reason` text character set {charset} NOT NULL, `aid` int(6) NOT NULL default '0', `adminIp` varchar(128) NOT NULL default '', + `admin_name` varchar(64) NOT NULL default '', `sid` int(6) NOT NULL default '0', `country` varchar(4) default NULL, `RemovedBy` int(8) NULL, @@ -224,6 +226,7 @@ CREATE TABLE IF NOT EXISTS `{prefix}_comms` ( `reason` text NOT NULL, `aid` int(6) NOT NULL DEFAULT '0', `adminIp` varchar(128) NOT NULL DEFAULT '', + `admin_name` varchar(64) NOT NULL DEFAULT '', `sid` int(6) NOT NULL DEFAULT '0', `RemovedBy` int(8) DEFAULT NULL, `RemoveType` varchar(3) DEFAULT NULL, diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index b3b918d7e..d92da2f90 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -424,6 +424,18 @@ $join .= " LEFT JOIN `:prefix_servers_groups` AS SGS ON SGS.group_id = ASG.srv_group_id"; } +// Soft-retire filter (#1509): default to active admins only. +$view = (string) ($_GET['view'] ?? 'active'); +if (!in_array($view, ['active', 'inactive', 'all'], true)) { + $view = 'active'; +} +$hasEnabledColumn = \Sbpp\Auth\AdminsSchema::hasEnabledColumn($GLOBALS['PDO']); +$enabledWhere = !$hasEnabledColumn ? '' : match ($view) { + 'inactive' => ' AND ADM.enabled = 0', + 'all' => '', + default => ' AND ADM.enabled = 1', +}; + // Pagination needs the active-filter snapshot baked into every "next" // page link so subsequent navigation preserves the search. Pre-#1275 // the section was implicit (`?p=admin&c=admins`); now the page links @@ -432,7 +444,12 @@ // `http_build_query` handles array values (`admwebflag[]=…&admwebflag[]=…`) // natively, so multi-select filters round-trip without manual joining. $advSearchString = empty($activeFilters) ? '' : '&' . http_build_query($activeFilters); -$admins = $GLOBALS['PDO']->query("SELECT * FROM `:prefix_admins` AS ADM".$join." WHERE ADM.aid > 0".$where." ORDER BY user LIMIT " . (int) (($page-1) * $AdminsPerPage) . "," . (int) $AdminsPerPage)->resultset($whereParams); +$viewLink = $view === 'active' ? '' : '&view=' . rawurlencode($view); +$admins = $GLOBALS['PDO']->query( + "SELECT * FROM `:prefix_admins` AS ADM" . $join + . " WHERE ADM.aid > 0" . $enabledWhere . $where + . " ORDER BY user LIMIT " . (int) (($page - 1) * $AdminsPerPage) . "," . (int) $AdminsPerPage +)->resultset($whereParams); // The server filter joins through `:prefix_admins_servers_groups` and // `:prefix_servers_groups`, which can produce duplicate ADM.aid rows // when an admin reaches the same server via multiple paths. Dedupe @@ -450,7 +467,10 @@ } } -$query = $GLOBALS['PDO']->query("SELECT COUNT(ADM.aid) AS cnt FROM `:prefix_admins` AS ADM".$join." WHERE ADM.aid > 0".$where)->single($whereParams); +$query = $GLOBALS['PDO']->query( + "SELECT COUNT(ADM.aid) AS cnt FROM `:prefix_admins` AS ADM" . $join + . " WHERE ADM.aid > 0" . $enabledWhere . $where +)->single($whereParams); $admin_count = $query['cnt']; if (isset($_GET['page']) && $_GET['page'] > 0) { @@ -487,8 +507,9 @@ $admin['nodemocount'] = $nodem['num']; $admin['name'] = stripslashes($admin['user']); - $admin['server_flag_string'] = SmFlagsToSb($userbank->GetProperty("srv_flags", $admin['aid'])); - $admin['web_flag_string'] = BitToString($userbank->GetProperty("extraflags", $admin['aid'])); + $admin['server_flag_string'] = SmFlagsToSb((string) ($userbank->GetProperty("srv_flags", $admin['aid']) ?? '')); + $admin['web_flag_string'] = BitToString((int) ($userbank->GetProperty("extraflags", $admin['aid']) ?? 0)); + $admin['enabled'] = (int) ($admin['enabled'] ?? 1); $lastvisit = $userbank->GetProperty("lastvisit", $admin['aid']); if (!$lastvisit) { @@ -502,12 +523,12 @@ // Page links carry §ion=admins so prev/next/picker keep the user // on this section rather than ricocheting to the default landing. if ($page > 1) { - $prev = CreateLinkR(' prev', "index.php?p=admin&c=admins§ion=admins&page=" . ($page - 1) . $advSearchString); + $prev = CreateLinkR(' prev', "index.php?p=admin&c=admins§ion=admins&page=" . ($page - 1) . $viewLink . $advSearchString); } else { $prev = ""; } if ($AdminsEnd < $admin_count) { - $next = CreateLinkR('next ', "index.php?p=admin&c=admins§ion=admins&page=" . ($page + 1) . $advSearchString); + $next = CreateLinkR('next ', "index.php?p=admin&c=admins§ion=admins&page=" . ($page + 1) . $viewLink . $advSearchString); } else { $next = ""; } @@ -535,7 +556,7 @@ // `htmlspecialchars` on the base URL is what stops the ADM-4 // multi-filter `&admwebflag[]=…` from breaking out of the // attribute string. - $baseUrl = 'index.php?p=admin&c=admins§ion=admins' . $advSearchString . '&page='; + $baseUrl = 'index.php?p=admin&c=admins§ion=admins' . $viewLink . $advSearchString . '&page='; $baseUrlAttr = htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); $admin_nav .= ' '; } +$chipBase = 'index.php?p=admin&c=admins§ion=admins' . $advSearchString; + \Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminAdminsListView( // We pass the can_* gates explicitly rather than splatting // ...Perms::for($userbank): the helper's @return array @@ -564,5 +587,7 @@ admin_count: (int) $admin_count, admin_nav: (string) $admin_nav, admins: $admin_list, + active_view: $view, + chip_base_link: $chipBase, )); echo ''; diff --git a/web/pages/admin.bans.php b/web/pages/admin.bans.php index 59487f573..9def76fa9 100644 --- a/web/pages/admin.bans.php +++ b/web/pages/admin.bans.php @@ -132,13 +132,14 @@ $bancnt++; $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_bans` (`created`, `authid`, `ip`, `name`, `ends`, `length`, `reason`, `aid`, `adminIp`, `type`) - VALUES (UNIX_TIMESTAMP(), '', :ip, 'Imported Ban', (UNIX_TIMESTAMP() + 0), 0, 'banned_ip.cfg import', :aid, :admip, :btype)" + "INSERT INTO `:prefix_bans` (`created`, `authid`, `ip`, `name`, `ends`, `length`, `reason`, `aid`, `adminIp`, `admin_name`, `type`) + VALUES (UNIX_TIMESTAMP(), '', :ip, 'Imported Ban', (UNIX_TIMESTAMP() + 0), 0, 'banned_ip.cfg import', :aid, :admip, :admin_name, :btype)" ); $GLOBALS['PDO']->bindMultiple([ ':ip' => $line[2], ':aid' => $userbank->GetAid(), ':admip' => $_SERVER['REMOTE_ADDR'], + ':admin_name' => (string) $userbank->GetProperty('user'), ':btype' => BanType::Ip->value, ]); $GLOBALS['PDO']->execute(); @@ -181,14 +182,15 @@ } $bancnt++; $GLOBALS['PDO']->query( - "INSERT INTO `:prefix_bans` (`created`, `authid`, `ip`, `name`, `ends`, `length`, `reason`, `aid`, `adminIp`, `type`) - VALUES (UNIX_TIMESTAMP(), :authid, '', :name, (UNIX_TIMESTAMP() + 0), 0, 'banned_user.cfg import', :aid, :ip, :btype)" + "INSERT INTO `:prefix_bans` (`created`, `authid`, `ip`, `name`, `ends`, `length`, `reason`, `aid`, `adminIp`, `admin_name`, `type`) + VALUES (UNIX_TIMESTAMP(), :authid, '', :name, (UNIX_TIMESTAMP() + 0), 0, 'banned_user.cfg import', :aid, :ip, :admin_name, :btype)" ); $GLOBALS['PDO']->bindMultiple([ ':authid' => $steam, ':name' => $name, ':aid' => $userbank->GetAid(), ':ip' => $_SERVER['REMOTE_ADDR'], + ':admin_name' => (string) $userbank->GetProperty('user'), ':btype' => BanType::Steam->value, ]); $GLOBALS['PDO']->execute(); diff --git a/web/pages/page.banlist.php b/web/pages/page.banlist.php index ba07a2f74..a3b944b6d 100644 --- a/web/pages/page.banlist.php +++ b/web/pages/page.banlist.php @@ -543,7 +543,7 @@ function setPostKey() } $res = $GLOBALS['PDO']->query("SELECT BA.bid ban_id, BA.type, BA.ip ban_ip, BA.authid, BA.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, BA.ureason unban_reason, BA.aid, AD.gid AS gid, adminIp, BA.sid ban_server, country ban_country, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, AD.gid, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, AD.gid, MO.icon as mod_icon, CAST(MID(BA.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(BA.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_demos` as DM WHERE DM.demtype='B' and DM.demid = BA.bid) as demo_count, (SELECT (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 0 AND BH.authid = BA.authid AND BH.authid != '' AND BH.authid IS NOT NULL)) + (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 1 AND BH.ip = BA.ip AND BH.ip != '' AND BH.ip IS NOT NULL))) as history_count @@ -577,7 +577,7 @@ function setPostKey() : $publicFilterWheren; $res = $GLOBALS['PDO']->query("SELECT bid ban_id, BA.type, BA.ip ban_ip, BA.authid, BA.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, BA.ureason unban_reason, BA.aid, AD.gid AS gid, adminIp, BA.sid ban_server, country ban_country, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, AD.gid, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, AD.gid, MO.icon as mod_icon, CAST(MID(BA.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(BA.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_demos` as DM WHERE DM.demtype='B' and DM.demid = BA.bid) as demo_count, (SELECT (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 0 AND BH.authid = BA.authid AND BH.authid != '' AND BH.authid IS NOT NULL)) + (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 1 AND BH.ip = BA.ip AND BH.ip != '' AND BH.ip IS NOT NULL))) as history_count @@ -742,7 +742,7 @@ function setPostKey() $publicFilterBranch3 = $branch3HasWhere ? $publicFilterAnd : $publicFilterWheren; $res = $GLOBALS['PDO']->query("SELECT BA.bid ban_id, BA.type, BA.ip ban_ip, BA.authid, BA.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, BA.ureason unban_reason, BA.aid, AD.gid AS gid, adminIp, BA.sid ban_server, country ban_country, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, AD.gid, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS admin_name, AD.gid, MO.icon as mod_icon, CAST(MID(BA.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(BA.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_demos` as DM WHERE DM.demtype='B' and DM.demid = BA.bid) as demo_count, (SELECT (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 0 AND BH.authid = BA.authid AND BH.authid != '' AND BH.authid IS NOT NULL)) + (SELECT count(*) FROM `:prefix_bans` as BH WHERE (BH.type = BA.type AND BH.type = 1 AND BH.ip = BA.ip AND BH.ip != '' AND BH.ip IS NOT NULL))) as history_count diff --git a/web/pages/page.commslist.php b/web/pages/page.commslist.php index 586c69feb..f1d8cf428 100644 --- a/web/pages/page.commslist.php +++ b/web/pages/page.commslist.php @@ -409,7 +409,7 @@ function setPostKey() // name. The parens lock the AND-predicates onto the entire OR // group. $res = $GLOBALS['PDO']->query("SELECT bid ban_id, CO.type, CO.authid, CO.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, CO.ureason unban_reason, CO.aid, AD.gid AS gid, adminIp, CO.sid ban_server, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(CO.admin_name, ''), AD.user) AS admin_name, MO.icon as mod_icon, CAST(MID(CO.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(CO.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 1)) as mute_count, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 2)) as gag_count, @@ -453,7 +453,7 @@ function setPostKey() } $res = $GLOBALS['PDO']->query("SELECT bid ban_id, CO.type, CO.authid, CO.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, CO.ureason unban_reason, CO.aid, AD.gid AS gid, adminIp, CO.sid ban_server, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(CO.admin_name, ''), AD.user) AS admin_name, MO.icon as mod_icon, CAST(MID(CO.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(CO.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 1)) as mute_count, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 2)) as gag_count, @@ -602,7 +602,7 @@ function setPostKey() } $res = $GLOBALS['PDO']->query("SELECT CO.bid ban_id, CO.type, CO.authid, CO.name player_name, created ban_created, ends ban_ends, length ban_length, reason ban_reason, CO.ureason unban_reason, CO.aid, AD.gid AS gid, adminIp, CO.sid ban_server, RemovedOn, RemovedBy, RemoveType row_type, - SE.ip server_ip, AD.user admin_name, MO.icon as mod_icon, + SE.ip server_ip, COALESCE(NULLIF(CO.admin_name, ''), AD.user) AS admin_name, MO.icon as mod_icon, CAST(MID(CO.authid, 9, 1) AS UNSIGNED) + CAST('76561197960265728' AS UNSIGNED) + CAST(MID(CO.authid, 11, 10) * 2 AS UNSIGNED) AS community_id, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 1)) as mute_count, (SELECT count(*) FROM `:prefix_comms` as BH WHERE (BH.authid = CO.authid AND BH.authid != '' AND BH.authid IS NOT NULL AND BH.type = 2)) as gag_count, diff --git a/web/pages/page.home.php b/web/pages/page.home.php index 4e1878d22..70dca1f4c 100644 --- a/web/pages/page.home.php +++ b/web/pages/page.home.php @@ -115,7 +115,7 @@ function SbppServerQryHelpers(): string AND (length = 0 OR ends > UNIX_TIMESTAMP())") ->single()['cnt']; -$rows = $GLOBALS['PDO']->query("SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, ba.RemoveType, ba.type +$rows = $GLOBALS['PDO']->query("SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, COALESCE(NULLIF(ba.admin_name, ''), ad.user) AS user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, ba.RemoveType, ba.type FROM `:prefix_bans` AS ba LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid @@ -203,7 +203,7 @@ function SbppServerQryHelpers(): string $CommCount = (int) $GLOBALS['PDO']->query("SELECT count(bid) AS cnt FROM `:prefix_comms`")->single()['cnt']; -$rows = $GLOBALS['PDO']->query("SELECT bid, ba.authid, ba.type, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, ba.RemoveType +$rows = $GLOBALS['PDO']->query("SELECT bid, ba.authid, ba.type, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, COALESCE(NULLIF(ba.admin_name, ''), ad.user) AS user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, ba.RemoveType FROM `:prefix_comms` AS ba LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid diff --git a/web/scripts/api-contract.js b/web/scripts/api-contract.js index 74a3e03ed..674992451 100644 --- a/web/scripts/api-contract.js +++ b/web/scripts/api-contract.js @@ -39,6 +39,13 @@ * @typedef {Object} ApiAdminsAddRequest * @typedef {Object} ApiAdminsAddResponse */ +/** + * Soft-retire an admin: keeps the row (and ban/comm attribution) but blocks + * panel login and SourceMod admin load via `enabled = 0`. + * + * @typedef {Object} ApiAdminsDeactivateRequest + * @typedef {{aid: number, enabled: number, rehash: (string, message: {title: string, body: string, kind: string}} | null)} ApiAdminsDeactivateResponse + */ /** * @typedef {Object} ApiAdminsEditPermsRequest * @typedef {Object} ApiAdminsEditPermsResponse @@ -47,6 +54,12 @@ * @typedef {Object} ApiAdminsGeneratePasswordRequest * @typedef {Object} ApiAdminsGeneratePasswordResponse */ +/** + * Restore a soft-retired admin (`enabled = 1`). + * + * @typedef {Object} ApiAdminsReactivateRequest + * @typedef {{aid: number, enabled: number, rehash: (string, message: {title: string, body: string, kind: string}} | null)} ApiAdminsReactivateResponse + */ /** * Delete an admin row + their server group memberships (#1352). Modern JSON * twin of the v1.x sourcebans.js `RemoveAdmin()` helper (deleted at #1123 D1) @@ -652,8 +665,10 @@ var Actions = Object.freeze({ AccountCheckPassword: 'account.check_password', AccountCheckSrvPassword: 'account.check_srv_password', AdminsAdd: 'admins.add', + AdminsDeactivate: 'admins.deactivate', AdminsEditPerms: 'admins.edit_perms', AdminsGeneratePassword: 'admins.generate_password', + AdminsReactivate: 'admins.reactivate', AdminsRemove: 'admins.remove', AuthLogin: 'auth.login', AuthLostPassword: 'auth.lost_password', diff --git a/web/tests/api/AdminsTest.php b/web/tests/api/AdminsTest.php index b92dd64d6..999961081 100644 --- a/web/tests/api/AdminsTest.php +++ b/web/tests/api/AdminsTest.php @@ -417,4 +417,97 @@ public function testGeneratePasswordRejectsAnonymous(): void $env = $this->api('admins.generate_password', []); $this->assertEnvelopeError($env, 'forbidden'); } + + public function testDeactivateSetsEnabledZero(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', $this->adminParams([ + 'name' => 'DeactivateMe', + 'steam' => 'STEAM_0:0:15091', + ])); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + + $env = $this->api('admins.deactivate', ['aid' => $aid, 'ureason' => 'left team']); + $this->assertTrue($env['ok'], json_encode($env)); + $this->assertSame(0, (int) $env['data']['enabled']); + $row = $this->row('admins', ['aid' => $aid]); + $this->assertNotNull($row); + $this->assertSame(0, (int) $row['enabled']); + $this->assertSame( + 'Admin (DeactivateMe) has been deactivated. Reason: left team', + $this->latestLogMessage('Admin Deactivated'), + ); + $this->assertSnapshot('admins/deactivate_success', $env, ['data.aid', 'data.rehash']); + } + + public function testDeactivateRefusesOwner(): void + { + $this->loginAsAdmin(); + $env = $this->api('admins.deactivate', ['aid' => Fixture::adminAid()]); + $this->assertEnvelopeError($env, 'cannot_deactivate_owner'); + $this->assertSnapshot('admins/deactivate_owner_blocked', $env); + } + + public function testDeactivateRefusesAlreadyInactive(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', $this->adminParams([ + 'name' => 'AlreadyOff', + 'steam' => 'STEAM_0:0:15092', + ])); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + $this->assertTrue($this->api('admins.deactivate', ['aid' => $aid])['ok']); + + $env = $this->api('admins.deactivate', ['aid' => $aid]); + $this->assertEnvelopeError($env, 'already_inactive'); + } + + public function testReactivateSetsEnabledOne(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', $this->adminParams([ + 'name' => 'ReactivateMe', + 'steam' => 'STEAM_0:0:15093', + ])); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + $this->assertTrue($this->api('admins.deactivate', ['aid' => $aid])['ok']); + + $env = $this->api('admins.reactivate', ['aid' => $aid]); + $this->assertTrue($env['ok'], json_encode($env)); + $this->assertSame(1, (int) $env['data']['enabled']); + $row = $this->row('admins', ['aid' => $aid]); + $this->assertNotNull($row); + $this->assertSame(1, (int) $row['enabled']); + $this->assertSnapshot('admins/reactivate_success', $env, ['data.aid', 'data.rehash']); + } + + public function testRemoveSnapshotsAdminNameOnBans(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', $this->adminParams([ + 'name' => 'SnapIssuer', + 'steam' => 'STEAM_0:0:15094', + ])); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, "", ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", "")', + DB_PREFIX + ))->execute(['STEAM_0:1:15094', 'Target', 'for snapshot', $aid]); + $bid = (int) $pdo->lastInsertId(); + + $env = $this->api('admins.remove', ['aid' => $aid]); + $this->assertTrue($env['ok'], json_encode($env)); + $this->assertNull($this->row('admins', ['aid' => $aid])); + + $ban = $this->row('bans', ['bid' => $bid]); + $this->assertNotNull($ban); + $this->assertSame('SnapIssuer', $ban['admin_name']); + } } diff --git a/web/tests/api/PermissionMatrixTest.php b/web/tests/api/PermissionMatrixTest.php index b1088e0b9..17b1100cd 100644 --- a/web/tests/api/PermissionMatrixTest.php +++ b/web/tests/api/PermissionMatrixTest.php @@ -53,6 +53,8 @@ public static function expectedMatrix(): array // -- admins. 'admins.add' => ['perm' => ADMIN_OWNER | ADMIN_ADD_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.remove' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], + 'admins.deactivate' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], + 'admins.reactivate' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.edit_perms' => ['perm' => ADMIN_OWNER | ADMIN_EDIT_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.generate_password' => ['perm' => 0, 'requireAdmin' => true, 'public' => false], diff --git a/web/tests/e2e/specs/flows/data-export.spec.ts b/web/tests/e2e/specs/flows/data-export.spec.ts index dfdc5d952..5f94f144a 100644 --- a/web/tests/e2e/specs/flows/data-export.spec.ts +++ b/web/tests/e2e/specs/flows/data-export.spec.ts @@ -40,7 +40,7 @@ * can parse the manifest by reading just the first * central-directory entry without slurping the whole bundle). * - * 5. The manifest carries `format_version: 1` and a non-empty + * 5. The manifest carries `format_version: 2` and a non-empty * `row_counts` dictionary — the load-bearing fields the * operator's pipeline keys off. * @@ -161,7 +161,7 @@ test.describe('admin data export', () => { const manifestJson = await manifestFile!.async('text'); const manifest = JSON.parse(manifestJson); - expect(manifest.format_version).toBe(1); + expect(manifest.format_version).toBe(2); expect(typeof manifest.bundle_id).toBe('string'); expect(manifest.bundle_id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, diff --git a/web/tests/integration/AdminEnabledAttributionTest.php b/web/tests/integration/AdminEnabledAttributionTest.php new file mode 100644 index 000000000..bb30fe868 --- /dev/null +++ b/web/tests/integration/AdminEnabledAttributionTest.php @@ -0,0 +1,219 @@ +loginAsAdmin(); + $env = $this->api('bans.add', [ + 'nickname' => 'Attributed', + 'type' => 0, + 'steam' => 'STEAM_0:0:15090', + 'ip' => '', + 'length' => 0, + 'dfile' => '', + 'dname' => '', + 'reason' => 'snapshot check', + 'fromsub' => 0, + ]); + $this->assertTrue($env['ok'], json_encode($env)); + $bid = (int) ($env['data']['bid'] ?? 0); + $this->assertGreaterThan(0, $bid); + $ban = $this->row('bans', ['bid' => $bid]); + $this->assertNotNull($ban); + $this->assertSame('admin', $ban['admin_name']); + } + + public function testDeactivateKeepsBanAdminNameViaJoinFallback(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', [ + 'mask' => 0, + 'srv_mask' => '', + 'name' => 'SoftIssuer', + 'steam' => 'STEAM_0:0:15095', + 'email' => 'soft@issuer.test', + 'password' => 'longpassword', + 'password2' => 'longpassword', + 'server_group' => 'c', + 'web_group' => 'c', + 'server_password' => '-1', + 'web_name' => '', + 'server_name' => '0', + 'servers' => '', + 'single_servers' => '', + ]); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, "", ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", "")', + DB_PREFIX + ))->execute(['STEAM_0:1:15095', 'Player', 'keep name', $aid]); + + $this->assertTrue($this->api('admins.deactivate', ['aid' => $aid])['ok']); + + $stmt = $pdo->query(sprintf( + "SELECT COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS shown + FROM `%s_bans` BA + LEFT JOIN `%s_admins` AD ON BA.aid = AD.aid + WHERE BA.authid = %s + ORDER BY BA.bid DESC LIMIT 1", + DB_PREFIX, + DB_PREFIX, + $pdo->quote('STEAM_0:1:15095'), + )); + $shown = $stmt->fetchColumn(); + $this->assertSame('SoftIssuer', $shown); + } + + public function testHardRemovePreservesAdminNameSnapshot(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', [ + 'mask' => 0, + 'srv_mask' => '', + 'name' => 'HardIssuer', + 'steam' => 'STEAM_0:0:15096', + 'email' => 'hard@issuer.test', + 'password' => 'longpassword', + 'password2' => 'longpassword', + 'server_group' => 'c', + 'web_group' => 'c', + 'server_password' => '-1', + 'web_name' => '', + 'server_name' => '0', + 'servers' => '', + 'single_servers' => '', + ]); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, "", ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", "")', + DB_PREFIX + ))->execute(['STEAM_0:1:15096', 'Player', 'snap on delete', $aid]); + $bid = (int) $pdo->lastInsertId(); + + $this->assertTrue($this->api('admins.remove', ['aid' => $aid])['ok']); + + $ban = $this->row('bans', ['bid' => $bid]); + $this->assertNotNull($ban); + $this->assertSame('HardIssuer', $ban['admin_name']); + + $stmt = $pdo->query(sprintf( + "SELECT COALESCE(NULLIF(BA.admin_name, ''), AD.user) AS shown + FROM `%s_bans` BA + LEFT JOIN `%s_admins` AD ON BA.aid = AD.aid + WHERE BA.bid = %d", + DB_PREFIX, + DB_PREFIX, + $bid, + )); + $this->assertSame('HardIssuer', $stmt->fetchColumn()); + } + + public function testInactiveAdminFailsPasswordLogin(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', [ + 'mask' => 0, + 'srv_mask' => '', + 'name' => 'NoLogin', + 'steam' => 'STEAM_0:0:15097', + 'email' => 'nologin@test', + 'password' => 'longpassword', + 'password2' => 'longpassword', + 'server_group' => 'c', + 'web_group' => 'c', + 'server_password' => '-1', + 'web_name' => '', + 'server_name' => '0', + 'servers' => '', + 'single_servers' => '', + ]); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + $this->assertTrue($this->api('admins.deactivate', ['aid' => $aid])['ok']); + + $handler = new NormalAuthHandler($GLOBALS['PDO'], 'NoLogin', 'longpassword', false); + $this->assertFalse($handler->getResult(), 'Inactive admin must fail closed like a bad password'); + } + + public function testInactiveAdminProfileLoadsForListButGrantsNoAccess(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', [ + 'mask' => ADMIN_LIST_ADMINS, + 'srv_mask' => 'a', + 'name' => 'ListInactive', + 'steam' => 'STEAM_0:0:15098', + 'email' => 'listinactive@test', + 'password' => 'longpassword', + 'password2' => 'longpassword', + 'server_group' => 'c', + 'web_group' => 'c', + 'server_password' => '-1', + 'web_name' => '', + 'server_name' => '0', + 'servers' => '', + 'single_servers' => '', + ]); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + $this->assertTrue($this->api('admins.deactivate', ['aid' => $aid])['ok']); + + /** @var \CUserManager $userbank */ + $userbank = $GLOBALS['userbank']; + $flags = $userbank->GetProperty('srv_flags', $aid); + $this->assertIsString($flags); + $this->assertNotFalse(SmFlagsToSb((string) $flags)); + $this->assertFalse( + $userbank->HasAccess(ADMIN_LIST_ADMINS, $aid), + 'Soft-retired admin must not pass HasAccess', + ); + } + + public function testPluginAdminLoadSqlFiltersEnabled(): void + { + $path = dirname(__DIR__, 3) . '/game/addons/sourcemod/scripting/sbpp_main.sp'; + $this->assertFileExists($path); + $src = file_get_contents($path); + $this->assertIsString($src); + $this->assertStringContainsString( + 'a.enabled = 1 AND', + $src, + 'SourceMod admin-load query must refuse soft-retired admins', + ); + } + + public function testUpdater811BackfillsAdminName(): void + { + $path = dirname(__DIR__, 3) . '/web/updater/data/811.php'; + $this->assertFileExists($path); + $src = file_get_contents($path); + $this->assertIsString($src); + $this->assertStringContainsString('admin_name', $src); + $this->assertStringContainsString('enabled', $src); + $this->assertStringContainsString('SET BA.admin_name = AD.user', $src); + $this->assertStringContainsString('SET CO.admin_name = AD.user', $src); + } +} diff --git a/web/tests/integration/AdminsDeleteDialogTest.php b/web/tests/integration/AdminsDeleteDialogTest.php index c61ed99a5..71d2e34f4 100644 --- a/web/tests/integration/AdminsDeleteDialogTest.php +++ b/web/tests/integration/AdminsDeleteDialogTest.php @@ -199,11 +199,24 @@ public function testPageTailScriptUsesActionsConstant(): void $this->assertStringContainsString('A.AdminsRemove', $html, 'The script must reference Actions.AdminsRemove (the PascalCase symbol ' . 'from api-contract.js), not a string literal.'); + $this->assertStringContainsString('A.AdminsDeactivate', $html); + $this->assertStringContainsString('A.AdminsReactivate', $html); // Sanity-check: we should NOT find the raw dotted string. $this->assertStringNotContainsString("'admins.remove'", $html, 'String literal action names are forbidden — see AGENTS.md anti-patterns.'); } + public function testDeactivateDialogRendersOncePerPage(): void + { + $html = $this->renderAdminsPage(); + + $matches = preg_match_all('/]*id="admins-deactivate-dialog"/', $html); + $this->assertSame(1, $matches); + $this->assertStringContainsString('data-testid="admins-deactivate-dialog"', $html); + $this->assertStringContainsString('data-testid="admins-deactivate-form"', $html); + $this->assertStringContainsString('data-action="admins-deactivate"', $html); + } + private function seedTargetAdmin(): void { $pdo = Fixture::rawPdo(); diff --git a/web/tests/unit/EntityExporterTest.php b/web/tests/unit/EntityExporterTest.php index 07b691fe0..14d4beb01 100644 --- a/web/tests/unit/EntityExporterTest.php +++ b/web/tests/unit/EntityExporterTest.php @@ -132,6 +132,11 @@ public function testForbiddenAdminColumnsNeverAppear(): void $this->assertContains('password', EntityExporter::FORBIDDEN_ADMIN_COLUMNS); $this->assertContains('srv_password', EntityExporter::FORBIDDEN_ADMIN_COLUMNS); $this->assertContains('validate', EntityExporter::FORBIDDEN_ADMIN_COLUMNS); + + $decoded = json_decode(trim(explode("\n", trim($output))[0]), true); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('enabled', $decoded); + $this->assertSame(1, $decoded['enabled']); } /** diff --git a/web/tests/unit/ManifestBuilderTest.php b/web/tests/unit/ManifestBuilderTest.php index a0ab09561..b9e22441f 100644 --- a/web/tests/unit/ManifestBuilderTest.php +++ b/web/tests/unit/ManifestBuilderTest.php @@ -44,7 +44,7 @@ public function testManifestCapConstantsMatchSpec(): void { $this->assertSame(5 * 1024 * 1024 * 1024, Manifest::MAX_S3_PUT_BYTES); $this->assertSame(64 * 1024 * 1024, Manifest::SAFETY_MARGIN_BYTES); - $this->assertSame(1, Manifest::FORMAT_VERSION); + $this->assertSame(2, Manifest::FORMAT_VERSION); } /** @@ -174,7 +174,7 @@ public function testToJsonProducesExpectedTopLevelShape(): void $keys, ); - $this->assertSame(1, $decoded['format_version']); + $this->assertSame(2, $decoded['format_version']); $this->assertIsArray($decoded['row_counts']); $this->assertIsArray($decoded['demo_files']); $this->assertIsArray($decoded['pii_policy']); diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index d92b013a2..1ec998b5f 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -59,6 +59,27 @@ {load_template file="admin.admins.search"} +
+ Active + Inactive + All +
+ @@ -136,20 +162,27 @@ {/if} {if $can_delete_admins} - {* #1352: data-action wires the delete button to the inline - page-tail script below, which opens the - `#admins-delete-dialog` for a confirm + reason - prompt, then calls `Actions.AdminsRemove` with the - trimmed reason. The pre-fix `onclick="if (typeof - RemoveAdmin === 'function') RemoveAdmin(...)"` was a - silent no-op since #1123 D1 deleted sourcebans.js (which - was the only definer of `RemoveAdmin`). The fallback - href lands on the admins list — there is no legacy GET - handler for `o=remove` (RemoveAdmin always went through - the JSON dispatcher), and adding one would expand scope - beyond the bug; the fallback is a graceful degradation - for the rare case where the JSON dispatcher itself is - missing (e.g. third-party theme that stripped api.js). *} + {if isset($admin.enabled) && $admin.enabled == 0} + + {else} + + {/if} + + {* ============================================================ #1352 — admins-delete row-action wiring (inline page-tail JS). @@ -266,10 +330,6 @@ } } /** - * Flip the busy / loading state on a triggered action button. Calls - * window.SBPP.setBusy when present (theme.js owns the spinner CSS - * contract) and falls back to plain `disabled` so third-party themes - * that strip theme.js still gate against double-clicks. * @param {Element|null} btn * @param {boolean} [busy] defaults to true */ @@ -288,13 +348,7 @@ return document.querySelector('[data-testid="admin-row"][data-id="' + aid + '"]'); } - /** - * Drop one from the count badge. Reads the parenthesised number out - * of the badge's textContent so a third-party theme that wraps the - * count differently still works as long as the testid points at a - * node whose text contains the digits. - * @returns {void} - */ + /** @returns {void} */ function decrementCount() { var el = document.querySelector('[data-testid="admin-count"]'); if (!el) return; @@ -303,54 +357,73 @@ el.textContent = '(' + (n - 1).toLocaleString() + ')'; } - /** @returns {HTMLDialogElement|null} */ - function dialog() { - return /** @type {HTMLDialogElement|null} */ (document.getElementById('admins-delete-dialog')); + /** @type {{aid: string, name: string, fallback: string, mode: string}|null} */ + var pending = null; + + /** + * @param {string} prefix + * @returns {HTMLDialogElement|null} + */ + function dialogBy(prefix) { + return /** @type {HTMLDialogElement|null} */ (document.getElementById(prefix + '-dialog')); } - /** @returns {HTMLTextAreaElement|null} */ - function reasonInput() { - return /** @type {HTMLTextAreaElement|null} */ (document.getElementById('admins-delete-reason')); + /** + * @param {string} prefix + * @returns {HTMLTextAreaElement|null} + */ + function reasonBy(prefix) { + return /** @type {HTMLTextAreaElement|null} */ (document.getElementById(prefix + '-reason')); } - /** @returns {HTMLElement|null} */ - function errorEl() { - var d = dialog(); - return d ? /** @type {HTMLElement|null} */ (d.querySelector('[data-testid="admins-delete-error"]')) : null; + /** + * @param {string} prefix + * @returns {HTMLElement|null} + */ + function errorBy(prefix) { + var d = dialogBy(prefix); + return d ? /** @type {HTMLElement|null} */ (d.querySelector('[data-testid="' + prefix + '-error"]')) : null; + } + /** @param {string} prefix @param {string} msg */ + function showError(prefix, msg) { + var e = errorBy(prefix); + if (!e) return; + e.textContent = msg; + e.hidden = false; + } + /** @param {string} prefix */ + function clearError(prefix) { + var e = errorBy(prefix); + if (!e) return; + e.textContent = ''; + e.hidden = true; } - /** @param {string} msg */ - function showError(msg) { var e = errorEl(); if (!e) return; e.textContent = msg; e.hidden = false; } - function clearError() { var e = errorEl(); if (!e) return; e.textContent = ''; e.hidden = true; } - - /** @type {{aid: string, name: string, fallback: string}|null} */ - var pending = null; - /** @param {{aid: string, name: string, fallback: string}} ctx */ - function openDeleteDialog(ctx) { + /** + * @param {string} prefix + * @param {{aid: string, name: string, fallback: string, mode: string}} ctx + */ + function openDialog(prefix, ctx) { pending = ctx; - var d = dialog(); + var d = dialogBy(prefix); if (!d) { - // Dialog markup missing (third-party theme that stripped - // the partial). Fall back to the admins list landing — - // there's no legacy GET handler for `o=remove`, so we - // can't perform the delete from this code path. Loud no-op - // is preferable to a silent no-op. if (ctx.fallback) window.location.href = ctx.fallback; return; } - var target = d.querySelector('[data-testid="admins-delete-target"]'); + var target = d.querySelector('[data-testid="' + prefix + '-target"]'); if (target) target.textContent = ctx.name || ('admin #' + ctx.aid); - var input = reasonInput(); + var input = reasonBy(prefix); if (input) input.value = ''; - clearError(); + clearError(prefix); d.removeAttribute('hidden'); try { d.showModal(); } catch (_e) { d.setAttribute('open', ''); } - if (input) { try { input.focus(); } catch (_e) { /* focus may throw if hidden */ } } + if (input) { try { input.focus(); } catch (_e2) { /* ignore */ } } } - function closeDeleteDialog() { - var d = dialog(); + /** @param {string} prefix */ + function closeDialog(prefix) { + var d = dialogBy(prefix); if (!d) return; - try { d.close(); } catch (_e) { /* not opened modally */ } + try { d.close(); } catch (_e) { /* ignore */ } d.setAttribute('hidden', ''); pending = null; } @@ -359,10 +432,48 @@ var t = /** @type {Element|null} */ (e.target); if (!t || !t.closest) return; - // Cancel button inside the dialog. if (t.closest('[data-testid="admins-delete-cancel"]')) { e.preventDefault(); - closeDeleteDialog(); + closeDialog('admins-delete'); + return; + } + if (t.closest('[data-testid="admins-deactivate-cancel"]')) { + e.preventDefault(); + closeDialog('admins-deactivate'); + return; + } + + var reactivateBtn = /** @type {HTMLElement|null} */ (t.closest('[data-action="admins-reactivate"]')); + if (reactivateBtn) { + e.preventDefault(); + var rAid = reactivateBtn.getAttribute('data-aid') || ''; + var rName = reactivateBtn.getAttribute('data-name') || ('admin #' + rAid); + var a = api(), A = actions(); + if (!a || !A || !rAid) return; + setBusy(reactivateBtn, true); + a.call(A.AdminsReactivate, { aid: Number(rAid) }).then(function (r) { + setBusy(reactivateBtn, false); + if (!r || r.ok === false) { + var msg = (r && r.error && r.error.message) || 'Unknown error'; + toast('error', 'Reactivate failed', msg); + return; + } + var row = rowForAid(rAid); + if (row && row.parentNode) row.parentNode.removeChild(row); + decrementCount(); + toast('success', 'Admin reactivated', rName + ' can log in again.'); + }); + return; + } + + var deactivateBtn = /** @type {HTMLElement|null} */ (t.closest('[data-action="admins-deactivate"]')); + if (deactivateBtn) { + e.preventDefault(); + var dAid = deactivateBtn.getAttribute('data-aid') || ''; + var dName = deactivateBtn.getAttribute('data-name') || ('admin #' + dAid); + var a2 = api(), A2 = actions(); + if (!a2 || !A2 || !dAid) return; + openDialog('admins-deactivate', { aid: dAid, name: dName, fallback: '', mode: 'deactivate' }); return; } @@ -373,33 +484,31 @@ var aid = btn.getAttribute('data-aid') || ''; var name = btn.getAttribute('data-name') || ('admin #' + aid); var fallback = btn.getAttribute('data-fallback-href') || ''; - var a = api(), A = actions(); - if (!a || !A || !aid) { - // No JSON dispatcher available — fall back to the admins - // list (no legacy GET handler exists for `o=remove`). + var a3 = api(), A3 = actions(); + if (!a3 || !A3 || !aid) { if (fallback) window.location.href = fallback; return; } - openDeleteDialog({ aid: aid, name: name, fallback: fallback }); + openDialog('admins-delete', { aid: aid, name: name, fallback: fallback, mode: 'delete' }); }); document.addEventListener('submit', function (e) { var form = /** @type {Element|null} */ (e.target); if (!form || !(/** @type {Element} */ (form)).closest) return; - if (!form.matches('[data-testid="admins-delete-form"]')) return; + + var isDelete = form.matches('[data-testid="admins-delete-form"]'); + var isDeactivate = form.matches('[data-testid="admins-deactivate-form"]'); + if (!isDelete && !isDeactivate) return; e.preventDefault(); if (!pending) return; - var input = reasonInput(); - // Reason is optional for the delete-admin surface (server-side - // handler accepts empty `ureason` and omits the audit suffix). - // Trim whitespace so the audit-log "Reason: " prefix doesn't - // get a blank tail when the operator typed only spaces. + var prefix = isDelete ? 'admins-delete' : 'admins-deactivate'; + var input = reasonBy(prefix); var reason = input ? input.value.trim() : ''; - clearError(); + clearError(prefix); var ctx = pending; - var submitBtn = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-delete-submit"]')); + var submitBtn = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="' + prefix + '-submit"]')); setBusy(submitBtn, true); var a = api(), A = actions(); @@ -413,27 +522,37 @@ var params = { aid: Number(ctx.aid) }; if (reason !== '') params.ureason = reason; - a.call(A.AdminsRemove, params).then(function (r) { + var action = isDelete ? A.AdminsRemove : A.AdminsDeactivate; + a.call(action, params).then(function (r) { setBusy(submitBtn, false); if (!r || r.ok === false) { var msg = (r && r.error && r.error.message) || 'Unknown error'; - showError(msg); - toast('error', 'Delete failed', msg); + showError(prefix, msg); + toast('error', isDelete ? 'Delete failed' : 'Deactivate failed', msg); return; } var row = rowForAid(ctx.aid); if (row && row.parentNode) row.parentNode.removeChild(row); decrementCount(); - closeDeleteDialog(); - toast('success', 'Admin deleted', ctx.name + ' has been removed.'); + closeDialog(prefix); + if (isDelete) { + toast('success', 'Admin deleted', ctx.name + ' has been removed.'); + } else { + toast('success', 'Admin deactivated', ctx.name + ' can no longer log in.'); + } }); }); document.addEventListener('cancel', function (e) { var t = /** @type {Element|null} */ (e.target); - if (!t || t.id !== 'admins-delete-dialog') return; - pending = null; - clearError(); + if (!t) return; + if (t.id === 'admins-delete-dialog') { + pending = null; + clearError('admins-delete'); + } else if (t.id === 'admins-deactivate-dialog') { + pending = null; + clearError('admins-deactivate'); + } }); })(); diff --git a/web/themes/default/page_bans.tpl b/web/themes/default/page_bans.tpl index d9392a151..ad6a1b240 100644 --- a/web/themes/default/page_bans.tpl +++ b/web/themes/default/page_bans.tpl @@ -432,7 +432,7 @@ {$ban.sname|escape} {if !$hideadminname} - {if empty($ban.aname)}deleted{else}{$ban.aname|escape}{/if} + {if empty($ban.aname)}Unknown{else}{$ban.aname|escape}{/if} {/if} {* #1363: title= surfaces the full SecondsToString breakdown diff --git a/web/themes/default/page_comms.tpl b/web/themes/default/page_comms.tpl index 74dc97b23..43c6004d8 100644 --- a/web/themes/default/page_comms.tpl +++ b/web/themes/default/page_comms.tpl @@ -316,7 +316,7 @@ {if $comm.admin} {$comm.admin|escape} {else} - + Unknown {/if} diff --git a/web/updater/data/811.php b/web/updater/data/811.php new file mode 100644 index 000000000..f13725b16 --- /dev/null +++ b/web/updater/data/811.php @@ -0,0 +1,90 @@ +dbs` +// reads below are suppressed inline. + +/** + * Add a column only when it isn't already present. + * + * @param callable(string, string, string): void $ensure + */ +$ensureColumn = static function (\Database $dbs, string $tableSuffix, string $column, string $alterSql): void { + $dbs->query( + 'SELECT COUNT(*) AS c FROM information_schema.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() ' + . 'AND TABLE_NAME = :table ' + . 'AND COLUMN_NAME = :column' + ); + $dbs->bind(':table', $dbs->getPrefix() . '_' . $tableSuffix); + $dbs->bind(':column', $column); + $row = $dbs->single(); + + if (is_array($row) && (int) ($row['c'] ?? 0) > 0) { + return; + } + + $dbs->query($alterSql); + $dbs->execute(); +}; + +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'admins', + 'enabled', + 'ALTER TABLE `:prefix_admins` ADD COLUMN `enabled` TINYINT(1) NOT NULL DEFAULT 1' +); +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'bans', + 'admin_name', + 'ALTER TABLE `:prefix_bans` ADD COLUMN `admin_name` VARCHAR(64) NOT NULL DEFAULT \'\'' +); +// @phpstan-ignore variable.undefined +$ensureColumn( + $this->dbs, + 'comms', + 'admin_name', + 'ALTER TABLE `:prefix_comms` ADD COLUMN `admin_name` VARCHAR(64) NOT NULL DEFAULT \'\'' +); + +// Backfill snapshots from live admin rows. Idempotent: only empty snapshots. +// @phpstan-ignore variable.undefined +$this->dbs->query( + 'UPDATE `:prefix_bans` AS BA' + . ' INNER JOIN `:prefix_admins` AS AD ON BA.aid = AD.aid' + . ' SET BA.admin_name = AD.user' + . ' WHERE BA.admin_name = \'\'' +); +// @phpstan-ignore variable.undefined +$this->dbs->execute(); + +// @phpstan-ignore variable.undefined +$this->dbs->query( + 'UPDATE `:prefix_comms` AS CO' + . ' INNER JOIN `:prefix_admins` AS AD ON CO.aid = AD.aid' + . ' SET CO.admin_name = AD.user' + . ' WHERE CO.admin_name = \'\'' +); +// @phpstan-ignore variable.undefined +$this->dbs->execute(); + +return true; diff --git a/web/updater/index.php b/web/updater/index.php index ec3f4944e..474375bee 100644 --- a/web/updater/index.php +++ b/web/updater/index.php @@ -10,6 +10,7 @@ require_once('Updater.php'); $updater = new Updater($GLOBALS['PDO']); +\Sbpp\Auth\AdminsSchema::clearCache(); \Sbpp\View\Renderer::render($theme, new \Sbpp\View\UpdaterView( updates: array_values(array_map('strval', $updater->getMessageStack())), diff --git a/web/updater/store.json b/web/updater/store.json index 10e643d19..0378c8f28 100644 --- a/web/updater/store.json +++ b/web/updater/store.json @@ -48,5 +48,6 @@ "807": "807.php", "808": "808.php", "809": "809.php", - "810": "810.php" + "810": "810.php", + "811": "811.php" } From fb131414e2bbf6f6afcd21664cf804a49dd604b2 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 19:59:34 -0300 Subject: [PATCH 02/19] feat(admins): soft-retire, durable issuer names, and bulk actions (#1509) Close deactivate/reactivate polish plus admins.bulk with list checkboxes, keeping ban/comm Admin cells on snapshot names after hard delete. --- AGENTS.md | 1 + web/api/handlers/_register.php | 1 + web/api/handlers/admins.php | 249 +++++++++- web/includes/View/AdminAdminsListView.php | 5 + web/pages/admin.admins.php | 7 + web/scripts/api-contract.js | 19 +- web/tests/api/AdminsTest.php | 69 +++ web/tests/api/PermissionMatrixTest.php | 1 + .../admins/bulk_deactivate_partial.json | 14 + .../admins/deactivate_owner_blocked.json | 7 + .../admins/deactivate_success.json | 13 + .../admins/reactivate_success.json | 13 + web/tests/e2e/fixtures/db.ts | 87 ++++ .../e2e/scripts/reassign-ban-issuer-e2e.php | 86 ++++ web/tests/e2e/scripts/seed-web-group-e2e.php | 72 +++ .../specs/flows/admin-deactivate-bulk.spec.ts | 219 +++++++++ .../AdminEnabledAttributionTest.php | 4 +- web/themes/default/page_admin_admins_list.tpl | 464 +++++++++++++++++- 18 files changed, 1321 insertions(+), 10 deletions(-) create mode 100644 web/tests/api/__snapshots__/admins/bulk_deactivate_partial.json create mode 100644 web/tests/api/__snapshots__/admins/deactivate_owner_blocked.json create mode 100644 web/tests/api/__snapshots__/admins/deactivate_success.json create mode 100644 web/tests/api/__snapshots__/admins/reactivate_success.json create mode 100644 web/tests/e2e/scripts/reassign-ban-issuer-e2e.php create mode 100644 web/tests/e2e/scripts/seed-web-group-e2e.php create mode 100644 web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 4a07e6b20..07b60d0ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4888,6 +4888,7 @@ contributions without contacting every contributor individually. | Edit a docs page or add a new one (the Astro + Starlight site published at sbpp.github.io) | `docs/src/content/docs//.md` (or `.mdx` when the page uses tabs / cards / asides — e.g. `getting-started/quickstart.mdx`, `setup/mariadb.mdx`). New pages also need a sidebar entry in `docs/astro.config.mjs` (the `sidebar:` array). Site config + theme tokens live in `docs/astro.config.mjs` + `docs/src/styles/sbpp.css`. The Starlight chrome ships from `@astrojs/starlight`; layout overrides land under `docs/src/components/` (see `ThemeProvider.astro` for the canonical override shape). Local dev: `cd docs && npm install && npm run dev`. CI gates: `.github/workflows/docs-build.yml` (per-PR build), `docs-deploy-trigger.yml` (main → repository_dispatch into sbpp.github.io), `docs-screenshots.yml` (gated on the `affects-ui` label, runs `docs/scripts/capture.mjs`). Source of truth is here; sbpp.github.io is the deploy shell only (#1333). | | Refresh installer / panel screenshots used in docs pages | `docs/scripts/capture.mjs` (Playwright; `npm run capture` in `docs/`). Output lands under `docs/src/assets/auto/{install,panel}/.png` so docs pages keep referencing the same path across runs. CI does this automatically on PRs labelled `affects-ui`; locally run after `./sbpp.sh up`. STEAM_API_KEY is the all-zero dummy `00000000000000000000000000000000`. | | Add a JSON action | `web/api/handlers/_register.php` + `web/api/handlers/.php` | +| Soft-retire / hard-delete admins, keep ban+comm issuer names, or bulk-select on the admins list (#1509) | Soft-retire: `admins.enabled` + `admins.deactivate` / `admins.reactivate` in `web/api/handlers/admins.php` (Active/Inactive chips + dialogs in `page_admin_admins_list.tpl`). Hard delete still snapshots `bans.admin_name` / `comms.admin_name` before DELETE (migration `811.php`). Issuer display: `COALESCE(NULLIF(*.admin_name, ''), AD.user)` → template paints **Unknown**, never "deleted admin" on the Admin cell (comments still say "deleted admin" per #1500). Bulk: `admins.bulk` (`op` = `deactivate` \| `reactivate` \| `remove` \| `set_web_group` \| `set_srv_group`, partial `applied`/`skipped`) + checkbox column / sticky bar in `page_admin_admins_list.tpl`. Guards: no self on deactivate/remove; owners skipped. Tests: `AdminsTest` + `AdminEnabledAttributionTest` + `admin-deactivate-bulk.spec.ts`. | | Add or audit a publicly-reachable, unauthenticated auth surface (anything in `web/api/handlers/auth.php` or sibling registered as `requireAuth: false`) without leaking per-account state | The reference shape is `api_auth_lost_password` + `_api_auth_lost_password_generic_response` in `web/api/handlers/auth.php` (#1456). All reachable branches MUST return the same envelope; operator-side toggles (e.g. `config.enablenormallogin`) MAY surface as a per-toggle error code because the value is the same for every caller. The pre-#1456 shape branched on `not_registered` / `mail_failed` and let an unauthenticated visitor enumerate registered admin emails one request at a time by reading the painted toast back. See "Public auth surfaces: response-shape uniformity" in Conventions for the full contract (audit-log discipline, DB-write gating, SMTP gating, the documented response-time residual risk) + the matching Anti-patterns entry. Regression guards: `web/tests/api/AuthTest.php::testLostPasswordResponseIsIdenticalForKnownAndUnknownEmail` (byte-for-byte wire assertion) + `web/tests/api/__snapshots__/auth/lost_password_generic.json` (locked envelope) + `web/tests/e2e/specs/flows/lostpassword-toast.spec.ts` (chrome-side parity: same painted toast for known + unknown emails). Sibling surfaces still subject to follow-up (documented under the convention): `api_auth_login` branches its `Api::redirect()` target on per-account state via `?m=…` flags. | | Resolve / override the JSON-API endpoint URL the client-side `sb.api.call(...)` POSTs to | `web/scripts/api.js` (`resolveEndpoint()` — runs once at script-load, computes `new URL('../api.php', document.currentScript.src).href`). The script lives at `/scripts/api.js` regardless of which page loads it, so resolving `../api.php` against the script's own URL lands on the panel-root `/api.php` for top-level page renders, iframe-routed surfaces (`pages/admin.kickit.php` / `pages/admin.blockit.php`), AND subdir installs (`https://host/sourcebans/` → script at `…/scripts/api.js` → endpoint at `…/api.php`). The endpoint stays writable on `sb.api` so callers can swap it; do not edit the resolver to a bare `'./api.php'` literal — that's the pre-#1433 regression shape that 404s every iframe round-trip (`./api.php` resolves against the iframe's document URL `/pages/admin.kickit.php` → `/pages/api.php`, no such route). **Load via static ``, async loaders, ES-module `import()`), and a null `currentScript` collapses `SCRIPT_SRC` to the empty string and silently falls back to the bare-relative `./api.php` — i.e. the exact pre-#1433 bug. The three static load sites in the default theme are `core/header.tpl` (top-level panel chrome → `./scripts/api.js`), `page_kickit.tpl`, and `page_blockit.tpl` (iframe surfaces → `../scripts/api.js`); a theme fork that wants to lazy-load needs its own paired endpoint resolver. Pinned by `web/tests/integration/ApiJsEndpointResolutionTest.php` (static) + `web/tests/e2e/specs/flows/kickit-iframe.spec.ts` (runtime). | | Stamp `SB_VERSION` / `MAJOR_REVISION` before compiling SourceMod plugins | `game/addons/sourcemod/scripting/scripts/resolve-plugin-version.sh` → `include/sbpp_version.inc` (included from `sourcebanspp.inc` + `sbpp_checker.sp`). Tiers: `SBPP_RELEASE_VERSION` (release tag in `release.yml`) → `web/configs/version.json` → `git describe` → `dev`. Checked-in `sbpp_version.inc` is the direct-compile fallback. Regression: `web/tests/integration/PluginVersionResolveTest.php`. | diff --git a/web/api/handlers/_register.php b/web/api/handlers/_register.php index 9c9e1267b..7a5abf728 100644 --- a/web/api/handlers/_register.php +++ b/web/api/handlers/_register.php @@ -60,6 +60,7 @@ Api::register('admins.remove', 'api_admins_remove', ADMIN_OWNER | ADMIN_DELETE_ADMINS); Api::register('admins.deactivate', 'api_admins_deactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS); Api::register('admins.reactivate', 'api_admins_reactivate', ADMIN_OWNER | ADMIN_DELETE_ADMINS); +Api::register('admins.bulk', 'api_admins_bulk', ADMIN_OWNER | ADMIN_DELETE_ADMINS | ADMIN_EDIT_ADMINS); Api::register('admins.edit_perms', 'api_admins_edit_perms', ADMIN_OWNER | ADMIN_EDIT_ADMINS); Api::register('admins.generate_password', 'api_admins_generate_password', 0, true); diff --git a/web/api/handlers/admins.php b/web/api/handlers/admins.php index 5b23943af..bc46ab805 100644 --- a/web/api/handlers/admins.php +++ b/web/api/handlers/admins.php @@ -117,7 +117,7 @@ function api_admins_remove(array $params): array * blocks panel login and SourceMod admin load via `enabled = 0`. * * @param array{aid?: int|string, ureason?: string} $params - * @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}} + * @return array{aid: int, enabled: int, rehash: string|null, message: array{title: string, body: string, kind: string}} */ function api_admins_deactivate(array $params): array { @@ -169,7 +169,7 @@ function api_admins_deactivate(array $params): array * Restore a soft-retired admin (`enabled = 1`). * * @param array{aid?: int|string, ureason?: string} $params - * @return array{aid: int, enabled: int, rehash: ?string, message: array{title: string, body: string, kind: string}} + * @return array{aid: int, enabled: int, rehash: string|null, message: array{title: string, body: string, kind: string}} */ function api_admins_reactivate(array $params): array { @@ -214,6 +214,251 @@ function api_admins_reactivate(array $params): array ]; } +/** + * Apply one lifecycle / group op to many admin ids. Partial success: + * owner / self / not-found / already-* rows land in `skipped` and the + * rest still commit. Per-op permission is re-checked inside so a caller + * holding only EDIT_ADMINS cannot deactivate via this entry point. + * + * Inputs: + * - `op` (string, required) — `deactivate` | `reactivate` | `remove` + * | `set_web_group` | `set_srv_group` + * - `aids` (list of int, required, max 100) + * - `ureason` (string, optional) — deactivate / remove + * - `gid` (int, required for set_web_group) — 0 clears web group + * - `srv_group_id` (int, required for set_srv_group) — 0 clears SM group + * + * @param array{ + * op?: string, + * aids?: list, + * ureason?: string, + * gid?: int|string, + * srv_group_id?: int|string + * } $params + * @return array{ + * op: string, + * applied: list, + * skipped: list, + * rehash: string|null, + * message: array{title: string, body: string, kind: string} + * } + */ +function api_admins_bulk(array $params): array +{ + global $userbank; + + $op = trim((string) ($params['op'] ?? '')); + $allowedOps = ['deactivate', 'reactivate', 'remove', 'set_web_group', 'set_srv_group']; + if (!in_array($op, $allowedOps, true)) { + throw new ApiError('validation', 'Unknown bulk op.', 'op'); + } + + $rawAids = $params['aids'] ?? null; + if (!is_array($rawAids) || $rawAids === []) { + throw new ApiError('validation', 'Select at least one admin.', 'aids'); + } + if (count($rawAids) > 100) { + throw new ApiError('validation', 'Bulk selection is limited to 100 admins.', 'aids'); + } + + $aids = []; + foreach ($rawAids as $raw) { + $aid = (int) $raw; + if ($aid > 0 && !in_array($aid, $aids, true)) { + $aids[] = $aid; + } + } + if ($aids === []) { + throw new ApiError('validation', 'Select at least one admin.', 'aids'); + } + + $isLifecycle = in_array($op, ['deactivate', 'reactivate', 'remove'], true); + if ($isLifecycle) { + if (!$userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::DeleteAdmins))) { + throw new ApiError('forbidden', 'You do not have permission to deactivate or delete admins.'); + } + } else { + if (!$userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::EditAdmins))) { + throw new ApiError('forbidden', 'You do not have permission to edit admin groups.'); + } + } + + $ureason = trim((string) ($params['ureason'] ?? '')); + $actorAid = (int) $userbank->GetAid(); + $gid = (int) ($params['gid'] ?? 0); + $srvGroupId = (int) ($params['srv_group_id'] ?? 0); + + if ($op === 'set_web_group' && !array_key_exists('gid', $params)) { + throw new ApiError('validation', 'Web group is required.', 'gid'); + } + if ($op === 'set_srv_group' && !array_key_exists('srv_group_id', $params)) { + throw new ApiError('validation', 'Server group is required.', 'srv_group_id'); + } + + $applied = []; + $skipped = []; + $rehashSids = []; + + foreach ($aids as $aid) { + if ($isLifecycle && $aid === $actorAid && in_array($op, ['deactivate', 'remove'], true)) { + $skipped[] = ['aid' => $aid, 'reason' => 'self']; + continue; + } + + try { + $result = match ($op) { + 'deactivate' => api_admins_deactivate(['aid' => $aid, 'ureason' => $ureason]), + 'reactivate' => api_admins_reactivate(['aid' => $aid, 'ureason' => $ureason]), + 'remove' => api_admins_remove(['aid' => $aid, 'ureason' => $ureason]), + 'set_web_group' => _api_admins_set_web_group($aid, $gid), + 'set_srv_group' => _api_admins_set_srv_group($aid, $srvGroupId), + }; + $applied[] = $aid; + if (!empty($result['rehash']) && is_string($result['rehash'])) { + foreach (explode(',', $result['rehash']) as $sid) { + $sid = (int) $sid; + if ($sid > 0 && !in_array($sid, $rehashSids, true)) { + $rehashSids[] = $sid; + } + } + } + } catch (ApiError $e) { + $skipped[] = ['aid' => $aid, 'reason' => $e->errorCode]; + } + } + + $appliedN = count($applied); + $skippedN = count($skipped); + $titles = [ + 'deactivate' => 'Admins deactivated', + 'reactivate' => 'Admins reactivated', + 'remove' => 'Admins deleted', + 'set_web_group' => 'Web group updated', + 'set_srv_group' => 'Server group updated', + ]; + $verbs = [ + 'deactivate' => 'deactivated', + 'reactivate' => 'reactivated', + 'remove' => 'deleted', + 'set_web_group' => 'updated', + 'set_srv_group' => 'updated', + ]; + $body = $appliedN . ' ' . $verbs[$op]; + if ($skippedN > 0) { + $body .= ', ' . $skippedN . ' skipped'; + } + $body .= '.'; + + return [ + 'op' => $op, + 'applied' => $applied, + 'skipped' => $skipped, + 'rehash' => $rehashSids ? implode(',', $rehashSids) : null, + 'message' => [ + 'title' => $titles[$op], + 'body' => $body, + 'kind' => $appliedN > 0 ? 'green' : 'red', + ], + ]; +} + +/** + * @return array{rehash: string|null} + */ +function _api_admins_set_web_group(int $aid, int $gid): array +{ + $admin = $GLOBALS['PDO']->query( + "SELECT aid, user, password, email, extraflags FROM `:prefix_admins` WHERE aid = :aid" + ); + $GLOBALS['PDO']->bind(':aid', $aid); + $admin = $GLOBALS['PDO']->single(); + if (!$admin) { + throw new ApiError('not_found', 'Admin not found.'); + } + + if ($gid > 0) { + $group = $GLOBALS['PDO']->query( + "SELECT gid FROM `:prefix_groups` WHERE gid = :gid AND type != 3" + ); + $GLOBALS['PDO']->bind(':gid', $gid); + if (!$GLOBALS['PDO']->single()) { + throw new ApiError('validation', 'Unknown web group.', 'gid'); + } + $password = (string) ($admin['password'] ?? ''); + $email = (string) ($admin['email'] ?? ''); + if ($password === '' || $email === '') { + throw new ApiError( + 'missing_credentials', + 'Admins need a password and email before you can give them web permissions.', + ); + } + } + + $persistGid = max(0, $gid); + $GLOBALS['PDO']->query('UPDATE `:prefix_admins` SET `gid` = :gid WHERE `aid` = :aid'); + $GLOBALS['PDO']->bind(':gid', $persistGid); + $GLOBALS['PDO']->bind(':aid', $aid); + $GLOBALS['PDO']->execute(); + + $allservers = _api_admins_rehash_sids($aid); + Log::add( + LogType::Message, + "Admin's Groups Updated", + "Admin ({$admin['user']}) web group has been updated.", + ); + + return ['rehash' => $allservers ? implode(',', $allservers) : null]; +} + +/** + * @return array{rehash: string|null} + */ +function _api_admins_set_srv_group(int $aid, int $srvGroupId): array +{ + $admin = $GLOBALS['PDO']->query( + "SELECT aid, user, extraflags FROM `:prefix_admins` WHERE aid = :aid" + ); + $GLOBALS['PDO']->bind(':aid', $aid); + $admin = $GLOBALS['PDO']->single(); + if (!$admin) { + throw new ApiError('not_found', 'Admin not found.'); + } + + $resolvedGroupName = ''; + $persistId = 0; + if ($srvGroupId > 0) { + $GLOBALS['PDO']->query('SELECT id, name FROM `:prefix_srvgroups` WHERE id = :id'); + $GLOBALS['PDO']->bind(':id', $srvGroupId); + $row = $GLOBALS['PDO']->single(); + if (!$row) { + throw new ApiError('validation', 'Unknown server group.', 'srv_group_id'); + } + $resolvedGroupName = (string) ($row['name'] ?? ''); + $persistId = (int) $row['id']; + } + + $GLOBALS['PDO']->query('UPDATE `:prefix_admins` SET `srv_group` = :name WHERE `aid` = :aid'); + $GLOBALS['PDO']->bind(':name', $resolvedGroupName); + $GLOBALS['PDO']->bind(':aid', $aid); + $GLOBALS['PDO']->execute(); + + $GLOBALS['PDO']->query( + 'UPDATE `:prefix_admins_servers_groups` SET `group_id` = :gid WHERE `admin_id` = :aid' + ); + $GLOBALS['PDO']->bind(':gid', $persistId > 0 ? $persistId : -1); + $GLOBALS['PDO']->bind(':aid', $aid); + $GLOBALS['PDO']->execute(); + + $allservers = _api_admins_rehash_sids($aid); + Log::add( + LogType::Message, + "Admin's Groups Updated", + "Admin ({$admin['user']}) server group has been updated.", + ); + + return ['rehash' => $allservers ? implode(',', $allservers) : null]; +} + /** * Server SIDs that need `sm_rehash` after an admin access change. * diff --git a/web/includes/View/AdminAdminsListView.php b/web/includes/View/AdminAdminsListView.php index e9c4c5ac0..a2f043f0a 100644 --- a/web/includes/View/AdminAdminsListView.php +++ b/web/includes/View/AdminAdminsListView.php @@ -36,6 +36,8 @@ final class AdminAdminsListView extends View * @param string $active_view One of `active` / `inactive` / `all` * @param string $chip_base_link Base href for the Active/Inactive/All chips * (search filters preserved; `view=` appended per chip) + * @param list $web_groups + * @param list $srv_groups */ public function __construct( public readonly bool $can_list_admins, @@ -46,6 +48,9 @@ public function __construct( public readonly array $admins, public readonly string $active_view = 'active', public readonly string $chip_base_link = 'index.php?p=admin&c=admins§ion=admins', + public readonly array $web_groups = [], + public readonly array $srv_groups = [], + public readonly int $current_aid = 0, ) { } } diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index 439d37e2c..1a7db6aca 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -451,6 +451,7 @@ $admin['server_flag_string'] = SmFlagsToSb((string) ($userbank->GetProperty("srv_flags", $admin['aid']) ?? '')); $admin['web_flag_string'] = BitToString((int) ($userbank->GetProperty("extraflags", $admin['aid']) ?? 0)); $admin['enabled'] = (int) ($admin['enabled'] ?? 1); + $admin['is_owner'] = (((int) ($userbank->GetProperty("extraflags", $admin['aid']) ?? 0)) & ADMIN_OWNER) !== 0; $lastvisit = $userbank->GetProperty("lastvisit", $admin['aid']); if (!$lastvisit) { @@ -513,6 +514,9 @@ $chipBase = 'index.php?p=admin&c=admins§ion=admins' . $advSearchString; +$bulkWebGroups = $GLOBALS['PDO']->query('SELECT gid, name FROM `:prefix_groups` WHERE type != 3 ORDER BY name')->resultset(); +$bulkSrvGroups = $GLOBALS['PDO']->query('SELECT id, name FROM `:prefix_srvgroups` ORDER BY name')->resultset(); + \Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminAdminsListView( // We pass the can_* gates explicitly rather than splatting // ...Perms::for($userbank): the helper's @return array @@ -529,4 +533,7 @@ admins: $admin_list, active_view: $view, chip_base_link: $chipBase, + web_groups: $bulkWebGroups, + srv_groups: $bulkSrvGroups, + current_aid: (int) $userbank->GetAid(), )); diff --git a/web/scripts/api-contract.js b/web/scripts/api-contract.js index 674992451..302ef9911 100644 --- a/web/scripts/api-contract.js +++ b/web/scripts/api-contract.js @@ -39,12 +39,26 @@ * @typedef {Object} ApiAdminsAddRequest * @typedef {Object} ApiAdminsAddResponse */ +/** + * Apply one lifecycle / group op to many admin ids. Partial success: owner / + * self / not-found / already-* rows land in `skipped` and the rest still + * commit. Per-op permission is re-checked inside so a caller holding only + * EDIT_ADMINS cannot deactivate via this entry point. Inputs: - `op` + * (string, required) — `deactivate` | `reactivate` | `remove` | + * `set_web_group` | `set_srv_group` - `aids` (list of int, required, max 100) + * - `ureason` (string, optional) — deactivate / remove - `gid` (int, + * required for set_web_group) — 0 clears web group - `srv_group_id` (int, + * required for set_srv_group) — 0 clears SM group + * + * @typedef {Object} ApiAdminsBulkRequest + * @typedef {{ op: string, applied: Array, skipped: Array<{aid: number, reason: string}>, rehash: string|null, message: {title: string, body: string, kind: string} }} ApiAdminsBulkResponse + */ /** * Soft-retire an admin: keeps the row (and ban/comm attribution) but blocks * panel login and SourceMod admin load via `enabled = 0`. * * @typedef {Object} ApiAdminsDeactivateRequest - * @typedef {{aid: number, enabled: number, rehash: (string, message: {title: string, body: string, kind: string}} | null)} ApiAdminsDeactivateResponse + * @typedef {{aid: number, enabled: number, rehash: string|null, message: {title: string, body: string, kind: string}}} ApiAdminsDeactivateResponse */ /** * @typedef {Object} ApiAdminsEditPermsRequest @@ -58,7 +72,7 @@ * Restore a soft-retired admin (`enabled = 1`). * * @typedef {Object} ApiAdminsReactivateRequest - * @typedef {{aid: number, enabled: number, rehash: (string, message: {title: string, body: string, kind: string}} | null)} ApiAdminsReactivateResponse + * @typedef {{aid: number, enabled: number, rehash: string|null, message: {title: string, body: string, kind: string}}} ApiAdminsReactivateResponse */ /** * Delete an admin row + their server group memberships (#1352). Modern JSON @@ -665,6 +679,7 @@ var Actions = Object.freeze({ AccountCheckPassword: 'account.check_password', AccountCheckSrvPassword: 'account.check_srv_password', AdminsAdd: 'admins.add', + AdminsBulk: 'admins.bulk', AdminsDeactivate: 'admins.deactivate', AdminsEditPerms: 'admins.edit_perms', AdminsGeneratePassword: 'admins.generate_password', diff --git a/web/tests/api/AdminsTest.php b/web/tests/api/AdminsTest.php index 999961081..430a9cfa8 100644 --- a/web/tests/api/AdminsTest.php +++ b/web/tests/api/AdminsTest.php @@ -510,4 +510,73 @@ public function testRemoveSnapshotsAdminNameOnBans(): void $this->assertNotNull($ban); $this->assertSame('SnapIssuer', $ban['admin_name']); } + + public function testBulkDeactivateAppliesAndSkipsOwner(): void + { + $this->loginAsAdmin(); + $a1 = $this->api('admins.add', $this->adminParams([ + 'name' => 'BulkOne', + 'steam' => 'STEAM_0:0:15101', + 'email' => 'bulkone@kick.test', + ])); + $a2 = $this->api('admins.add', $this->adminParams([ + 'name' => 'BulkTwo', + 'steam' => 'STEAM_0:0:15102', + 'email' => 'bulktwo@kick.test', + ])); + $this->assertTrue($a1['ok'], json_encode($a1)); + $this->assertTrue($a2['ok'], json_encode($a2)); + $aid1 = (int) $a1['data']['aid']; + $aid2 = (int) $a2['data']['aid']; + $ownerAid = Fixture::adminAid(); + + $env = $this->api('admins.bulk', [ + 'op' => 'deactivate', + 'aids' => [$aid1, $aid2, $ownerAid], + ]); + $this->assertTrue($env['ok'], json_encode($env)); + $this->assertSame([$aid1, $aid2], $env['data']['applied']); + $this->assertContains( + ['aid' => $ownerAid, 'reason' => 'self'], + $env['data']['skipped'], + ); + $this->assertSame(0, (int) $this->row('admins', ['aid' => $aid1])['enabled']); + $this->assertSame(0, (int) $this->row('admins', ['aid' => $aid2])['enabled']); + $this->assertSnapshot('admins/bulk_deactivate_partial', $env, ['data.applied', 'data.skipped', 'data.rehash']); + } + + public function testBulkSetWebGroup(): void + { + $this->loginAsAdmin(); + $add = $this->api('admins.add', $this->adminParams([ + 'name' => 'BulkGroup', + 'steam' => 'STEAM_0:0:15103', + 'email' => 'bulkgroup@kick.test', + ])); + $this->assertTrue($add['ok'], json_encode($add)); + $aid = (int) $add['data']['aid']; + + $pdo = Fixture::rawPdo(); + $pdo->exec(sprintf( + "INSERT INTO `%s_groups` (type, name, flags) VALUES (1, 'BulkWeb', 0)", + DB_PREFIX + )); + $gid = (int) $pdo->lastInsertId(); + + $env = $this->api('admins.bulk', [ + 'op' => 'set_web_group', + 'aids' => [$aid], + 'gid' => $gid, + ]); + $this->assertTrue($env['ok'], json_encode($env)); + $this->assertSame([$aid], $env['data']['applied']); + $this->assertSame($gid, (int) $this->row('admins', ['aid' => $aid])['gid']); + } + + public function testBulkRejectsEmptyAids(): void + { + $this->loginAsAdmin(); + $env = $this->api('admins.bulk', ['op' => 'deactivate', 'aids' => []]); + $this->assertEnvelopeError($env, 'validation'); + } } diff --git a/web/tests/api/PermissionMatrixTest.php b/web/tests/api/PermissionMatrixTest.php index 17b1100cd..2dbee77eb 100644 --- a/web/tests/api/PermissionMatrixTest.php +++ b/web/tests/api/PermissionMatrixTest.php @@ -55,6 +55,7 @@ public static function expectedMatrix(): array 'admins.remove' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.deactivate' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.reactivate' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS, 'requireAdmin' => false, 'public' => false], + 'admins.bulk' => ['perm' => ADMIN_OWNER | ADMIN_DELETE_ADMINS | ADMIN_EDIT_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.edit_perms' => ['perm' => ADMIN_OWNER | ADMIN_EDIT_ADMINS, 'requireAdmin' => false, 'public' => false], 'admins.generate_password' => ['perm' => 0, 'requireAdmin' => true, 'public' => false], diff --git a/web/tests/api/__snapshots__/admins/bulk_deactivate_partial.json b/web/tests/api/__snapshots__/admins/bulk_deactivate_partial.json new file mode 100644 index 000000000..aadefcc9d --- /dev/null +++ b/web/tests/api/__snapshots__/admins/bulk_deactivate_partial.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "data": { + "op": "deactivate", + "applied": "<*>", + "skipped": "<*>", + "rehash": "<*>", + "message": { + "title": "Admins deactivated", + "body": "2 deactivated, 1 skipped.", + "kind": "green" + } + } +} diff --git a/web/tests/api/__snapshots__/admins/deactivate_owner_blocked.json b/web/tests/api/__snapshots__/admins/deactivate_owner_blocked.json new file mode 100644 index 000000000..7a13f5727 --- /dev/null +++ b/web/tests/api/__snapshots__/admins/deactivate_owner_blocked.json @@ -0,0 +1,7 @@ +{ + "ok": false, + "error": { + "code": "cannot_deactivate_owner", + "message": "Error: You cannot deactivate the owner." + } +} diff --git a/web/tests/api/__snapshots__/admins/deactivate_success.json b/web/tests/api/__snapshots__/admins/deactivate_success.json new file mode 100644 index 000000000..ee68ec662 --- /dev/null +++ b/web/tests/api/__snapshots__/admins/deactivate_success.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "data": { + "aid": "<*>", + "enabled": 0, + "rehash": "<*>", + "message": { + "title": "Admin deactivated", + "body": "DeactivateMe can no longer log in or use in-game admin. Ban history still shows their name.", + "kind": "green" + } + } +} diff --git a/web/tests/api/__snapshots__/admins/reactivate_success.json b/web/tests/api/__snapshots__/admins/reactivate_success.json new file mode 100644 index 000000000..24c461886 --- /dev/null +++ b/web/tests/api/__snapshots__/admins/reactivate_success.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "data": { + "aid": "<*>", + "enabled": 1, + "rehash": "<*>", + "message": { + "title": "Admin reactivated", + "body": "ReactivateMe can log in and use in-game admin again.", + "kind": "green" + } + } +} diff --git a/web/tests/e2e/fixtures/db.ts b/web/tests/e2e/fixtures/db.ts index 7b58cd5fe..da238eaa2 100644 --- a/web/tests/e2e/fixtures/db.ts +++ b/web/tests/e2e/fixtures/db.ts @@ -41,6 +41,10 @@ const SET_SETTING_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/set-setting-e2e.php'; const ORPHAN_BAN_AID_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/orphan-ban-aid-e2e.php'; +const REASSIGN_BAN_ISSUER_INSIDE_CONTAINER = + '/var/www/html/web/tests/e2e/scripts/reassign-ban-issuer-e2e.php'; +const SEED_WEB_GROUP_INSIDE_CONTAINER = + '/var/www/html/web/tests/e2e/scripts/seed-web-group-e2e.php'; const SEED_SERVER_GROUP_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/seed-server-group-e2e.php'; const DELETE_SERVER_INSIDE_CONTAINER = @@ -468,6 +472,89 @@ export async function orphanBanAidE2e(bid: number, newAid = 99999): Promise { + const inContainer = process.env.E2E_IN_CONTAINER === '1'; + const cmd = inContainer ? 'php' : 'docker'; + const cmdArgs = inContainer + ? [REASSIGN_BAN_ISSUER_INSIDE_CONTAINER] + : ['compose', 'exec', '-T', 'web', 'php', REASSIGN_BAN_ISSUER_INSIDE_CONTAINER]; + + const child = execFile(cmd, cmdArgs, { + maxBuffer: 8 * 1024 * 1024, + cwd: inContainer ? undefined : process.cwd(), + }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + + child.stdin?.write(JSON.stringify({ bid, aid })); + child.stdin?.end(); + + await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error( + `reassign-ban-issuer-e2e.php exited ${code}\n` + + `stdout:\n${stdout}\nstderr:\n${stderr}`, + )); + }); + }); +} + +/** + * Insert a web group for bulk assign E2E coverage. + */ +export async function seedWebGroupE2e(name: string): Promise<{ gid: number; name: string }> { + const inContainer = process.env.E2E_IN_CONTAINER === '1'; + const cmd = inContainer ? 'php' : 'docker'; + const cmdArgs = inContainer + ? [SEED_WEB_GROUP_INSIDE_CONTAINER] + : ['compose', 'exec', '-T', 'web', 'php', SEED_WEB_GROUP_INSIDE_CONTAINER]; + + const child = execFile(cmd, cmdArgs, { + maxBuffer: 8 * 1024 * 1024, + cwd: inContainer ? undefined : process.cwd(), + }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + + child.stdin?.write(JSON.stringify({ name })); + child.stdin?.end(); + + await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error( + `seed-web-group-e2e.php exited ${code}\n` + + `stdout:\n${stdout}\nstderr:\n${stderr}`, + )); + }); + }); + + const parsed = JSON.parse(stdout.trim()) as { gid: number; name: string }; + if (!parsed.gid || parsed.gid <= 0) { + throw new Error(`seed-web-group-e2e.php returned invalid payload: ${stdout}`); + } + return parsed; +} + /** * Per-server seed row consumed by `seedServerGroupWithServersE2e`. * RFC 5737 documentation IPs (203.0.113.0/24, 198.51.100.0/24, diff --git a/web/tests/e2e/scripts/reassign-ban-issuer-e2e.php b/web/tests/e2e/scripts/reassign-ban-issuer-e2e.php new file mode 100644 index 000000000..8b05f22ba --- /dev/null +++ b/web/tests/e2e/scripts/reassign-ban-issuer-e2e.php @@ -0,0 +1,86 @@ +query('SELECT aid FROM `:prefix_admins` WHERE aid = :aid'); +$GLOBALS['PDO']->bind(':aid', $aid); +$exists = $GLOBALS['PDO']->single(); +if ($exists === false || $exists === null || $exists === []) { + fwrite(STDERR, "reassign-ban-issuer-e2e.php: aid=$aid does not exist in :prefix_admins.\n"); + exit(2); +} + +$GLOBALS['PDO']->query( + 'UPDATE `:prefix_bans` SET aid = :aid, admin_name = :empty WHERE bid = :bid' +); +$GLOBALS['PDO']->bindMultiple([ + ':aid' => $aid, + ':empty' => '', + ':bid' => $bid, +]); +$GLOBALS['PDO']->execute(); + +fwrite(STDOUT, "reassigned bid=$bid (aid → $aid, admin_name cleared) on " . DB_NAME . "\n"); diff --git a/web/tests/e2e/scripts/seed-web-group-e2e.php b/web/tests/e2e/scripts/seed-web-group-e2e.php new file mode 100644 index 000000000..95e71d00c --- /dev/null +++ b/web/tests/e2e/scripts/seed-web-group-e2e.php @@ -0,0 +1,72 @@ +query( + 'INSERT INTO `:prefix_groups` (`type`, `name`, `flags`) VALUES (1, :name, 0)' +); +$GLOBALS['PDO']->bind(':name', $name); +$GLOBALS['PDO']->execute(); + +$GLOBALS['PDO']->query('SELECT LAST_INSERT_ID() AS gid'); +$row = $GLOBALS['PDO']->single(); +$gid = (int) ($row['gid'] ?? 0); +if ($gid <= 0) { + fwrite(STDERR, "seed-web-group-e2e.php: insert failed.\n"); + exit(2); +} + +fwrite(STDOUT, json_encode(['gid' => $gid, 'name' => $name], JSON_THROW_ON_ERROR) . "\n"); diff --git a/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts new file mode 100644 index 000000000..030c3420f --- /dev/null +++ b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts @@ -0,0 +1,219 @@ +/** + * #1509 — soft-retire (deactivate / reactivate), durable ban issuer + * names after hard delete, and admins-list bulk select. + * + * Selectors use data-testid hooks only. + */ + +import { expect, test } from '../../fixtures/auth.ts'; +import { + reassignBanIssuerE2e, + seedWebGroupE2e, + truncateE2eDb, +} from '../../fixtures/db.ts'; +import { seedBanViaApi } from '../../fixtures/seeds.ts'; + +const ADMIN_ADMINS_ROUTE = '/index.php?p=admin&c=admins§ion=admins'; +const BANLIST_ROUTE = '/index.php?p=banlist'; + +type ApiWindow = { + sb: { + api: { + call: ( + action: string, + payload: Record, + ) => Promise<{ + ok: boolean; + data?: Record; + error?: { code: string; message: string }; + }>; + }; + }; + Actions: Record; +}; + +async function addAdmin( + page: import('@playwright/test').Page, + params: { name: string; steam: string; email: string }, +): Promise { + const env = await page.evaluate(async (p) => { + const w = window as unknown as ApiWindow; + return await w.sb.api.call(w.Actions.AdminsAdd, { + mask: 0, + srv_mask: '', + name: p.name, + steam: p.steam, + email: p.email, + password: 'longpassword', + password2: 'longpassword', + server_group: 'c', + web_group: 'c', + server_password: '-1', + web_name: '', + server_name: '0', + servers: '', + single_servers: '', + }); + }, params); + expect(env.ok, JSON.stringify(env)).toBe(true); + const aid = Number(env.data?.aid); + expect(aid).toBeGreaterThan(0); + return aid; +} + +test.describe('flow: admin deactivate + bulk (#1509)', () => { + test.skip(({ isMobile }) => isMobile, 'desktop chromium only'); + + test.beforeEach(async () => { + await truncateE2eDb(); + }); + + test('deactivate → inactive chip → reactivate', async ({ page }) => { + await page.goto('/'); + const aid = await addAdmin(page, { + name: 'e2e-deactivate-me', + steam: 'STEAM_0:0:150901', + email: 'e2e-deactivate@test.local', + }); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await expect(row).toBeVisible(); + + await row.locator('[data-testid="admin-action-deactivate"]').click(); + const dialog = page.locator('[data-testid="admins-deactivate-dialog"]'); + await expect(dialog).toBeVisible(); + await dialog.locator('[data-testid="admins-deactivate-reason"]').fill('e2e leave'); + + const deactivateResp = page.waitForResponse( + (r) => r.url().includes('api.php') && r.request().method() === 'POST' && r.status() === 200, + ); + await dialog.locator('[data-testid="admins-deactivate-submit"]').click(); + const body = await (await deactivateResp).json(); + expect(body.ok).toBe(true); + expect(body.data?.enabled).toBe(0); + + await expect(row).toBeHidden(); + + await page.goto(`${ADMIN_ADMINS_ROUTE}&view=inactive`); + const inactiveRow = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await expect(inactiveRow).toBeVisible(); + await expect(inactiveRow.locator('[data-testid="admin-inactive-badge"]')).toBeVisible(); + + const reactivateResp = page.waitForResponse( + (r) => r.url().includes('api.php') && r.request().method() === 'POST' && r.status() === 200, + ); + await inactiveRow.locator('[data-testid="admin-action-reactivate"]').click(); + const reBody = await (await reactivateResp).json(); + expect(reBody.ok).toBe(true); + expect(reBody.data?.enabled).toBe(1); + }); + + test('hard delete snapshots ban issuer name on banlist', async ({ page }) => { + await page.goto('/'); + const issuerName = 'e2e-issuer'; + const aid = await addAdmin(page, { + name: issuerName, + steam: 'STEAM_0:0:150902', + email: 'e2e-issuer@test.local', + }); + + const seeded = await seedBanViaApi(page, { + nickname: 'AttrPlayer', + steam: 'STEAM_0:1:150902', + }); + await reassignBanIssuerE2e(seeded.bid, aid); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await expect(row).toBeVisible(); + await row.locator('[data-testid="admin-action-delete"]').click(); + const dialog = page.locator('[data-testid="admins-delete-dialog"]'); + await expect(dialog).toBeVisible(); + const deleteResp = page.waitForResponse( + (r) => r.url().includes('api.php') && r.request().method() === 'POST' && r.status() === 200, + ); + await dialog.locator('[data-testid="admins-delete-submit"]').click(); + expect((await (await deleteResp).json()).ok).toBe(true); + + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(BANLIST_ROUTE); + const adminCell = page.locator('[data-testid="ban-row"] .col-admin').first(); + await expect(adminCell).toBeVisible(); + await expect(adminCell).toContainText(issuerName); + await expect(adminCell).not.toContainText('deleted admin'); + await expect(adminCell).not.toContainText('Unknown'); + }); + + test('bulk select → deactivate selected rows', async ({ page }) => { + await page.goto('/'); + const aid1 = await addAdmin(page, { + name: 'e2e-bulk-a', + steam: 'STEAM_0:0:150903', + email: 'e2e-bulk-a@test.local', + }); + const aid2 = await addAdmin(page, { + name: 'e2e-bulk-b', + steam: 'STEAM_0:0:150904', + email: 'e2e-bulk-b@test.local', + }); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row1 = page.locator(`[data-testid="admin-row"][data-id="${aid1}"]`); + const row2 = page.locator(`[data-testid="admin-row"][data-id="${aid2}"]`); + await expect(row1).toBeVisible(); + await expect(row2).toBeVisible(); + + await row1.locator('[data-testid="admin-row-select"]').check(); + await row2.locator('[data-testid="admin-row-select"]').check(); + + const bar = page.locator('[data-testid="admins-bulk-bar"]'); + await expect(bar).toBeVisible(); + await expect(page.locator('[data-testid="admins-bulk-count"]')).toContainText('2'); + + await page.locator('[data-testid="admins-bulk-deactivate"]').click(); + const dialog = page.locator('[data-testid="admins-bulk-deactivate-dialog"]'); + await expect(dialog).toBeVisible(); + + const bulkResp = page.waitForResponse( + (r) => r.url().includes('api.php') && r.request().method() === 'POST' && r.status() === 200, + ); + await dialog.locator('[data-testid="admins-bulk-deactivate-submit"]').click(); + const body = await (await bulkResp).json(); + expect(body.ok).toBe(true); + expect(body.data?.op).toBe('deactivate'); + expect(body.data?.applied).toEqual(expect.arrayContaining([aid1, aid2])); + + await expect(row1).toBeHidden(); + await expect(row2).toBeHidden(); + }); + + test('bulk select → assign web group', async ({ page }) => { + await page.goto('/'); + const aid = await addAdmin(page, { + name: 'e2e-bulk-group', + steam: 'STEAM_0:0:150905', + email: 'e2e-bulk-group@test.local', + }); + const group = await seedWebGroupE2e('E2E Bulk Web'); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await expect(row).toBeVisible(); + await row.locator('[data-testid="admin-row-select"]').check(); + + await page.locator('[data-testid="admins-bulk-web-group"]').click(); + const dialog = page.locator('[data-testid="admins-bulk-web-group-dialog"]'); + await expect(dialog).toBeVisible(); + await dialog.locator('[data-testid="admins-bulk-web-group-select"]').selectOption(String(group.gid)); + + const bulkResp = page.waitForResponse( + (r) => r.url().includes('api.php') && r.request().method() === 'POST' && r.status() === 200, + ); + await dialog.locator('[data-testid="admins-bulk-web-group-submit"]').click(); + const body = await (await bulkResp).json(); + expect(body.ok).toBe(true); + expect(body.data?.op).toBe('set_web_group'); + expect(body.data?.applied).toEqual([aid]); + }); +}); diff --git a/web/tests/integration/AdminEnabledAttributionTest.php b/web/tests/integration/AdminEnabledAttributionTest.php index bb30fe868..d4c038c91 100644 --- a/web/tests/integration/AdminEnabledAttributionTest.php +++ b/web/tests/integration/AdminEnabledAttributionTest.php @@ -195,7 +195,9 @@ public function testInactiveAdminProfileLoadsForListButGrantsNoAccess(): void public function testPluginAdminLoadSqlFiltersEnabled(): void { $path = dirname(__DIR__, 3) . '/game/addons/sourcemod/scripting/sbpp_main.sp'; - $this->assertFileExists($path); + if (!is_file($path)) { + $this->markTestSkipped('game/ is not mounted in the web container'); + } $src = file_get_contents($path); $this->assertIsString($src); $this->assertStringContainsString( diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index d71111f7c..cee2513cb 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -87,6 +87,14 @@ + {if $can_delete_admins || $can_edit_admins} + + {/if} @@ -98,7 +106,27 @@ {foreach $admins as $admin} - + + {if $can_delete_admins || $can_edit_admins} + + {/if}
+ + Name Bans Server group
+ {if (!empty($admin.is_owner)) || ($admin.aid == $current_aid)} + + {else} + + {/if} +
@@ -204,8 +232,27 @@ rule. Same display dance as `.ban-cards` / `.log-cards`. *}
{foreach $admins as $admin} -
+
+ {if $can_delete_admins || $can_edit_admins} + {if (!empty($admin.is_owner)) || ($admin.aid == $current_aid)} + + {else} + + {/if} + {/if}
{$admin.user|truncate:1:'':true|upper|escape}
@@ -255,6 +302,27 @@ {/if} {if $can_delete_admins} + {if isset($admin.enabled) && $admin.enabled == 0} + + {else} + + {/if}
+ {if $can_delete_admins || $can_edit_admins} + + {/if} + {* ============================================================ #1352 — admin-delete confirm + reason modal scaffold. @@ -364,6 +465,115 @@ + + + + + + + + {* ============================================================ #1352 — admins-delete row-action wiring (inline page-tail JS). @@ -402,7 +612,7 @@ } /** * @param {Element|null} btn - * @param {boolean} [busy] defaults to true + * @param {boolean} [busy] */ function setBusy(btn, busy) { if (!btn) return; @@ -431,8 +641,53 @@ el.textContent = '(' + (n - 1).toLocaleString() + ')'; } + /** @returns {number[]} */ + function selectedAids() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:checked'); + var aids = []; + for (var i = 0; i < boxes.length; i++) { + var aid = Number(/** @type {HTMLElement} */ (boxes[i]).getAttribute('data-aid') || 0); + if (aid > 0 && aids.indexOf(aid) === -1) aids.push(aid); + } + return aids; + } + + /** @returns {void} */ + function syncBulkBar() { + var bar = document.querySelector('[data-testid="admins-bulk-bar"]'); + var countEl = document.querySelector('[data-testid="admins-bulk-count"]'); + var aids = selectedAids(); + if (countEl) countEl.textContent = aids.length + ' selected'; + if (!bar) return; + if (aids.length > 0) { + bar.removeAttribute('hidden'); + /** @type {HTMLElement} */ (bar).style.display = 'flex'; + } else { + bar.setAttribute('hidden', ''); + /** @type {HTMLElement} */ (bar).style.display = 'none'; + } + var all = document.querySelector('[data-action="admins-select-all"]'); + if (all) { + var enabled = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); + var checked = document.querySelectorAll('[data-action="admins-select-row"]:checked'); + /** @type {HTMLInputElement} */ (all).checked = enabled.length > 0 && checked.length === enabled.length; + /** @type {HTMLInputElement} */ (all).indeterminate = checked.length > 0 && checked.length < enabled.length; + } + } + + /** @returns {void} */ + function clearSelection() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]'); + for (var i = 0; i < boxes.length; i++) { + /** @type {HTMLInputElement} */ (boxes[i]).checked = false; + } + syncBulkBar(); + } + /** @type {{aid: string, name: string, fallback: string, mode: string}|null} */ var pending = null; + /** @type {string|null} */ + var pendingBulkOp = null; /** * @param {string} prefix @@ -502,6 +757,101 @@ pending = null; } + /** + * @param {string} prefix + * @param {string} op + * @param {string} label + */ + function openBulkDialog(prefix, op, label) { + var aids = selectedAids(); + if (!aids.length) return; + pendingBulkOp = op; + var d = dialogBy(prefix); + if (!d) return; + var target = d.querySelector('[data-testid="' + prefix + '-target"]'); + if (target) target.textContent = aids.length + ' ' + label; + var input = reasonBy(prefix); + if (input) input.value = ''; + clearError(prefix); + d.removeAttribute('hidden'); + try { d.showModal(); } + catch (_e) { d.setAttribute('open', ''); } + } + + /** @param {string} prefix */ + function closeBulkDialog(prefix) { + var d = dialogBy(prefix); + if (!d) return; + try { d.close(); } catch (_e) { /* ignore */ } + d.setAttribute('hidden', ''); + pendingBulkOp = null; + } + + /** + * @param {string} op + * @param {Record} extra + * @param {HTMLButtonElement|null} submitBtn + * @param {string} prefix + */ + function runBulk(op, extra, submitBtn, prefix) { + var a = api(), A = actions(); + var aids = selectedAids(); + if (!a || !A || !aids.length) { + setBusy(submitBtn, false); + return; + } + /** @type {Record} */ + var params = { op: op, aids: aids }; + Object.keys(extra || {}).forEach(function (k) { params[k] = extra[k]; }); + setBusy(submitBtn, true); + a.call(A.AdminsBulk, params).then(function (r) { + setBusy(submitBtn, false); + if (!r || r.ok === false) { + var msg = (r && r.error && r.error.message) || 'Unknown error'; + if (prefix) showError(prefix, msg); + toast('error', 'Bulk action failed', msg); + return; + } + var data = r.data || {}; + var applied = data.applied || []; + for (var i = 0; i < applied.length; i++) { + if (op === 'remove' || op === 'deactivate') { + var rows = rowsForAid(String(applied[i])); + for (var j = 0; j < rows.length; j++) { + var row = rows[j]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } + decrementCount(); + } + } + if (prefix) closeBulkDialog(prefix); + clearSelection(); + var title = (data.message && data.message.title) || 'Done'; + var body = (data.message && data.message.body) || ''; + toast(applied.length ? 'success' : 'error', title, body); + if (op === 'set_web_group' || op === 'set_srv_group' || op === 'reactivate') { + window.location.reload(); + } + }); + } + + document.addEventListener('change', function (e) { + var t = /** @type {Element|null} */ (e.target); + if (!t || !t.closest) return; + if (t.matches('[data-action="admins-select-all"]')) { + var on = /** @type {HTMLInputElement} */ (t).checked; + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); + for (var i = 0; i < boxes.length; i++) { + /** @type {HTMLInputElement} */ (boxes[i]).checked = on; + } + syncBulkBar(); + return; + } + if (t.matches('[data-action="admins-select-row"]')) { + syncBulkBar(); + } + }); + document.addEventListener('click', function (e) { var t = /** @type {Element|null} */ (e.target); if (!t || !t.closest) return; @@ -516,6 +866,58 @@ closeDialog('admins-deactivate'); return; } + if (t.closest('[data-testid="admins-bulk-deactivate-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-deactivate'); + return; + } + if (t.closest('[data-testid="admins-bulk-delete-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-delete'); + return; + } + if (t.closest('[data-testid="admins-bulk-web-group-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-web-group'); + return; + } + if (t.closest('[data-testid="admins-bulk-srv-group-cancel"]')) { + e.preventDefault(); + closeBulkDialog('admins-bulk-srv-group'); + return; + } + if (t.closest('[data-action="admins-bulk-clear"]')) { + e.preventDefault(); + clearSelection(); + return; + } + if (t.closest('[data-action="admins-bulk-deactivate"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-deactivate', 'deactivate', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-reactivate"]')) { + e.preventDefault(); + var aR = api(), AR = actions(); + if (!aR || !AR) return; + runBulk('reactivate', {}, /** @type {HTMLButtonElement|null} */ (t.closest('button')), ''); + return; + } + if (t.closest('[data-action="admins-bulk-delete"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-delete', 'remove', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-web-group"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-web-group', 'set_web_group', 'admins'); + return; + } + if (t.closest('[data-action="admins-bulk-srv-group"]')) { + e.preventDefault(); + openBulkDialog('admins-bulk-srv-group', 'set_srv_group', 'admins'); + return; + } var reactivateBtn = /** @type {HTMLElement|null} */ (t.closest('[data-action="admins-reactivate"]')); if (reactivateBtn) { @@ -532,8 +934,11 @@ toast('error', 'Reactivate failed', msg); return; } - var row = rowForAid(rAid); - if (row && row.parentNode) row.parentNode.removeChild(row); + var rows = rowsForAid(rAid); + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } decrementCount(); toast('success', 'Admin reactivated', rName + ' can log in again.'); }); @@ -570,6 +975,41 @@ var form = /** @type {Element|null} */ (e.target); if (!form || !(/** @type {Element} */ (form)).closest) return; + if (form.matches('[data-testid="admins-bulk-deactivate-form"]')) { + e.preventDefault(); + var reasonB = reasonBy('admins-bulk-deactivate'); + var submitB = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-deactivate-submit"]')); + /** @type {Record} */ + var extraB = {}; + if (reasonB && reasonB.value.trim() !== '') extraB.ureason = reasonB.value.trim(); + runBulk('deactivate', extraB, submitB, 'admins-bulk-deactivate'); + return; + } + if (form.matches('[data-testid="admins-bulk-delete-form"]')) { + e.preventDefault(); + var reasonD = reasonBy('admins-bulk-delete'); + var submitD = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-delete-submit"]')); + /** @type {Record} */ + var extraD = {}; + if (reasonD && reasonD.value.trim() !== '') extraD.ureason = reasonD.value.trim(); + runBulk('remove', extraD, submitD, 'admins-bulk-delete'); + return; + } + if (form.matches('[data-testid="admins-bulk-web-group-form"]')) { + e.preventDefault(); + var selW = /** @type {HTMLSelectElement|null} */ (document.getElementById('admins-bulk-web-group-select')); + var submitW = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-web-group-submit"]')); + runBulk('set_web_group', { gid: Number(selW ? selW.value : 0) }, submitW, 'admins-bulk-web-group'); + return; + } + if (form.matches('[data-testid="admins-bulk-srv-group-form"]')) { + e.preventDefault(); + var selS = /** @type {HTMLSelectElement|null} */ (document.getElementById('admins-bulk-srv-group-select')); + var submitS = /** @type {HTMLButtonElement|null} */ (form.querySelector('[data-testid="admins-bulk-srv-group-submit"]')); + runBulk('set_srv_group', { srv_group_id: Number(selS ? selS.value : 0) }, submitS, 'admins-bulk-srv-group'); + return; + } + var isDelete = form.matches('[data-testid="admins-delete-form"]'); var isDeactivate = form.matches('[data-testid="admins-deactivate-form"]'); if (!isDelete && !isDeactivate) return; @@ -629,8 +1069,22 @@ } else if (t.id === 'admins-deactivate-dialog') { pending = null; clearError('admins-deactivate'); + } else if (t.id === 'admins-bulk-deactivate-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-deactivate'); + } else if (t.id === 'admins-bulk-delete-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-delete'); + } else if (t.id === 'admins-bulk-web-group-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-web-group'); + } else if (t.id === 'admins-bulk-srv-group-dialog') { + pendingBulkOp = null; + clearError('admins-bulk-srv-group'); } }); + + syncBulkBar(); })(); {/literal} From 6605ad2804c8912be149b3bbd2d6a3ccdcc768f6 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 20:13:13 -0300 Subject: [PATCH 03/19] fix seeder to insert coherent data in admins --- web/tests/Synthesizer.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/tests/Synthesizer.php b/web/tests/Synthesizer.php index b908210f4..10d0e3b47 100644 --- a/web/tests/Synthesizer.php +++ b/web/tests/Synthesizer.php @@ -156,6 +156,8 @@ final class Synthesizer private array $adminAids = []; /** @var list */ private array $groupGids = []; + /** First web group insert is always Owner (`insertWebGroups`). */ + private int $ownerWebGid = 0; /** Type=3 org groups in `:prefix_groups` (Server groups UI). @var list */ private array $serverOrgGids = []; /** @var list */ @@ -562,7 +564,11 @@ private function insertGroups(): int $count = min($this->scale['groups'], count($defs)); for ($i = 0; $i < $count; $i++) { $stmt->execute([$defs[$i]['name'], $defs[$i]['flags']]); - $this->groupGids[] = (int) $this->pdo->lastInsertId(); + $gid = (int) $this->pdo->lastInsertId(); + $this->groupGids[] = $gid; + if ($defs[$i]['name'] === 'Owner') { + $this->ownerWebGid = $gid; + } } return $count; } @@ -664,7 +670,12 @@ private function insertAdmins(): int if ($srvFlags !== null && mt_rand(0, 3) === 0) { $srvFlags .= 'o'; } - $extra = mt_rand(0, 3) === 0 ? 16777216 : 0; // ~25% Owner-flagged + // Personal ADMIN_OWNER only when the admin is in the Owner web + // group. Random Owner bits on Trial/Moderator/etc. made bulk + // select look broken (grey checkbox while the group column + // said something else). Owner-group members stay unselectable + // either via this bit or via GetProperty's group-flag OR. + $extra = ($this->ownerWebGid > 0 && $gid === $this->ownerWebGid) ? 16777216 : 0; $immunity = mt_rand(0, 99); $lastvisit = $this->now - mt_rand(60, 60 * 60 * 24 * 30); $stmt->execute([ From 0fb0dd334fb91c9b63535e19089362574e3b6076 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 20:14:25 -0300 Subject: [PATCH 04/19] fix(admins): match reactivate row action to icon-only chrome Use the same ghost icon button pattern as deactivate/delete so inactive rows do not show a bordered labeled control. --- web/themes/default/page_admin_admins_list.tpl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index cee2513cb..c4e2e0773 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -188,21 +188,21 @@ {/if} {if $can_delete_admins} {if isset($admin.enabled) && $admin.enabled == 0} - {else} {else} {* #1402: data-action="admin-add-generate-password" replaces the dead `onclick="if (typeof LoadGeneratePassword === 'function') LoadGeneratePassword(); return false;"` guard. The page-tail @@ -137,10 +148,22 @@ tabindex="6" data-testid="admin-add-useserverpass" onclick="var el = document.getElementById('a_serverpass'); if (el) el.disabled = !this.checked;"> - +
+ + +
@@ -675,6 +698,45 @@ return out.join(','); } + /** + * Sync a password-visibility toggle and every listed input. + * @param {HTMLElement} toggle + * @param {boolean} visible + * @returns {void} + */ + function setPasswordGroupVisible(toggle, visible) { + var raw = toggle.getAttribute('data-password-targets') || ''; + var ids = raw.split(',').map(function (s) { return s.trim(); }).filter(Boolean); + ids.forEach(function (inputId) { + var input = /** @type {HTMLInputElement|null} */ (document.getElementById(inputId)); + if (input && !input.disabled) input.type = visible ? 'text' : 'password'; + }); + toggle.setAttribute('aria-pressed', visible ? 'true' : 'false'); + toggle.setAttribute('aria-label', visible ? 'Hide password' : 'Show password'); + toggle.setAttribute('title', visible ? 'Hide password' : 'Show password'); + var icon = toggle.querySelector('[data-lucide]'); + if (icon) { + icon.setAttribute('data-lucide', visible ? 'eye-off' : 'eye'); + if (window.lucide) window.lucide.createIcons(); + } + } + + // ---------- Show / hide password ---------- + document.addEventListener('click', function (e) { + var t = /** @type {Element|null} */ (e.target); + if (!t || !t.closest) return; + var toggle = /** @type {HTMLElement|null} */ (t.closest('[data-action="toggle-password"]')); + if (!toggle) return; + e.preventDefault(); + var raw = toggle.getAttribute('data-password-targets') || ''; + var firstId = raw.split(',')[0] ? raw.split(',')[0].trim() : ''; + var first = firstId + ? /** @type {HTMLInputElement|null} */ (document.getElementById(firstId)) + : null; + if (!first || first.disabled) return; + setPasswordGroupVisible(toggle, first.type === 'password'); + }); + // ---------- Generate password ---------- document.addEventListener('click', function (e) { var t = /** @type {Element|null} */ (e.target); @@ -695,16 +757,12 @@ // #1402 adversarial review MEDIUM 5: leave the input // types as `password` (matches v1.x `LoadGeneratePassword` // — the legacy helper never flipped .type either). - // The pre-fix `type='text'` change was a privacy / - // shoulder-surf / screenshot leak: the freshly- - // generated password sat in plaintext on the operator's - // screen indefinitely after the click, even after the - // operator left the field. Operators who genuinely - // need to see the value can copy it into their - // password manager from the password field's clipboard - // (browsers + extensions both support this) or use - // their browser's "show password" toggle on a per- - // field basis. + // Reset any open eye-toggle so a prior "show" click + // does not leave the freshly generated value visible. + var pwToggle = /** @type {HTMLElement|null} */ ( + document.querySelector('[data-testid="admin-add-password-toggle"]') + ); + if (pwToggle) setPasswordGroupVisible(pwToggle, false); }).catch(function (err) { // sb.api.call only rejects on internal failures (it // catches fetch / json errors and synthesises an From 339a48154d818cd97bb17e98e847ba0bdfdff251 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 20:52:47 -0300 Subject: [PATCH 07/19] fix(frontend): flip themed select panels when they would overflow Open upward when there is more room above the trigger, and shrink max-height to the available viewport space. --- .../integration/ThemedSelectEnhancerTest.php | 5 +++ web/themes/default/css/theme.css | 6 +++ web/themes/default/js/theme.js | 39 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/web/tests/integration/ThemedSelectEnhancerTest.php b/web/tests/integration/ThemedSelectEnhancerTest.php index 1db58d66b..17c60575b 100644 --- a/web/tests/integration/ThemedSelectEnhancerTest.php +++ b/web/tests/integration/ThemedSelectEnhancerTest.php @@ -41,6 +41,8 @@ public function testThemeJsDefinesSingleSelectEnhancer(): void self::assertStringContainsString("data-lucide=\"chevron-down\" class=\"ssel__chevron\"", $js); self::assertStringContainsString('data-native-select', $js); self::assertStringContainsString("querySelectorAll('select.select')", $js); + self::assertStringContainsString('function positionSelectPanel(', $js); + self::assertStringContainsString("data-placement", $js); } public function testThemeJsSkipsMultiselectAndNativeOptOut(): void @@ -64,6 +66,9 @@ public function testThemeCssSharesSelectChromeBetweenMselAndSsel(): void self::assertStringContainsString('.msel__panel, .ssel__panel {', $css); self::assertStringContainsString('.ssel__option[aria-selected="true"]', $css); self::assertStringContainsString('.ssel__group', $css); + self::assertStringContainsString('.msel[data-placement="top"] .msel__panel', $css); + self::assertStringContainsString('.ssel[data-placement="top"] .ssel__panel', $css); + self::assertStringContainsString('bottom: calc(100% + 0.25rem)', $css); } public function testThemeCssDoesNotForceMinWidthOnSingleSelect(): void diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 7aac9aa3d..66f2bfde0 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -944,6 +944,7 @@ html.dark .admin-tabs > [aria-current="page"] { border-bottom-color: var(--brand position: absolute; z-index: 40; top: calc(100% + 0.25rem); + bottom: auto; left: 0; right: 0; max-height: 16rem; @@ -954,6 +955,11 @@ html.dark .admin-tabs > [aria-current="page"] { border-bottom-color: var(--brand border: 1px solid var(--border); box-shadow: var(--shadow-lg, 0 10px 30px rgb(0 0 0 / 0.18)); } +.msel[data-placement="top"] .msel__panel, +.ssel[data-placement="top"] .ssel__panel { + top: auto; + bottom: calc(100% + 0.25rem); +} .msel__panel[hidden], .ssel__panel[hidden] { display: none !important; } .msel__option, .ssel__option { display: flex; diff --git a/web/themes/default/js/theme.js b/web/themes/default/js/theme.js index 296a48721..5ffc02c70 100644 --- a/web/themes/default/js/theme.js +++ b/web/themes/default/js/theme.js @@ -1985,6 +1985,35 @@ if (document.readyState !== 'loading') applyPlatformHints(); else document.addEventListener('DOMContentLoaded', applyPlatformHints); + // ---- Themed select panel placement (msel + ssel) ------------ + // Open downward by default; flip above the trigger when the panel + // would overflow the viewport and there is more room above. + /** + * @param {HTMLElement} wrap + * @param {HTMLElement} trigger + * @param {HTMLElement} panel + * @returns {void} + */ + function positionSelectPanel(wrap, trigger, panel) { + panel.style.maxHeight = ''; + wrap.setAttribute('data-placement', 'bottom'); + + const gap = 4; + const triggerRect = trigger.getBoundingClientRect(); + const spaceBelow = window.innerHeight - triggerRect.bottom - gap; + const spaceAbove = triggerRect.top - gap; + const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const cssCap = 16 * rootFs; + const needed = Math.min(panel.scrollHeight, cssCap); + const openUp = needed > spaceBelow && spaceAbove > spaceBelow; + wrap.setAttribute('data-placement', openUp ? 'top' : 'bottom'); + + const available = openUp ? spaceAbove : spaceBelow; + if (available > 0 && available < cssCap) { + panel.style.maxHeight = Math.max(5 * rootFs, available) + 'px'; + } + } + // ---- MULTI-SELECT (select[data-multiselect]) --------------- // Progressive enhancement around a real - {$server.ip}:{$server.port} - - {/foreach} +
+ +
{/if} {/if} @@ -434,16 +383,9 @@ the orphan `$server_script` View property + the per-row `id="sa{$server.sid}"` span hook it targeted. - #1405 — additive replacement: the per-row span above carries - `[data-testid="server-host"]` + `data-fallback=":"` - and the wrapping grid div opts in via - `data-server-hydrate="auto"` + `data-trunchostname="40"`. The - shared helper (` {/literal} - - {* - #1405 — per-tile A2S hydration for the "Individual servers" - grid above. The shared helper auto-runs on first paint for - every `[data-server-hydrate="auto"]` container, fires - `Actions.ServersHostPlayers` per tile, and patches the live - hostname into the row's `[data-testid="server-host"]` slot. - This template only consumes the hostname cell — the rest of - the helper's hydration surface (status pill / map / players - bar / map-img / refresh / toggle / players panel) is - feature-detected and silently no-ops on tiles that don't - ship those testid hooks. Same mounting shape as - `page_dashboard.tpl`'s Servers widget. - - `defer` lets the rest of the page paint before the helper - boots; auto-run still fires once it does (the helper - branches on `document.readyState`). The script ships under - web/scripts/ so all four surfaces (public servers list, - admin Server Management list, dashboard Servers widget, this - Add Admin per-server access grid) share one helper file — - never copy-paste the hydration code into a new template. - *} - {/if}
From d2525c5a1cdf472f542946b1a3a0e3e92ef20b58 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 22:30:26 -0300 Subject: [PATCH 09/19] fix missing mb-3 spacing on admins list --- web/themes/default/css/theme.css | 4 ++-- web/themes/default/page_admin_admins_list.tpl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 66f2bfde0..2a691a031 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -2246,8 +2246,8 @@ details.queue-row > summary > .row-actions { max-width: 1400px; } -.m-0 { margin: 0; } .mt-2 { margin-top: 0.5rem; } .mt-4 { margin-top: 1rem; } .mt-6 { margin-top: 1.5rem; } -.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; } +.m-0 { margin: 0; } .mt-2 { margin-top: 0.5rem; } .mt-3 { margin-top: 0.75rem; } .mt-4 { margin-top: 1rem; } .mt-6 { margin-top: 1.5rem; } +.mb-2 { margin-bottom: 0.5rem; } .mb-3 { margin-bottom: 0.75rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; } .space-y-3 > * + * { margin-top: 0.75rem; } .space-y-4 > * + * { margin-top: 1rem; } .space-y-6 > * + * { margin-top: 1.5rem; } /* Hide scrollbars on chip rows */ diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index c4e2e0773..b5c073b50 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -51,11 +51,11 @@

Click an admin row's actions to edit details, permissions, or server access.

-
+
{load_template file="admin.admins.search"}
-
+
Date: Tue, 4 Aug 2026 22:39:52 -0300 Subject: [PATCH 10/19] rehash admins after deactivate/reactivate --- .../specs/flows/admin-deactivate-bulk.spec.ts | 140 ++++++++++++++++++ .../integration/AdminsDeleteDialogTest.php | 3 + web/themes/default/page_admin_admins_list.tpl | 32 +++- 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts index 030c3420f..1d57a7f85 100644 --- a/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts +++ b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts @@ -188,6 +188,146 @@ test.describe('flow: admin deactivate + bulk (#1509)', () => { await expect(row2).toBeHidden(); }); + test('deactivate success chains SystemRehashAdmins when handler returns rehash sids', async ({ page }) => { + await page.goto('/'); + const aid = await addAdmin(page, { + name: 'e2e-rehash-deact', + steam: 'STEAM_0:0:150906', + email: 'e2e-rehash-deact@test.local', + }); + + const apiCalls: Array<{ action: string; params?: Record }> = []; + await page.route((url) => url.pathname.endsWith('/api.php'), async (route) => { + const req = route.request(); + if (req.method() !== 'POST') { + await route.continue(); + return; + } + let body: { action?: string; params?: Record } = {}; + try { + body = JSON.parse(req.postData() ?? '{}'); + } catch { + await route.continue(); + return; + } + if (body.action === 'admins.deactivate') { + apiCalls.push({ action: body.action, params: body.params }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: { + aid, + enabled: 0, + rehash: '11,22', + message: { + title: 'Admin deactivated', + body: 'stub', + kind: 'green', + }, + }, + }), + }); + return; + } + if (body.action === 'system.rehash_admins') { + apiCalls.push({ action: body.action, params: body.params }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, data: { results: [] } }), + }); + return; + } + await route.continue(); + }); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await expect(row).toBeVisible(); + await row.locator('[data-testid="admin-action-deactivate"]').click(); + const dialog = page.locator('[data-testid="admins-deactivate-dialog"]'); + await dialog.locator('[data-testid="admins-deactivate-submit"]').click(); + + await expect.poll(() => apiCalls.map((c) => c.action)).toEqual([ + 'admins.deactivate', + 'system.rehash_admins', + ]); + const rehashCall = apiCalls.find((c) => c.action === 'system.rehash_admins'); + expect(rehashCall?.params?.servers).toBe('11,22'); + }); + + test('bulk deactivate chains SystemRehashAdmins when handler returns rehash sids', async ({ page }) => { + await page.goto('/'); + const aid = await addAdmin(page, { + name: 'e2e-rehash-bulk', + steam: 'STEAM_0:0:150907', + email: 'e2e-rehash-bulk@test.local', + }); + + const apiCalls: Array<{ action: string; params?: Record }> = []; + await page.route((url) => url.pathname.endsWith('/api.php'), async (route) => { + const req = route.request(); + if (req.method() !== 'POST') { + await route.continue(); + return; + } + let body: { action?: string; params?: Record } = {}; + try { + body = JSON.parse(req.postData() ?? '{}'); + } catch { + await route.continue(); + return; + } + if (body.action === 'admins.bulk') { + apiCalls.push({ action: body.action, params: body.params }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: { + op: 'deactivate', + applied: [aid], + skipped: [], + rehash: '33', + message: { + title: 'Admins deactivated', + body: '1 deactivated.', + kind: 'green', + }, + }, + }), + }); + return; + } + if (body.action === 'system.rehash_admins') { + apiCalls.push({ action: body.action, params: body.params }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, data: { results: [] } }), + }); + return; + } + await route.continue(); + }); + + await page.goto(ADMIN_ADMINS_ROUTE); + const row = page.locator(`[data-testid="admin-row"][data-id="${aid}"]`); + await row.locator('[data-testid="admin-row-select"]').check(); + await page.locator('[data-testid="admins-bulk-deactivate"]').click(); + await page.locator('[data-testid="admins-bulk-deactivate-submit"]').click(); + + await expect.poll(() => apiCalls.map((c) => c.action)).toEqual([ + 'admins.bulk', + 'system.rehash_admins', + ]); + const rehashCall = apiCalls.find((c) => c.action === 'system.rehash_admins'); + expect(rehashCall?.params?.servers).toBe('33'); + }); + test('bulk select → assign web group', async ({ page }) => { await page.goto('/'); const aid = await addAdmin(page, { diff --git a/web/tests/integration/AdminsDeleteDialogTest.php b/web/tests/integration/AdminsDeleteDialogTest.php index 71d2e34f4..6c97a47c8 100644 --- a/web/tests/integration/AdminsDeleteDialogTest.php +++ b/web/tests/integration/AdminsDeleteDialogTest.php @@ -201,6 +201,9 @@ public function testPageTailScriptUsesActionsConstant(): void 'from api-contract.js), not a string literal.'); $this->assertStringContainsString('A.AdminsDeactivate', $html); $this->assertStringContainsString('A.AdminsReactivate', $html); + $this->assertStringContainsString('A.SystemRehashAdmins', $html, + 'Deactivate / reactivate / bulk must chain SystemRehashAdmins when the handler returns rehash SIDs.'); + $this->assertStringContainsString('fireRehashIfNeeded', $html); // Sanity-check: we should NOT find the raw dotted string. $this->assertStringNotContainsString("'admins.remove'", $html, 'String literal action names are forbidden — see AGENTS.md anti-patterns.'); diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index b5c073b50..7efed9bbb 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -675,6 +675,28 @@ } } + /** + * Chain system.rehash_admins when the handler returned SIDs + * (config.enableadminrehashing). Same shape as Add Admin / + * _admin_edit_helpers fireRehash — never block the UI toast on + * a flaky rehash. + * @param {any} data + * @param {() => void} [then] + * @returns {void} + */ + function fireRehashIfNeeded(data, then) { + var done = typeof then === 'function' ? then : function () {}; + var a = api(), A = actions(); + var rehashSids = ((data && data.rehash) || '').toString(); + if (!a || !A || !A.SystemRehashAdmins || !rehashSids) { + done(); + return; + } + a.call(A.SystemRehashAdmins, { servers: rehashSids }) + .then(done) + .catch(done); + } + /** @returns {void} */ function clearSelection() { var boxes = document.querySelectorAll('[data-action="admins-select-row"]'); @@ -829,9 +851,11 @@ var title = (data.message && data.message.title) || 'Done'; var body = (data.message && data.message.body) || ''; toast(applied.length ? 'success' : 'error', title, body); - if (op === 'set_web_group' || op === 'set_srv_group' || op === 'reactivate') { - window.location.reload(); - } + fireRehashIfNeeded(data, function () { + if (op === 'set_web_group' || op === 'set_srv_group' || op === 'reactivate') { + window.location.reload(); + } + }); }); } @@ -941,6 +965,7 @@ } decrementCount(); toast('success', 'Admin reactivated', rName + ' can log in again.'); + fireRehashIfNeeded(r.data || {}); }); return; } @@ -1057,6 +1082,7 @@ } else { toast('success', 'Admin deactivated', ctx.name + ' can no longer log in.'); } + fireRehashIfNeeded(r.data || {}); }); }); From ecd3757278e765cd79bda5948752a7a3e0bec353 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 22:39:58 -0300 Subject: [PATCH 11/19] document windows powershell docker compose --- AGENTS.md | 25 +++++++++++++++++++++++-- docker/README.md | 6 ++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e00e24b1..2a93965cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,25 @@ Quick rules: Run from the repo root. All commands are idempotent. +**Host shell:** `./sbpp.sh` is a bash wrapper for Linux / macOS / Git +Bash / WSL. On **Windows PowerShell**, do **not** invoke `./sbpp.sh`. +Read the matching arm in `sbpp.sh` and run the underlying +`docker compose` / `docker compose exec` line directly. Agents on a +`win32` / PowerShell host MUST follow that rule. + +Critical PowerShell translations (env overrides are load-bearing; +without `-e DB_NAME=sourcebans_test`, PHPUnit hits the seeded +`sourcebans` panel DB because the web container's compose env wins +over `phpunit.xml`): + +```powershell +# PHPUnit (sourcebans_test only) +docker compose exec -e DB_HOST=db -e DB_PORT=3306 -e DB_NAME=sourcebans_test -e DB_USER=sourcebans -e DB_PASS=sourcebans -e DB_PREFIX=sb -e DB_CHARSET=utf8mb4 web includes/vendor/bin/phpunit -c /var/www/html/web/phpunit.xml --testdox + +# Dev seed (sourcebans) +docker compose exec -e DB_HOST=db -e DB_PORT=3306 -e DB_NAME=sourcebans -e DB_USER=sourcebans -e DB_PASS=sourcebans -e DB_PREFIX=sb -e DB_CHARSET=utf8mb4 web php /var/www/html/web/tests/scripts/seed-dev-db.php +``` + ```sh ./sbpp.sh up # build + start (panel at :8080, admin/admin) ./sbpp.sh down # stop, keep volumes @@ -127,8 +146,10 @@ URLs after `up`: panel `http://localhost:8080` (admin/admin), Adminer The web container bind-mounts `./web`, so PHP edits land on the next request — no restart. Restart only when: -- `composer.json` changed → `./sbpp.sh composer install` -- anything in `docker/` changed → `./sbpp.sh rebuild` +- `composer.json` changed → `./sbpp.sh composer install` (or the + PowerShell `docker compose exec … composer` equivalent on Windows) +- anything in `docker/` changed → `./sbpp.sh rebuild` (or + `docker compose build --no-cache web` on Windows) ## Parallel stacks (subagents / multiple worktrees) diff --git a/docker/README.md b/docker/README.md index 92a7d992b..f347bf43e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -21,6 +21,12 @@ up on the next request — no rebuilds needed. - Docker 24+ with the Compose plugin (`docker compose`, not `docker-compose`) - Ports `8080`, `8081`, `8025`, `1025`, `3307` free on the host (override in `.env`) +`./sbpp.sh` is a bash wrapper. On **Windows PowerShell**, skip it and run +the underlying `docker compose` commands from `sbpp.sh` directly (see +`AGENTS.md` → Dev commands for the PHPUnit / seed env overrides; those +`-e DB_NAME=…` flags are required so tests never wipe the seeded panel +DB). + ## Quick start ```sh From 8433bb0255e2c4e6a9f6290442f3d0861beabea3 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 22:41:42 -0300 Subject: [PATCH 12/19] theme bulk admin group selects --- .../specs/flows/admin-deactivate-bulk.spec.ts | 1 + .../integration/ThemedSelectEnhancerTest.php | 29 +++++++++++++++++++ web/themes/default/page_admin_admins_list.tpl | 4 +-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts index 1d57a7f85..84601e64c 100644 --- a/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts +++ b/web/tests/e2e/specs/flows/admin-deactivate-bulk.spec.ts @@ -345,6 +345,7 @@ test.describe('flow: admin deactivate + bulk (#1509)', () => { await page.locator('[data-testid="admins-bulk-web-group"]').click(); const dialog = page.locator('[data-testid="admins-bulk-web-group-dialog"]'); await expect(dialog).toBeVisible(); + await expect(dialog.locator('[data-ssel="true"]')).toBeVisible(); await dialog.locator('[data-testid="admins-bulk-web-group-select"]').selectOption(String(group.gid)); const bulkResp = page.waitForResponse( diff --git a/web/tests/integration/ThemedSelectEnhancerTest.php b/web/tests/integration/ThemedSelectEnhancerTest.php index 4d6bc341d..a37dcbb75 100644 --- a/web/tests/integration/ThemedSelectEnhancerTest.php +++ b/web/tests/integration/ThemedSelectEnhancerTest.php @@ -95,4 +95,33 @@ public function testThemeCssDoesNotForceMinWidthOnSingleSelect(): void $afterShared, ); } + + public function testAdminsBulkGroupDialogsUseThemedSelectClass(): void + { + $path = dirname(__DIR__, 2) . '/themes/default/page_admin_admins_list.tpl'; + self::assertFileExists($path); + $src = file_get_contents($path); + self::assertNotFalse($src); + + self::assertStringContainsString( + 'id="admins-bulk-web-group-select"', + $src, + ); + self::assertStringContainsString( + ' + + + // (banlist/commslist use width:auto). Those styles stay on the + // visually-hidden select; mirror them onto the wrap so .ssel's + // default width:100% does not stretch the chrome. + if (select.style.width) wrap.style.width = select.style.width; + if (select.style.minWidth) wrap.style.minWidth = select.style.minWidth; + if (select.style.maxWidth) wrap.style.maxWidth = select.style.maxWidth; parent.insertBefore(wrap, select); wrap.appendChild(select); From e0c75ea0c76ed3f4548890151f9b221558d1951b Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 22:58:26 -0300 Subject: [PATCH 15/19] mirror flex sizing onto themed select wrap --- .../integration/ThemedSelectEnhancerTest.php | 6 +++-- web/themes/default/js/theme.js | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/web/tests/integration/ThemedSelectEnhancerTest.php b/web/tests/integration/ThemedSelectEnhancerTest.php index f2a5ec510..07232bf2b 100644 --- a/web/tests/integration/ThemedSelectEnhancerTest.php +++ b/web/tests/integration/ThemedSelectEnhancerTest.php @@ -45,8 +45,10 @@ public function testThemeJsDefinesSingleSelectEnhancer(): void self::assertStringContainsString("data-placement", $js); self::assertStringContainsString("getAttribute('data-placeholder')", $js); self::assertStringContainsString('new MutationObserver', $js); - self::assertStringContainsString('if (select.style.width) wrap.style.width = select.style.width;', $js); - self::assertStringContainsString('if (select.style.minWidth) wrap.style.minWidth = select.style.minWidth;', $js); + self::assertStringContainsString('if (st.width) wrap.style.width = st.width;', $js); + self::assertStringContainsString('if (st.minWidth) wrap.style.minWidth = st.minWidth;', $js); + self::assertStringContainsString('if (st.flex) wrap.style.flex = st.flex;', $js); + self::assertStringContainsString("wrap.style.width = 'auto'", $js); $mselStart = strpos($js, 'function enhanceMultiselect(select)'); self::assertNotFalse($mselStart); $mselChunk = substr($js, $mselStart, 8000); diff --git a/web/themes/default/js/theme.js b/web/themes/default/js/theme.js index da834f279..dd6307413 100644 --- a/web/themes/default/js/theme.js +++ b/web/themes/default/js/theme.js @@ -2218,13 +2218,22 @@ const wrap = document.createElement('div'); wrap.className = 'ssel'; wrap.setAttribute('data-ssel', 'true'); - // Native filters often set width/min-width on the + // (banlist filters use width:auto; advanced-search length uses + // width:5rem + flex:1). Those styles stay on the visually-hidden + // select; mirror them onto the wrap so .ssel's default width:100% + // does not stretch the chrome or force flex siblings onto a new row. + const st = select.style; + if (st.width) wrap.style.width = st.width; + if (st.minWidth) wrap.style.minWidth = st.minWidth; + if (st.maxWidth) wrap.style.maxWidth = st.maxWidth; + if (st.flex) wrap.style.flex = st.flex; + if (st.flexGrow) wrap.style.flexGrow = st.flexGrow; + if (st.flexShrink) wrap.style.flexShrink = st.flexShrink; + if (st.flexBasis) wrap.style.flexBasis = st.flexBasis; + if ((st.flex || st.flexGrow) && !st.width) { + wrap.style.width = 'auto'; + } parent.insertBefore(wrap, select); wrap.appendChild(select); From d0256496c5b41bbc8cc6b4cb5826db1b99bd1679 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Tue, 4 Aug 2026 23:01:18 -0300 Subject: [PATCH 16/19] unify admins bulk action button chrome --- web/themes/default/css/theme.css | 38 +++++++++++++++ web/themes/default/page_admin_admins_list.tpl | 47 +++++++++++-------- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 3fdc43949..9491095fa 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -420,6 +420,44 @@ html.dark .btn--secondary[aria-pressed="true"] { .btn--ghost:hover { --btn-color: var(--text); } .btn--danger { --btn-bg: var(--danger); --btn-color: white; --btn-bg-hover: #b91c1c; } .btn--sm { height: 2rem; padding: 0 0.75rem; font-size: var(--fs-xs); } + +/* Sticky bulk-action bar on admin-admins list. Groups related actions + with a hairline separator so lifecycle / group / destructive stay + visually ordered; every non-destructive control shares btn--secondary. */ +.admins-bulk-bar { + position: sticky; + bottom: 1rem; + z-index: 20; + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--bg-elevated); + box-shadow: var(--shadow-md); +} +.admins-bulk-bar[hidden] { display: none !important; } +.admins-bulk-bar__count { flex: 0 0 auto; } +.admins-bulk-bar__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-left: auto; +} +.admins-bulk-bar__group { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + align-items: center; +} +.admins-bulk-bar__group + .admins-bulk-bar__group { + padding-left: 0.75rem; + border-left: 1px solid var(--border); +} .btn--icon { width: 2.25rem; padding: 0; } /* .btn--xs sizes an icon-only button down to 1.5rem so it can sit inline next to a single line of text (drawer ID copy buttons — diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index eb3907d4c..dbb751db1 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -347,30 +347,37 @@ data-testid="admins-bulk-bar" hidden role="region" - aria-label="Bulk admin actions" - style="position:sticky;bottom:1rem;z-index:20;margin-top:1rem;display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;padding:0.75rem 1rem;border:1px solid var(--border);border-radius:0.75rem;background:var(--bg-elevated);box-shadow:var(--shadow-md)"> - 0 selected -
+ aria-label="Bulk admin actions"> + 0 selected +
{if $can_delete_admins} - - - +
+ + +
{/if} {if $can_edit_admins} - - +
+ + +
{/if} - +
+ {if $can_delete_admins} + + {/if} + +
{/if} From 38283b949356448037d3d280f35b45be392ddddc Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Wed, 5 Aug 2026 01:45:49 -0300 Subject: [PATCH 17/19] fix mobile admins bulk select chrome --- web/themes/default/css/theme.css | 61 +++++++- web/themes/default/page_admin_admins_list.tpl | 135 ++++++++++++------ 2 files changed, 143 insertions(+), 53 deletions(-) diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index 9491095fa..2bc4a0f76 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -421,14 +421,15 @@ html.dark .btn--secondary[aria-pressed="true"] { .btn--danger { --btn-bg: var(--danger); --btn-color: white; --btn-bg-hover: #b91c1c; } .btn--sm { height: 2rem; padding: 0 0.75rem; font-size: var(--fs-xs); } -/* Sticky bulk-action bar on admin-admins list. Groups related actions - with a hairline separator so lifecycle / group / destructive stay - visually ordered; every non-destructive control shares btn--secondary. */ +/* Sticky bulk-action bar on admin-admins list (above the table). + Groups related actions with a hairline separator so lifecycle / + group / destructive stay visually ordered; every non-destructive + control shares btn--secondary. */ .admins-bulk-bar { position: sticky; - bottom: 1rem; + top: 0.75rem; z-index: 20; - margin-top: 1rem; + margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; gap: 0.75rem; @@ -436,8 +437,8 @@ html.dark .btn--secondary[aria-pressed="true"] { padding: 0.75rem 1rem; border: 1px solid var(--border); border-radius: 0.75rem; - background: var(--bg-elevated); - box-shadow: var(--shadow-md); + background: var(--bg-surface); + box-shadow: var(--shadow-lg); } .admins-bulk-bar[hidden] { display: none !important; } .admins-bulk-bar__count { flex: 0 0 auto; } @@ -458,6 +459,36 @@ html.dark .btn--secondary[aria-pressed="true"] { padding-left: 0.75rem; border-left: 1px solid var(--border); } +@media (max-width: 768px) { + .admins-bulk-bar { + position: sticky; + top: 0.5rem; + margin-bottom: 1.25rem; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + max-height: min(40vh, 16rem); + overflow-y: auto; + } + .admins-bulk-bar__actions { + margin-left: 0; + width: 100%; + gap: 0.5rem; + } + .admins-bulk-bar__group { + width: 100%; + } + .admins-bulk-bar__group + .admins-bulk-bar__group { + padding-left: 0; + border-left: none; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + } + .admins-bulk-bar__group .btn { + flex: 1 1 calc(50% - 0.25rem); + justify-content: center; + } +} .btn--icon { width: 2.25rem; padding: 0; } /* .btn--xs sizes an icon-only button down to 1.5rem so it can sit inline next to a single line of text (drawer ID copy buttons — @@ -2220,6 +2251,21 @@ details.queue-row > summary > .row-actions { .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; } +.admins-list-select-all { + display: none; + padding: 0.625rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--bg-muted); +} +.admins-list-select-all__label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: var(--fs-sm); + color: var(--text-muted); + cursor: pointer; + user-select: none; +} /* ---- Responsive ---- */ [data-mobile-menu] { display: none; } @@ -2239,6 +2285,7 @@ details.queue-row > summary > .row-actions { dance as `.ban-cards` — hidden at desktop, block at mobile. */ .log-cards { display: block; } .admins-list-cards { display: block; } + .admins-list-select-all { 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 diff --git a/web/themes/default/page_admin_admins_list.tpl b/web/themes/default/page_admin_admins_list.tpl index dbb751db1..e8f7ada2b 100644 --- a/web/themes/default/page_admin_admins_list.tpl +++ b/web/themes/default/page_admin_admins_list.tpl @@ -82,6 +82,46 @@ {$admin_nav nofilter}
+ {if $can_delete_admins || $can_edit_admins} + + {/if} +
@@ -231,6 +271,17 @@ `@media (max-width: 768px) { .table { display: none } }` rule. Same display dance as `.ban-cards` / `.log-cards`. *}
+ {if $can_delete_admins || $can_edit_admins} +
+ +
+ {/if} {foreach $admins as $admin}
- {if $can_delete_admins || $can_edit_admins} - - {/if} - {* ============================================================ #1352 — admin-delete confirm + reason modal scaffold. @@ -659,6 +670,35 @@ return aids; } + /** @returns {number[]} */ + function enabledAids() { + var boxes = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); + var aids = []; + for (var i = 0; i < boxes.length; i++) { + var aid = Number(/** @type {HTMLElement} */ (boxes[i]).getAttribute('data-aid') || 0); + if (aid > 0 && aids.indexOf(aid) === -1) aids.push(aid); + } + return aids; + } + + /** + * Keep desktop-table and mobile-card checkboxes for the same + * aid in lockstep (both surfaces stay in the DOM; only one is + * visible per viewport). + * @param {string} aid + * @param {boolean} on + * @returns {void} + */ + function setRowChecked(aid, on) { + if (!aid) return; + var boxes = document.querySelectorAll('[data-action="admins-select-row"][data-aid="' + aid + '"]'); + for (var i = 0; i < boxes.length; i++) { + var box = /** @type {HTMLInputElement} */ (boxes[i]); + if (box.disabled) continue; + box.checked = on; + } + } + /** @returns {void} */ function syncBulkBar() { var bar = document.querySelector('[data-testid="admins-bulk-bar"]'); @@ -673,12 +713,13 @@ bar.setAttribute('hidden', ''); /** @type {HTMLElement} */ (bar).style.display = 'none'; } - var all = document.querySelector('[data-action="admins-select-all"]'); - if (all) { - var enabled = document.querySelectorAll('[data-action="admins-select-row"]:not(:disabled)'); - var checked = document.querySelectorAll('[data-action="admins-select-row"]:checked'); - /** @type {HTMLInputElement} */ (all).checked = enabled.length > 0 && checked.length === enabled.length; - /** @type {HTMLInputElement} */ (all).indeterminate = checked.length > 0 && checked.length < enabled.length; + var enabled = enabledAids(); + var allOn = enabled.length > 0 && aids.length === enabled.length; + var allSome = aids.length > 0 && aids.length < enabled.length; + var allBoxes = document.querySelectorAll('[data-action="admins-select-all"]'); + for (var ai = 0; ai < allBoxes.length; ai++) { + /** @type {HTMLInputElement} */ (allBoxes[ai]).checked = allOn; + /** @type {HTMLInputElement} */ (allBoxes[ai]).indeterminate = allSome; } } @@ -879,6 +920,8 @@ return; } if (t.matches('[data-action="admins-select-row"]')) { + var row = /** @type {HTMLInputElement} */ (t); + setRowChecked(row.getAttribute('data-aid') || '', row.checked); syncBulkBar(); } }); From 8a9bd3d11da9d1df54b1c9341c3b7e281da50070 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Wed, 5 Aug 2026 02:07:12 -0300 Subject: [PATCH 18/19] replace native confirm with panel dialogs --- AGENTS.md | 1 + web/pages/admin.edit.ban.php | 49 ++++--- .../specs/flows/admin-groups-delete.spec.ts | 13 +- .../NativeConfirmRegressionTest.php | 102 ++++++++++++++ web/themes/default/css/theme.css | 7 + web/themes/default/js/theme.js | 124 ++++++++++++++++++ .../page_admin_bans_protests_archiv.tpl | 39 +++--- .../page_admin_bans_submissions_archiv.tpl | 39 +++--- web/themes/default/page_admin_groups_list.tpl | 64 +++++---- .../default/page_admin_servers_list.tpl | 63 +++++---- .../default/page_admin_settings_logs.tpl | 15 ++- .../default/page_admin_settings_settings.tpl | 4 +- .../default/page_admin_settings_themes.tpl | 20 ++- 13 files changed, 420 insertions(+), 120 deletions(-) create mode 100644 web/tests/integration/NativeConfirmRegressionTest.php diff --git a/AGENTS.md b/AGENTS.md index 2a93965cd..ad83b46d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4922,6 +4922,7 @@ contributions without contacting every contributor individually. | Reuse the moderation-queue card layout (admin submissions / protests, mobile-stacked summary rows) | `web/themes/default/css/theme.css` (`.queue-row`, `.queue-row__body`, `.queue-row__date` — #1207 PUB-2). Apply by adding `class="queue-row …"` to the outer `
` and dropping the inline `flex` / `flex-shrink:0` styles from the summary children. | | Add visible row actions to a table-rendered admin list (Edit / Unmute / Remove buttons + responsive mobile-card mirror) | `web/themes/default/page_admin_admins_list.tpl` is the density reference: icon-only `` / `' + + '' + + '
' + + ''; + document.body.appendChild(d); + + d.addEventListener('click', (/** @type {MouseEvent} */ e) => { + if (e.target === d) finishConfirm(false); + }); + d.addEventListener('cancel', (/** @type {Event} */ e) => { + e.preventDefault(); + finishConfirm(false); + }); + const cancelBtn = d.querySelector('[data-testid="sbpp-confirm-cancel"]'); + if (cancelBtn) { + cancelBtn.addEventListener('click', (/** @type {Event} */ e) => { + e.preventDefault(); + finishConfirm(false); + }); + } + const form = d.querySelector('[data-testid="sbpp-confirm-form"]'); + if (form) { + form.addEventListener('submit', (/** @type {Event} */ e) => { + e.preventDefault(); + finishConfirm(true); + }); + } + return d; + } + + /** + * @param {boolean} ok + * @returns {void} + */ + function finishConfirm(ok) { + const resolve = confirmResolve; + confirmResolve = null; + const d = /** @type {HTMLDialogElement | null} */ (document.getElementById('sbpp-confirm-dialog')); + if (d) { + try { d.close(); } catch (_e) { /* not opened modally */ } + d.setAttribute('hidden', ''); + } + if (resolve) resolve(ok); + } + + /** + * Open the shared confirm dialog. Resolves `true` on Confirm, + * `false` on Cancel / backdrop / Escape. Does not fall back to + * `window.confirm`. + * + * @param {SbppConfirmOpts} opts + * @returns {Promise} + */ + function confirmDialog(opts) { + return new Promise((resolve) => { + if (confirmResolve) { + const prev = confirmResolve; + confirmResolve = null; + prev(false); + } + const d = ensureConfirmDialog(); + const titleEl = d.querySelector('[data-testid="sbpp-confirm-title"]'); + const bodyEl = /** @type {HTMLElement | null} */ (d.querySelector('[data-testid="sbpp-confirm-body"]')); + const submitBtn = /** @type {HTMLButtonElement | null} */ (d.querySelector('[data-testid="sbpp-confirm-submit"]')); + const cancelBtn = /** @type {HTMLButtonElement | null} */ (d.querySelector('[data-testid="sbpp-confirm-cancel"]')); + + if (titleEl) titleEl.textContent = opts.title || 'Confirm'; + if (bodyEl) { + const body = opts.body || ''; + bodyEl.textContent = body; + bodyEl.hidden = body === ''; + } + if (cancelBtn) cancelBtn.textContent = opts.cancelLabel || 'Cancel'; + if (submitBtn) { + submitBtn.textContent = opts.confirmLabel || 'Confirm'; + submitBtn.className = opts.danger + ? 'btn btn--danger' + : 'btn btn--primary'; + } + + confirmResolve = resolve; + d.removeAttribute('hidden'); + try { d.showModal(); } + catch (_e) { d.setAttribute('open', ''); } + if (submitBtn) { + try { submitBtn.focus(); } catch (_e) { /* focus may throw */ } + } + }); + } + // `SHOWTOAST_DEFAULT_DURATION` is exposed on the SBPP namespace // so E2E specs can read it at runtime instead of hardcoding // `6000` (or `6500` / `7500` derived literals) into per-spec @@ -1918,6 +2041,7 @@ openDrawer: openDrawer, closeDrawer: closeDrawer, setBusy: setBusy, + confirm: confirmDialog, SHOWTOAST_DEFAULT_DURATION: SHOWTOAST_DEFAULT_DURATION, }; diff --git a/web/themes/default/page_admin_bans_protests_archiv.tpl b/web/themes/default/page_admin_bans_protests_archiv.tpl index 55cc04149..a9bf15f59 100644 --- a/web/themes/default/page_admin_bans_protests_archiv.tpl +++ b/web/themes/default/page_admin_bans_protests_archiv.tpl @@ -252,21 +252,30 @@ if (archiv === '2') msg = 'Restore the ban protest for "' + key + '" from the archive?'; else if (archiv === '1') msg = 'Move the ban protest for "' + key + '" to the archive?'; else msg = 'Delete the ban protest for "' + key + '"?'; - if (!window.confirm(msg)) return; - var a = api(), A = actions(); - if (!a || !A || !Number.isFinite(pid)) return; - setBusy(btn, true); - a.call(A.ProtestsRemove, { pid: pid, archiv: archiv }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); - return; - } - var node = document.getElementById('apid_' + pid); - if (node && node.parentNode) node.parentNode.removeChild(node); - var counter = document.getElementById('protcountarchiv'); - if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); - toast('success', 'Done', 'Archive updated.'); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: archiv === '0' ? 'Delete protest' : (archiv === '2' ? 'Restore protest' : 'Archive protest'), + body: msg, + confirmLabel: archiv === '0' ? 'Delete' : (archiv === '2' ? 'Restore' : 'Archive'), + danger: archiv === '0', + }).then(function (ok) { + if (!ok) return; + var a = api(), A = actions(); + if (!a || !A || !Number.isFinite(pid)) return; + setBusy(btn, true); + a.call(A.ProtestsRemove, { pid: pid, archiv: archiv }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); + return; + } + var node = document.getElementById('apid_' + pid); + if (node && node.parentNode) node.parentNode.removeChild(node); + var counter = document.getElementById('protcountarchiv'); + if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); + toast('success', 'Done', 'Archive updated.'); + }); }); }); })(); diff --git a/web/themes/default/page_admin_bans_submissions_archiv.tpl b/web/themes/default/page_admin_bans_submissions_archiv.tpl index 5a8a4c661..c9a2456c3 100644 --- a/web/themes/default/page_admin_bans_submissions_archiv.tpl +++ b/web/themes/default/page_admin_bans_submissions_archiv.tpl @@ -263,21 +263,30 @@ if (archiv === '2') msg = 'Restore the ban submission for "' + name + '" from the archive?'; else if (archiv === '1') msg = 'Move the ban submission for "' + name + '" to the archive?'; else msg = 'Delete the ban submission for "' + name + '"?'; - if (!window.confirm(msg)) return; - var a = api(), A = actions(); - if (!a || !A || !Number.isFinite(sid)) return; - setBusy(btn, true); - a.call(A.SubmissionsRemove, { sid: sid, archiv: archiv }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); - return; - } - var node = document.getElementById('asid_' + sid); - if (node && node.parentNode) node.parentNode.removeChild(node); - var counter = document.getElementById('subcountarchiv'); - if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); - toast('success', 'Done', 'Archive updated.'); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: archiv === '0' ? 'Delete submission' : (archiv === '2' ? 'Restore submission' : 'Archive submission'), + body: msg, + confirmLabel: archiv === '0' ? 'Delete' : (archiv === '2' ? 'Restore' : 'Archive'), + danger: archiv === '0', + }).then(function (ok) { + if (!ok) return; + var a = api(), A = actions(); + if (!a || !A || !Number.isFinite(sid)) return; + setBusy(btn, true); + a.call(A.SubmissionsRemove, { sid: sid, archiv: archiv }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + toast('error', 'Action failed', (r && r.error && r.error.message) || 'Unknown error'); + return; + } + var node = document.getElementById('asid_' + sid); + if (node && node.parentNode) node.parentNode.removeChild(node); + var counter = document.getElementById('subcountarchiv'); + if (counter) counter.textContent = String(Math.max(0, Number(counter.textContent) - 1)); + toast('success', 'Done', 'Archive updated.'); + }); }); }); })(); diff --git a/web/themes/default/page_admin_groups_list.tpl b/web/themes/default/page_admin_groups_list.tpl index 6ca57399c..102a83c6d 100644 --- a/web/themes/default/page_admin_groups_list.tpl +++ b/web/themes/default/page_admin_groups_list.tpl @@ -580,34 +580,52 @@ function SbppGroupsSave(event) { } function SbppGroupsDelete(gid, name, btn) { - if (!confirm('Delete group "' + name + '"?')) return; - SbppGroupsSetBusy(btn, true); - sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: 'web' }) - .then(function (r) { - // Leave the button busy on success — the apply handler reloads / - // navigates within 1.5s and re-enabling it would let the operator - // queue a second delete on the now-stale row. - if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete group', + body: 'Delete group "' + name + '"?', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + SbppGroupsSetBusy(btn, true); + sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: 'web' }) + .then(function (r) { + // Leave the button busy on success — the apply handler reloads / + // navigates within 1.5s and re-enabling it would let the operator + // queue a second delete on the now-stale row. + if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); + return; + } + SbppGroupsSetBusy(btn, false); SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - return; - } - SbppGroupsSetBusy(btn, false); - SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - }); + }); + }); } function SbppServerGroupsDelete(gid, name, type, btn) { - if (!confirm('Delete group "' + name + '"?')) return; - SbppGroupsSetBusy(btn, true); - sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: String(type) }) - .then(function (r) { - if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete group', + body: 'Delete group "' + name + '"?', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + SbppGroupsSetBusy(btn, true); + sb.api.call(Actions.GroupsRemove, { gid: Number(gid), type: String(type) }) + .then(function (r) { + if (r && r.ok && (r.data && (r.data.reload || (r.data.message && r.data.message.redir)))) { + SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); + return; + } + SbppGroupsSetBusy(btn, false); SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - return; - } - SbppGroupsSetBusy(btn, false); - SbppGroupsApplyResponse(r, { defaultTitle: 'Group deleted' }); - }); + }); + }); } // --- Live bitmask preview (#1258) --- diff --git a/web/themes/default/page_admin_servers_list.tpl b/web/themes/default/page_admin_servers_list.tpl index 5d369bc33..a12bdb0ce 100644 --- a/web/themes/default/page_admin_servers_list.tpl +++ b/web/themes/default/page_admin_servers_list.tpl @@ -289,35 +289,42 @@ var sid = Number(btn.dataset.sid); var label = btn.dataset.label || ('Server #' + sid); if (!Number.isFinite(sid) || sid <= 0) return; - if (!window.confirm('Delete ' + label + '?\n\nThis removes the server entry and any group/admin mappings. Bans logged from it are retained.')) { - return; - } - var api = window.sb && window.sb.api; - if (!api || !window.Actions) return; - setBusy(btn, true); - api.call(window.Actions.ServersRemove, { sid: sid }).then(function (r) { - if (!r || r.ok === false) { - setBusy(btn, false); - if (r && r.error && window.SBPP && window.SBPP.showToast) { - window.SBPP.showToast({ kind: 'error', title: 'Delete failed', body: r.error.message || 'Unknown error' }); + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Delete server', + body: 'Delete ' + label + '?\n\nThis removes the server entry and any group/admin mappings. Bans logged from it are retained.', + confirmLabel: 'Delete', + danger: true, + }).then(function (ok) { + if (!ok) return; + var api = window.sb && window.sb.api; + if (!api || !window.Actions) return; + setBusy(btn, true); + api.call(window.Actions.ServersRemove, { sid: sid }).then(function (r) { + if (!r || r.ok === false) { + setBusy(btn, false); + if (r && r.error && window.SBPP && window.SBPP.showToast) { + window.SBPP.showToast({ kind: 'error', title: 'Delete failed', body: r.error.message || 'Unknown error' }); + } + return; + } + // The handler returns { remove: 'sid_', counter: { srvcount: } }; + // mirror what applyApiResponse does in sourcebans.js without + // dragging in the legacy module. + var d = (r && r.data) || {}; + if (d.remove) { + var node = document.getElementById(String(d.remove)); + if (node && node.parentNode) node.parentNode.removeChild(node); + } + if (d.counter && typeof d.counter.srvcount !== 'undefined') { + var counter = document.getElementById('srvcount'); + if (counter) counter.textContent = String(d.counter.srvcount); + } + if (window.SBPP && window.SBPP.showToast) { + window.SBPP.showToast({ kind: 'success', title: 'Server deleted', body: label }); } - return; - } - // The handler returns { remove: 'sid_', counter: { srvcount: } }; - // mirror what applyApiResponse does in sourcebans.js without - // dragging in the legacy module. - var d = (r && r.data) || {}; - if (d.remove) { - var node = document.getElementById(String(d.remove)); - if (node && node.parentNode) node.parentNode.removeChild(node); - } - if (d.counter && typeof d.counter.srvcount !== 'undefined') { - var counter = document.getElementById('srvcount'); - if (counter) counter.textContent = String(d.counter.srvcount); - } - if (window.SBPP && window.SBPP.showToast) { - window.SBPP.showToast({ kind: 'success', title: 'Server deleted', body: label }); - } + }); }); }); })(); diff --git a/web/themes/default/page_admin_settings_logs.tpl b/web/themes/default/page_admin_settings_logs.tpl index a8fd983b2..756334b8e 100644 --- a/web/themes/default/page_admin_settings_logs.tpl +++ b/web/themes/default/page_admin_settings_logs.tpl @@ -283,11 +283,20 @@ * Truncate the log table by hitting the legacy `?log_clear=true` * endpoint on this page (admin.settings.php's TRUNCATE branch). We * full-page nav so the freshly-empty list paints without a JSON dance. - * Confirm() so a misclick on the danger button doesn't nuke history. + * Confirm dialog so a misclick on the danger button doesn't nuke history. */ window.clearLogs = function () { - if (!window.confirm('Clear the entire system log? This cannot be undone.')) return; - window.location.href = 'index.php?p=admin&c=settings§ion=logs&log_clear=true'; + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Clear system log', + body: 'Clear the entire system log? This cannot be undone.', + confirmLabel: 'Clear log', + danger: true, + }).then(function (ok) { + if (!ok) return; + window.location.href = 'index.php?p=admin&c=settings§ion=logs&log_clear=true'; + }); }; })(); {/literal} diff --git a/web/themes/default/page_admin_settings_settings.tpl b/web/themes/default/page_admin_settings_settings.tpl index d84ef6b27..18f9bd64a 100644 --- a/web/themes/default/page_admin_settings_settings.tpl +++ b/web/themes/default/page_admin_settings_settings.tpl @@ -617,7 +617,9 @@ window.clearCacheBtn = function () { if (!window.sb || !window.sb.api || !window.Actions) return; window.sb.api.call(window.Actions.SystemClearCache, {}).then(function () { - window.alert('Cache cleared.'); + if (window.SBPP && typeof window.SBPP.showToast === 'function') { + window.SBPP.showToast({ kind: 'success', title: 'Cache cleared' }); + } }); }; diff --git a/web/themes/default/page_admin_settings_themes.tpl b/web/themes/default/page_admin_settings_themes.tpl index b6f4cae2e..508e5ddc1 100644 --- a/web/themes/default/page_admin_settings_themes.tpl +++ b/web/themes/default/page_admin_settings_themes.tpl @@ -150,12 +150,20 @@ */ window.applyTheme = function (theme) { if (!theme) return; - if (!window.confirm('Switch the panel theme to "' + theme + '"? Every visitor will see the new theme on their next request.')) return; - if (!window.sb || !window.sb.api || !window.Actions) return; - window.sb.api.callOrAlert(window.Actions.SystemApplyTheme, { theme: theme }).then(function (env) { - if (env && env.ok && env.data && env.data.reload) { - window.location.reload(); - } + var S = window.SBPP; + if (!S || typeof S.confirm !== 'function') return; + S.confirm({ + title: 'Switch theme', + body: 'Switch the panel theme to "' + theme + '"? Every visitor will see the new theme on their next request.', + confirmLabel: 'Switch theme', + }).then(function (ok) { + if (!ok) return; + if (!window.sb || !window.sb.api || !window.Actions) return; + window.sb.api.callOrAlert(window.Actions.SystemApplyTheme, { theme: theme }).then(function (env) { + if (env && env.ok && env.data && env.data.reload) { + window.location.reload(); + } + }); }); }; })(); From bafd23fc86558fb91e1fb02b65ca16a3aeb15e97 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Wed, 5 Aug 2026 02:11:25 -0300 Subject: [PATCH 19/19] use multiselects on edit admin servers --- AGENTS.md | 2 +- web/includes/View/EditAdminServersView.php | 4 +- .../EditAdminServersMultiselectTest.php | 152 ++++++++++++++++++ .../page_admin_edit_admins_servers.tpl | 95 ++++++++--- 4 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 web/tests/integration/EditAdminServersMultiselectTest.php diff --git a/AGENTS.md b/AGENTS.md index ad83b46d2..b5c7be7c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4936,7 +4936,7 @@ contributions without contacting every contributor individually. | Edit the player-detail drawer (open trigger, tabs, panes, lazy loaders) | `web/themes/default/js/theme.js` (`renderDrawerBody` / `loadPaneIfNeeded`). The drawer handles two focal kinds via `drawerKind` (`'ban'` / `'comm'`): the bans list ships `data-drawer-bid` / `data-drawer-href` on row anchors, the comms list ships `data-drawer-cid`. `loadDrawer({kind, id})` dispatches to `Actions.BansDetail` (bid → `bans.detail`) or `Actions.CommsDetail` (cid → `comms.detail`) and stamps the response into `drawerDetail`. `loadPaneIfNeeded` then keys lazy panes off the focal kind: bans-focal History sends `{bid}` (handler excludes the focal via `BA.bid <> ?`); comm-focal History sends `{authid: drawerDetail.player.steam_id}` (no focal to exclude — different table); bans-focal Comms sends `{bid}` (resolves to authid; no comm to exclude); comm-focal Comms sends `{cid}` (handler excludes the focal cid via `C.bid <> ?` — sister contract to the bans-focal History exclusion so the Overview pane and the Comms tab don't render the same record twice). The Notes tab is admin-only and shared across both focal kinds (keys off `player.steam_id`). | | Add a comms-list player drawer parity surface (mirror the banlist's `data-drawer-href` row anchor with a comm-focal equivalent) | The desktop `` and mobile `
` rows in `web/themes/default/page_comms.tpl` carry a player-name anchor with `data-drawer-cid="{$comm.cid}"` + `data-testid="drawer-trigger"`. The `href` falls back to a useful no-JS surface (`?p=commslist&id=…` desktop / `?p=commslist&searchText=…` mobile) so the affordance still leads somewhere when JS is off / `theme.js` is stripped by a third-party theme. The drawer JS (`theme.js`'s document `click` delegate at `[data-drawer-bid], [data-drawer-cid], [data-drawer-href]`) routes the click through `keyFromTrigger(trigger)` which returns `{kind: 'comm', id}` for cid triggers; downstream `loadDrawer` dispatches `Actions.CommsDetail` and the renderer branches on `drawerKind === 'comm'` for the header chip ("Comm #N") and the Overview pane's focal-block grid (`[data-testid="drawer-block"]` with `Type` / `Reason` / `Started` / `Ends` rows — vs `[data-testid="drawer-ban"]` on the bans-focal path). The handler is `api_comms_detail` in `web/api/handlers/comms.php` (sister to `api_bans_detail`, same envelope shape modulo `cid` instead of `bid` / `block` instead of `ban` / `'unmuted'` instead of `'unbanned'` in the state vocab — both `api_comms_detail` AND `api_comms_player_history` use `'unmuted'` for `RemoveType IN ('U', 'D')` rows so the drawer's Overview pane and Comms tab don't render contradictory state labels for the same player). Public action; field-level hide-* gating mirrors `bans.detail`. Pill CSS lives next to `.pill--unbanned` in `theme.css` (`.pill--unmuted` carries the same success-bg + emerald colour treatment because admin-lifted is admin-lifted regardless of the focal kind). The drawer JS's `stateLabel()` switch in `theme.js` carries the matching `'unmuted' → 'Unmuted'` arm. Regression guards: `web/tests/api/CommsTest.php` (snapshot + state vocab + lifted-block branch + permanent-block branch + `comms.player_history` cid path with focal exclusion + 404 on unknown cid + lone-focal empty-feed shape) and `web/tests/e2e/specs/flows/ui/comms-drawer.spec.ts` (desktop) + `web/tests/e2e/specs/responsive/drawer.spec.ts` (mobile — clicking a `.ban-cards [data-testid="drawer-trigger"]` opens the comm-focal drawer, header reads "Comm #N", Type row in Overview pane). The desktop spec mirrors `player-drawer.spec.ts`'s isolation strategy — NO `truncateE2eDb` between tests, unique authids per (subtest × project × worker), and `seedCommOrAccept` / `seedBanOrAccept` helpers that tolerate `already_blocked` / `already_banned` so a Playwright retry on the same worker reuses the existing row. Adding a per-test truncate would only widen the cross-file race window where a concurrent worker's API call lands during another worker's truncate→reseed gap and gets a `forbidden` cascade; the comms-drawer tests are read-shaped (open the drawer, assert the chrome) so authid-namespacing is enough. | | Render the per-server map thumbnail in the expanded public server card | `web/themes/default/page_servers.tpl` (`` slot inside `[data-testid="server-players-panel"]`) + `web/scripts/server-tile-hydrate.js`'s `applyData()` (patches `src` from `r.data.mapimg`, toggles `hidden` on `load` / `error`). The lookup is feature-detected via the testid so the admin Server Management list (which does NOT ship the slot) silently no-ops. The URL itself comes from global helper `\GetMapImage()` in `web/includes/system-functions.php` (falls back to `images/maps/nomap.jpg` when the file is missing); the bundled `nomap.jpg` placeholder ships under `web/images/maps/`. The slot must default to `hidden` and stay hidden on the `error` branch — fork installs without `nomap.jpg` would otherwise paint a broken-image icon. Sizing (#1375): the inline style is `display:block;width:100%;max-width:340px;height:auto;margin:0 auto 0.5rem` — `max-width: 340px` matches the natural source width of the bundled `*.jpg` thumbnails (340×255, ~4:3) so the box never upscales and never exceeds the source dimensions; `height: auto` derives the proportional height from the rendered width so the rendered box matches the source aspect ratio exactly. Pre-#1375 the slot ran `width:100%;max-height:140px;object-fit:cover` which clamped the box to a ~2.86:1 strip on a 28rem card and `object-fit:cover` cropped the middle horizontal band of a 4:3 source — operators perceived the result as "stretched horizontally". Don't reintroduce `max-height` or `object-fit:cover` here; let `height: auto` carry the proportional sizing. Regression guards: `web/tests/integration/ServerMapImageRenderTest.php` (template ships the slot + helper carries the wiring + handler still emits `mapimg`, AND `testMapImgSlotPreservesNaturalAspectRatio` pins the new `max-width: 340px` / `height: auto` shape + the absence of `max-height` / `object-fit`) + `web/tests/e2e/specs/flows/server-map-thumbnail.spec.ts` (runtime visibility under success / 404 / connect-error). #1312 restored this surface after the #1123 D1 redesign dropped the legacy ``; #1313 moved the wiring out of the inline `