From 84db109e635b8bbe14992bf0fbf89c0244ffd5dd Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 27 Feb 2026 04:12:37 -0500 Subject: [PATCH] Polish role admin speech flow and permission tooltips --- client/public/version.js | 2 +- client/src/main.ts | 106 ++++++++++++++++++--------------- client/src/network/protocol.ts | 1 + server/app/auth_service.py | 26 +++++++- server/app/models.py | 1 + server/app/server.py | 1 + 6 files changed, 88 insertions(+), 49 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 519eb58..7b72377 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 R289"; +window.CHGRID_WEB_VERSION = "2026.02.27 R290"; // 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 071abe7..1f2ba26 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -206,6 +206,11 @@ type AdminUserSummary = { status: 'active' | 'disabled'; }; +type AdminPendingUserMutation = + | { action: 'set_role'; username: string; role: string } + | { action: 'ban'; username: string } + | { action: 'unban'; username: string }; + /** Builds linearized help-view lines from sectioned help content. */ function buildHelpLines(help: HelpData): string[] { const lines: string[] = []; @@ -303,6 +308,7 @@ let adminMenuIndex = 0; let adminRoles: AdminRoleSummary[] = []; let adminRoleIndex = 0; let adminPermissionKeys: string[] = []; +let adminPermissionTooltips: Record = {}; let adminRolePermissionIndex = 0; let adminRoleDeleteReplacementIndex = 0; let adminUsers: AdminUserSummary[] = []; @@ -310,7 +316,7 @@ let adminUserIndex = 0; let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null; let adminSelectedRoleName = ''; let adminSelectedUsername = ''; -let adminPendingRoleChange: { username: string; role: string } | null = null; +let adminPendingUserMutation: AdminPendingUserMutation | null = null; let activeTeleport: | { startX: number; @@ -1584,6 +1590,7 @@ function getAvailableAdminActions(): AdminMenuAction[] { function handleAdminRolesList(message: Extract): void { adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b)); + adminPermissionTooltips = { ...(message.permissionTooltips ?? {}) }; if (adminPendingUserAction === 'set_role' && adminSelectedUsername) { state.mode = 'adminUserRoleSelect'; adminRoleIndex = 0; @@ -1633,33 +1640,44 @@ function handleAdminActionResult(message: Extract 0) { - adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); - const selected = adminUsers[adminUserIndex]; - updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); - } - adminPendingRoleChange = null; - audio.sfxUiConfirm(); - return; - } - adminPendingRoleChange = null; - updateStatus(message.message); + updateStatus(message.message); + if (!message.ok) { + adminPendingUserMutation = null; audio.sfxUiCancel(); return; } - updateStatus(message.message); - if (message.ok) { - audio.sfxUiConfirm(); - } else { - audio.sfxUiCancel(); + + if (adminPendingUserMutation) { + if (adminPendingUserMutation.action === 'set_role') { + const target = adminUsers.find((entry) => entry.username === adminPendingUserMutation.username); + if (target) { + target.role = adminPendingUserMutation.role; + } + } else if (adminPendingUserMutation.action === 'ban') { + adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username); + if (state.mode === 'adminUserList' && adminPendingUserAction === 'ban') { + if (adminUsers.length > 0) { + adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); + } else { + state.mode = 'adminMenu'; + adminPendingUserAction = null; + } + } + } else if (adminPendingUserMutation.action === 'unban') { + adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username); + if (state.mode === 'adminUserList' && adminPendingUserAction === 'unban') { + if (adminUsers.length > 0) { + adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); + } else { + state.mode = 'adminMenu'; + adminPendingUserAction = null; + } + } + } + adminPendingUserMutation = null; } + + audio.sfxUiConfirm(); } /** Builds dependencies shared by connect/disconnect flow helpers. */ @@ -2741,7 +2759,7 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void } const entries = [...adminPermissionKeys, '__delete_role__']; const control = handleListControlKey(code, key, entries, adminRolePermissionIndex, (entry) => - entry === '__delete_role__' ? `Delete role ${role.name}` : `${entry} ${role.permissions.includes(entry) ? 'on' : 'off'}`, + entry === '__delete_role__' ? `Delete role ${role.name}` : `${entry}: ${role.permissions.includes(entry) ? 'on' : 'off'}`, ); if (control.type === 'move') { adminRolePermissionIndex = control.index; @@ -2749,7 +2767,17 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void if (value === '__delete_role__') { updateStatus(`Delete role ${role.name}.`); } else { - updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`); + updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`); + } + audio.sfxUiBlip(); + return; + } + if (code === 'Space') { + const value = entries[adminRolePermissionIndex]; + if (value === '__delete_role__') { + updateStatus('Delete the current role and reassign affected users.'); + } else { + updateStatus(adminPermissionTooltips[value] || 'No tooltip available.'); } audio.sfxUiBlip(); return; @@ -2782,7 +2810,7 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void } role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b)); signaling.send({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions }); - updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`); + updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`); audio.sfxUiBlip(); return; } @@ -2803,7 +2831,7 @@ function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): v const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name); if (control.type === 'move') { adminRoleDeleteReplacementIndex = control.index; - updateStatus(`Replacement role: ${candidates[adminRoleDeleteReplacementIndex].name}.`); + updateStatus(candidates[adminRoleDeleteReplacementIndex].name); audio.sfxUiBlip(); return; } @@ -2850,29 +2878,13 @@ 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.'); - } + adminPendingUserMutation = { action: 'ban', username: selected.username }; signaling.send({ type: 'admin_user_ban', username: 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.'); - } + adminPendingUserMutation = { action: 'unban', username: selected.username }; signaling.send({ type: 'admin_user_unban', username: selected.username }); adminPendingUserAction = 'unban'; return; @@ -2903,7 +2915,7 @@ function handleAdminUserRoleSelectModeInput(code: string, key: string): void { } if (control.type === 'select') { const selectedRole = adminRoles[adminRoleIndex]; - adminPendingRoleChange = { username: adminSelectedUsername, role: selectedRole.name }; + adminPendingUserMutation = { action: 'set_role', username: adminSelectedUsername, role: selectedRole.name }; signaling.send({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name }); state.mode = 'adminUserList'; adminPendingUserAction = 'set_role'; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 1264753..f70135e 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -289,6 +289,7 @@ export const adminRolesListSchema = z.object({ type: z.literal('admin_roles_list'), roles: z.array(adminRoleSummarySchema), permissionKeys: z.array(z.string()), + permissionTooltips: z.record(z.string(), z.string()).optional(), }); export const adminUsersListSchema = z.object({ diff --git a/server/app/auth_service.py b/server/app/auth_service.py index 544b49c..fe554f6 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -46,6 +46,25 @@ PERMISSIONS: tuple[str, ...] = ( "server.manage_settings", ) +PERMISSION_DESCRIPTIONS: dict[str, str] = { + "item.create": "Allow creating new items.", + "item.edit.own": "Allow editing items created by this user.", + "item.edit.any": "Allow editing any item.", + "item.delete.own": "Allow deleting items created by this user.", + "item.delete.any": "Allow deleting any item.", + "item.use": "Allow using item primary and secondary actions.", + "item.pickup_drop.own": "Allow picking up and dropping items created by this user.", + "item.pickup_drop.any": "Allow picking up and dropping any item.", + "chat.send": "Allow sending chat messages.", + "voice.send": "Allow transmitting microphone audio.", + "profile.update_nickname": "Allow changing nickname.", + "account.delete.any": "Allow deleting other user accounts.", + "user.ban_unban": "Allow banning and unbanning users.", + "user.change_role": "Allow assigning user roles.", + "role.manage": "Allow creating, editing, and deleting roles.", + "server.manage_settings": "Allow changing server settings.", +} + DEFAULT_ROLE_PERMISSIONS: dict[str, set[str]] = { "admin": set(PERMISSIONS), "editor": { @@ -185,6 +204,11 @@ class AuthService: return list(PERMISSIONS) + def list_all_permission_descriptions(self) -> dict[str, str]: + """Return canonical permission tooltip text keyed by permission id.""" + + return {key: PERMISSION_DESCRIPTIONS.get(key, key) for key in PERMISSIONS} + def get_user_permissions(self, user_id: str) -> set[str]: """Return current permission set for one user id.""" @@ -829,7 +853,7 @@ class AuthService: now_ms = self.now_ms() for key in PERMISSIONS: - description = f"Permission: {key}" + description = PERMISSION_DESCRIPTIONS.get(key, key) self._db_execute( "INSERT OR IGNORE INTO permissions (key, description) VALUES (?, ?)", (key, description), diff --git a/server/app/models.py b/server/app/models.py index ca87329..b1fc89f 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -416,6 +416,7 @@ class AdminRolesListResultPacket(BasePacket): type: Literal["admin_roles_list"] roles: list[AdminRoleSummary] permissionKeys: list[str] + permissionTooltips: dict[str, str] | None = None class AdminUserSummary(BaseModel): diff --git a/server/app/server.py b/server/app/server.py index 2f9dc06..529adc2 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -1607,6 +1607,7 @@ class SignalingServer: type="admin_roles_list", roles=roles, permissionKeys=self.auth_service.list_all_permissions(), + permissionTooltips=self.auth_service.list_all_permission_descriptions(), ), ) return True