From aba319751bfa0362ca702021a471486b1da477a5 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 27 Feb 2026 03:49:28 -0500 Subject: [PATCH] Move admin menu wiring server-side and filter ban/unban lists --- client/public/version.js | 2 +- client/src/main.ts | 83 +++++++++++++++++++++++----------- client/src/network/protocol.ts | 10 +++- docs/protocol-notes.md | 3 +- docs/runtime-flow.md | 2 +- server/app/auth_service.py | 17 +------ server/app/models.py | 3 ++ server/app/server.py | 33 ++++++++++++-- 8 files changed, 103 insertions(+), 50 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 92bba80..6113b76 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.27 R286"; +window.CHGRID_WEB_VERSION = "2026.02.27 R287"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 5e17e13..1165572 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -186,10 +186,8 @@ type AuthPolicy = { passwordMaxLength: number; }; -type AdminMenuActionId = 'manage_roles' | 'change_user_role' | 'ban_user' | 'unban_user'; - type AdminMenuAction = { - id: AdminMenuActionId; + id: string; label: string; }; @@ -300,6 +298,7 @@ let itemPropertiesShowAll = false; let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; const adminMenuActions: AdminMenuAction[] = []; +let serverAdminMenuActions: AdminMenuAction[] = []; let adminMenuIndex = 0; let adminRoles: AdminRoleSummary[] = []; let adminRoleIndex = 0; @@ -595,6 +594,16 @@ function applyAuthPermissions(role: string | null | undefined, permissions: stri applyVoiceSendPermission(); } +/** Applies server-authored admin menu actions for current session. */ +function applyServerAdminMenuActions(actions: Array<{ id: string; label: string }> | null | undefined): void { + serverAdminMenuActions = (actions || []) + .map((entry) => ({ + id: String(entry.id || '').trim(), + label: String(entry.label || '').trim(), + })) + .filter((entry) => entry.id.length > 0 && entry.label.length > 0); +} + /** Applies server-authoritative voice.send permission immediately to local outbound track state. */ function applyVoiceSendPermission(): void { voiceSendAllowed = hasPermission('voice.send'); @@ -1485,6 +1494,7 @@ function sendAuthRequest(): void { function handleAuthRequired(message: Extract): void { applyAuthPolicy(message.authPolicy); applyAuthPermissions('user', []); + applyServerAdminMenuActions([]); setConnectionStatus('Authentication required.'); updateStatus(message.message); } @@ -1502,6 +1512,7 @@ async function handleAuthResult(message: Extract): void { const hadVoiceSend = voiceSendAllowed; applyAuthPermissions(message.role, message.permissions); + applyServerAdminMenuActions(message.adminMenuActions); if (hadVoiceSend && !voiceSendAllowed) { updateStatus('Voice send permission revoked.'); } @@ -1562,18 +1576,7 @@ function handleAuthPermissions(message: Extract { if (message.type === 'welcome') { applyAuthPolicy(message.auth?.policy); applyAuthPermissions(message.auth?.role, message.auth?.permissions); + const uiAdminActions = + (message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ?? + message.auth?.adminMenuActions; + applyServerAdminMenuActions(uiAdminActions); const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; connectedAnnouncement = reconnectInFlight @@ -2637,19 +2644,19 @@ function handleAdminMenuModeInput(code: string, key: string): void { } if (selected.id === 'change_user_role') { adminPendingUserAction = 'set_role'; - signaling.send({ type: 'admin_users_list' }); + signaling.send({ type: 'admin_users_list', action: 'set_role' }); updateStatus('Loading users...'); return; } if (selected.id === 'ban_user') { adminPendingUserAction = 'ban'; - signaling.send({ type: 'admin_users_list' }); + signaling.send({ type: 'admin_users_list', action: 'ban' }); updateStatus('Loading users...'); return; } if (selected.id === 'unban_user') { adminPendingUserAction = 'unban'; - signaling.send({ type: 'admin_users_list' }); + signaling.send({ type: 'admin_users_list', action: 'unban' }); updateStatus('Loading users...'); } return; @@ -2818,17 +2825,31 @@ function handleAdminUserListModeInput(code: string, key: string): void { return; } if (adminPendingUserAction === 'ban') { + adminUsers.splice(adminUserIndex, 1); + if (adminUsers.length > 0) { + adminUserIndex = Math.min(adminUserIndex, adminUsers.length - 1); + const next = adminUsers[adminUserIndex]; + updateStatus(`${next.username}, ${next.role}, ${next.status}.`); + } else { + state.mode = 'adminMenu'; + updateStatus('No users to ban.'); + } signaling.send({ type: 'admin_user_ban', username: selected.username }); - state.mode = 'normal'; - adminPendingUserAction = null; - updateStatus(`Banning ${selected.username}...`); + adminPendingUserAction = 'ban'; return; } if (adminPendingUserAction === 'unban') { + adminUsers.splice(adminUserIndex, 1); + if (adminUsers.length > 0) { + adminUserIndex = Math.min(adminUserIndex, adminUsers.length - 1); + const next = adminUsers[adminUserIndex]; + updateStatus(`${next.username}, ${next.role}, ${next.status}.`); + } else { + state.mode = 'adminMenu'; + updateStatus('No users to unban.'); + } signaling.send({ type: 'admin_user_unban', username: selected.username }); - state.mode = 'normal'; - adminPendingUserAction = null; - updateStatus(`Unbanning ${selected.username}...`); + adminPendingUserAction = 'unban'; return; } return; @@ -2858,9 +2879,19 @@ function handleAdminUserRoleSelectModeInput(code: string, key: string): void { if (control.type === 'select') { const selectedRole = adminRoles[adminRoleIndex]; signaling.send({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name }); - state.mode = 'normal'; + for (const user of adminUsers) { + if (user.username === adminSelectedUsername) { + user.role = selectedRole.name; + } + } + state.mode = 'adminUserList'; adminPendingUserAction = null; - updateStatus(`Setting ${adminSelectedUsername} to ${selectedRole.name}...`); + const selectedUser = adminUsers.find((user) => user.username === adminSelectedUsername); + if (selectedUser) { + updateStatus(`${selectedUser.username}, ${selectedUser.role}, ${selectedUser.status}.`); + } else { + updateStatus('Select user.'); + } return; } if (control.type === 'cancel') { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 7cab1e4..1264753 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -57,6 +57,7 @@ export const welcomeMessageSchema = z.object({ username: z.string().nullable().optional(), role: z.string().nullable().optional(), permissions: z.array(z.string()).optional(), + adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(), policy: z .object({ usernameMinLength: z.number().int().positive(), @@ -100,6 +101,11 @@ export const welcomeMessageSchema = z.object({ globalProperties: z.record(z.string(), z.unknown()).optional(), }), ), + adminMenu: z + .object({ + actions: z.array(z.object({ id: z.string(), label: z.string() })), + }) + .optional(), }) .optional(), }); @@ -125,6 +131,7 @@ export const authResultSchema = z.object({ username: z.string().optional(), role: z.string().optional(), permissions: z.array(z.string()).optional(), + adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(), nickname: z.string().optional(), authPolicy: z .object({ @@ -267,6 +274,7 @@ export const authPermissionsSchema = z.object({ type: z.literal('auth_permissions'), role: z.string(), permissions: z.array(z.string()), + adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(), }); const adminRoleSummarySchema = z.object({ @@ -345,7 +353,7 @@ export type OutgoingMessage = | { type: 'admin_role_create'; name: string } | { type: 'admin_role_update_permissions'; role: string; permissions: string[] } | { type: 'admin_role_delete'; role: string; replacementRole: string } - | { type: 'admin_users_list' } + | { type: 'admin_users_list'; action?: 'set_role' | 'ban' | 'unban' } | { type: 'admin_user_set_role'; username: string; role: string } | { type: 'admin_user_ban'; username: string } | { type: 'admin_user_unban'; username: string } diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index e936bbd..7b56a7b 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -18,7 +18,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `admin_role_create`: create role. - `admin_role_update_permissions`: replace one role permission set. - `admin_role_delete`: delete role with replacement role reassignment. -- `admin_users_list`: request user list for admin actions. +- `admin_users_list`: request user list for admin actions (`action`: `set_role | ban | unban`). - `admin_user_set_role`: set target user role. - `admin_user_ban` / `admin_user_unban`: disable/enable user account. - `update_position`: client movement intent; server enforces world bounds and movement rate policy. @@ -101,6 +101,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `label`, optional `range`, optional `tooltip`, optional `maxLength`, optional `options`, optional `visibleWhen`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`) + - `adminMenu.actions`: server-authored admin root menu labels/ordering for the authenticated user. - Client item UI requires this metadata from the server; there is no fallback item definition map. - Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics. - `visibleWhen` supports equality checks and string negation via `!` prefix (example: `{"mediaEffect": "!off"}`). diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index d901a1f..a8d12f6 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -18,7 +18,7 @@ - uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - - applies `welcome.uiDefinitions` for item menus/properties/options + - applies `welcome.uiDefinitions` for item menus/properties/options and admin menu labels/order - sends initial `update_position` echo from server-assigned starting tile - sends initial `update_nickname` - creates peer runtimes for known users diff --git a/server/app/auth_service.py b/server/app/auth_service.py index 671bea7..5e2cc54 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -758,7 +758,6 @@ class AuthService: password_hash TEXT NOT NULL, email TEXT UNIQUE, role_id INTEGER, - role TEXT, status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active', created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, @@ -866,26 +865,12 @@ class AuthService: ) def _backfill_user_roles(self) -> None: - """Backfill users.role_id from legacy users.role text, defaulting to user.""" + """Backfill users.role_id defaults for any null role assignment.""" role_id_by_name = self._role_id_by_name() default_user_role_id = role_id_by_name.get("user") if default_user_role_id is None: raise AuthError("Default user role missing.") - - user_cols = {str(row["name"]) for row in self._db_fetchall("PRAGMA table_info(users)")} - has_legacy_role = "role" in user_cols - if has_legacy_role: - rows = self._db_fetchall("SELECT id, role, role_id FROM users") - for row in rows: - if row["role_id"] is not None: - continue - legacy_role = str(row["role"] or "").strip().lower() - mapped_role = legacy_role if legacy_role in role_id_by_name else "user" - self._db_execute( - "UPDATE users SET role_id = ?, updated_at_ms = ? WHERE id = ?", - (role_id_by_name.get(mapped_role, default_user_role_id), self.now_ms(), int(row["id"])), - ) self._db_execute( "UPDATE users SET role_id = ?, updated_at_ms = ? WHERE role_id IS NULL", (default_user_role_id, self.now_ms()), diff --git a/server/app/models.py b/server/app/models.py index 8ff159c..ca87329 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -86,6 +86,7 @@ class AdminRoleDeletePacket(BasePacket): class AdminUsersListPacket(BasePacket): type: Literal["admin_users_list"] + action: Literal["set_role", "ban", "unban"] | None = None class AdminUserSetRolePacket(BasePacket): @@ -226,6 +227,7 @@ class AuthResultPacket(BasePacket): username: str | None = None role: str | None = None permissions: list[str] | None = None + adminMenuActions: list[dict[str, str]] | None = None nickname: str | None = None authPolicy: dict | None = None @@ -234,6 +236,7 @@ class AuthPermissionsPacket(BasePacket): type: Literal["auth_permissions"] role: str permissions: list[str] + adminMenuActions: list[dict[str, str]] | None = None class UserLeftPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index cb2c75c..2f9dc06 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -117,6 +117,12 @@ AUTH_FAILURE_JITTER_MAX_MS = 0.08 RADIO_METADATA_POLL_INTERVAL_S = 10.0 RADIO_METADATA_TIMEOUT_S = 6.0 CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0 +ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = ( + {"id": "manage_roles", "label": "Role management", "permission": "role.manage"}, + {"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"}, + {"id": "ban_user", "label": "Ban user", "permission": "user.ban_unban"}, + {"id": "unban_user", "label": "Unban user", "permission": "user.ban_unban"}, +) class SignalingServer: @@ -237,6 +243,18 @@ class SignalingServer: "passwordMaxLength": self.auth_service.password_max_length, } + def _build_admin_menu_actions_for_client(self, client: ClientConnection | None) -> list[dict[str, str]]: + """Build server-authored admin menu actions allowed for one client.""" + + if client is None: + return [] + client_permissions = client.permissions or set() + return [ + {"id": action["id"], "label": action["label"]} + for action in ADMIN_MENU_ACTION_DEFINITIONS + if action["permission"] in client_permissions + ] + @staticmethod def _sorted_permissions(values: set[str] | tuple[str, ...] | None) -> list[str]: """Return deterministic sorted permission list.""" @@ -278,6 +296,7 @@ class SignalingServer: type="auth_permissions", role=client.role, permissions=permissions, + adminMenuActions=self._build_admin_menu_actions_for_client(client), ), ) @@ -1279,7 +1298,7 @@ class SignalingServer: "movementTickMs": self.movement_tick_ms, "movementMaxStepsPerTick": self.movement_max_steps_per_tick, }, - uiDefinitions=self._build_ui_definitions(), + uiDefinitions=self._build_ui_definitions(client), serverInfo={"instanceId": self.instance_id, "version": self.server_version}, auth={ "authenticated": client.authenticated, @@ -1480,6 +1499,7 @@ class SignalingServer: username=session.user.username, role=session.user.role, permissions=self._sorted_permissions(session.user.permissions), + adminMenuActions=self._build_admin_menu_actions_for_client(client), nickname=client.nickname, authPolicy=self._auth_policy(), ), @@ -1487,7 +1507,7 @@ class SignalingServer: await self._activate_authenticated_client(client) return True - def _build_ui_definitions(self) -> dict: + def _build_ui_definitions(self, client: ClientConnection | None = None) -> dict: """Build server-owned UI definitions for item/menu rendering.""" item_types: list[dict] = [] @@ -1507,6 +1527,7 @@ class SignalingServer: return { "itemTypeOrder": list(ITEM_TYPE_SEQUENCE), "itemTypes": item_types, + "adminMenu": {"actions": self._build_admin_menu_actions_for_client(client)}, } async def _broadcast_wheel_result_after_delay( @@ -1598,6 +1619,10 @@ class SignalingServer: await deny("user_set_role", "Not authorized.") return True users = self.auth_service.list_users_for_admin() + if packet.action == "ban": + users = [entry for entry in users if str(entry.get("status")) == "active"] + elif packet.action == "unban": + users = [entry for entry in users if str(entry.get("status")) == "disabled"] await self._send(client.websocket, AdminUsersListResultPacket(type="admin_users_list", users=users)) return True @@ -1758,8 +1783,8 @@ class SignalingServer: PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc) return - # Compatibility path for local tests injecting pre-authenticated clients - # directly into server.clients without running websocket auth handshake. + # Test-harness compatibility: some unit tests inject clients directly into + # `server.clients` without running auth handshake packets. if not client.authenticated and client.websocket in self.clients: client.authenticated = True client.user_id = client.user_id or client.id