Polish role admin speech flow and permission tooltips

This commit is contained in:
Jage9
2026-02-27 04:12:37 -05:00
parent d114e0d532
commit 84db109e63
6 changed files with 88 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -206,6 +206,11 @@ type AdminUserSummary = {
status: 'active' | 'disabled'; 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. */ /** Builds linearized help-view lines from sectioned help content. */
function buildHelpLines(help: HelpData): string[] { function buildHelpLines(help: HelpData): string[] {
const lines: string[] = []; const lines: string[] = [];
@@ -303,6 +308,7 @@ let adminMenuIndex = 0;
let adminRoles: AdminRoleSummary[] = []; let adminRoles: AdminRoleSummary[] = [];
let adminRoleIndex = 0; let adminRoleIndex = 0;
let adminPermissionKeys: string[] = []; let adminPermissionKeys: string[] = [];
let adminPermissionTooltips: Record<string, string> = {};
let adminRolePermissionIndex = 0; let adminRolePermissionIndex = 0;
let adminRoleDeleteReplacementIndex = 0; let adminRoleDeleteReplacementIndex = 0;
let adminUsers: AdminUserSummary[] = []; let adminUsers: AdminUserSummary[] = [];
@@ -310,7 +316,7 @@ let adminUserIndex = 0;
let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null; let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null;
let adminSelectedRoleName = ''; let adminSelectedRoleName = '';
let adminSelectedUsername = ''; let adminSelectedUsername = '';
let adminPendingRoleChange: { username: string; role: string } | null = null; let adminPendingUserMutation: AdminPendingUserMutation | null = null;
let activeTeleport: let activeTeleport:
| { | {
startX: number; startX: number;
@@ -1584,6 +1590,7 @@ function getAvailableAdminActions(): AdminMenuAction[] {
function handleAdminRolesList(message: Extract<IncomingMessage, { type: 'admin_roles_list' }>): void { function handleAdminRolesList(message: Extract<IncomingMessage, { type: 'admin_roles_list' }>): void {
adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b)); adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b));
adminPermissionTooltips = { ...(message.permissionTooltips ?? {}) };
if (adminPendingUserAction === 'set_role' && adminSelectedUsername) { if (adminPendingUserAction === 'set_role' && adminSelectedUsername) {
state.mode = 'adminUserRoleSelect'; state.mode = 'adminUserRoleSelect';
adminRoleIndex = 0; adminRoleIndex = 0;
@@ -1633,34 +1640,45 @@ function handleAdminActionResult(message: Extract<IncomingMessage, { type: 'admi
if (message.action === 'role_update_permissions') { if (message.action === 'role_update_permissions') {
return; return;
} }
if (message.action === 'user_set_role') { updateStatus(message.message);
if (message.ok && adminPendingRoleChange) { if (!message.ok) {
for (const user of adminUsers) { adminPendingUserMutation = null;
if (user.username === adminPendingRoleChange.username) { audio.sfxUiCancel();
user.role = adminPendingRoleChange.role; return;
} }
if (adminPendingUserMutation) {
if (adminPendingUserMutation.action === 'set_role') {
const target = adminUsers.find((entry) => entry.username === adminPendingUserMutation.username);
if (target) {
target.role = adminPendingUserMutation.role;
} }
if (state.mode === 'adminUserList' && adminUsers.length > 0) { } 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)); 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);
audio.sfxUiCancel();
return;
}
updateStatus(message.message);
if (message.ok) {
audio.sfxUiConfirm();
} else { } else {
audio.sfxUiCancel(); 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. */ /** Builds dependencies shared by connect/disconnect flow helpers. */
function getConnectionFlowDeps(): ConnectFlowDeps { function getConnectionFlowDeps(): ConnectFlowDeps {
@@ -2741,7 +2759,7 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void
} }
const entries = [...adminPermissionKeys, '__delete_role__']; const entries = [...adminPermissionKeys, '__delete_role__'];
const control = handleListControlKey(code, key, entries, adminRolePermissionIndex, (entry) => 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') { if (control.type === 'move') {
adminRolePermissionIndex = control.index; adminRolePermissionIndex = control.index;
@@ -2749,7 +2767,17 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void
if (value === '__delete_role__') { if (value === '__delete_role__') {
updateStatus(`Delete role ${role.name}.`); updateStatus(`Delete role ${role.name}.`);
} else { } 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(); audio.sfxUiBlip();
return; return;
@@ -2782,7 +2810,7 @@ function handleAdminRolePermissionListModeInput(code: string, key: string): void
} }
role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b)); role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b));
signaling.send({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions }); 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(); audio.sfxUiBlip();
return; return;
} }
@@ -2803,7 +2831,7 @@ function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): v
const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name); const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name);
if (control.type === 'move') { if (control.type === 'move') {
adminRoleDeleteReplacementIndex = control.index; adminRoleDeleteReplacementIndex = control.index;
updateStatus(`Replacement role: ${candidates[adminRoleDeleteReplacementIndex].name}.`); updateStatus(candidates[adminRoleDeleteReplacementIndex].name);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
} }
@@ -2850,29 +2878,13 @@ function handleAdminUserListModeInput(code: string, key: string): void {
return; return;
} }
if (adminPendingUserAction === 'ban') { if (adminPendingUserAction === 'ban') {
adminUsers.splice(adminUserIndex, 1); adminPendingUserMutation = { action: 'ban', username: selected.username };
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 }); signaling.send({ type: 'admin_user_ban', username: selected.username });
adminPendingUserAction = 'ban'; adminPendingUserAction = 'ban';
return; return;
} }
if (adminPendingUserAction === 'unban') { if (adminPendingUserAction === 'unban') {
adminUsers.splice(adminUserIndex, 1); adminPendingUserMutation = { action: 'unban', username: selected.username };
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 }); signaling.send({ type: 'admin_user_unban', username: selected.username });
adminPendingUserAction = 'unban'; adminPendingUserAction = 'unban';
return; return;
@@ -2903,7 +2915,7 @@ function handleAdminUserRoleSelectModeInput(code: string, key: string): void {
} }
if (control.type === 'select') { if (control.type === 'select') {
const selectedRole = adminRoles[adminRoleIndex]; 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 }); signaling.send({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name });
state.mode = 'adminUserList'; state.mode = 'adminUserList';
adminPendingUserAction = 'set_role'; adminPendingUserAction = 'set_role';

View File

@@ -289,6 +289,7 @@ export const adminRolesListSchema = z.object({
type: z.literal('admin_roles_list'), type: z.literal('admin_roles_list'),
roles: z.array(adminRoleSummarySchema), roles: z.array(adminRoleSummarySchema),
permissionKeys: z.array(z.string()), permissionKeys: z.array(z.string()),
permissionTooltips: z.record(z.string(), z.string()).optional(),
}); });
export const adminUsersListSchema = z.object({ export const adminUsersListSchema = z.object({

View File

@@ -46,6 +46,25 @@ PERMISSIONS: tuple[str, ...] = (
"server.manage_settings", "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]] = { DEFAULT_ROLE_PERMISSIONS: dict[str, set[str]] = {
"admin": set(PERMISSIONS), "admin": set(PERMISSIONS),
"editor": { "editor": {
@@ -185,6 +204,11 @@ class AuthService:
return list(PERMISSIONS) 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]: def get_user_permissions(self, user_id: str) -> set[str]:
"""Return current permission set for one user id.""" """Return current permission set for one user id."""
@@ -829,7 +853,7 @@ class AuthService:
now_ms = self.now_ms() now_ms = self.now_ms()
for key in PERMISSIONS: for key in PERMISSIONS:
description = f"Permission: {key}" description = PERMISSION_DESCRIPTIONS.get(key, key)
self._db_execute( self._db_execute(
"INSERT OR IGNORE INTO permissions (key, description) VALUES (?, ?)", "INSERT OR IGNORE INTO permissions (key, description) VALUES (?, ?)",
(key, description), (key, description),

View File

@@ -416,6 +416,7 @@ class AdminRolesListResultPacket(BasePacket):
type: Literal["admin_roles_list"] type: Literal["admin_roles_list"]
roles: list[AdminRoleSummary] roles: list[AdminRoleSummary]
permissionKeys: list[str] permissionKeys: list[str]
permissionTooltips: dict[str, str] | None = None
class AdminUserSummary(BaseModel): class AdminUserSummary(BaseModel):

View File

@@ -1607,6 +1607,7 @@ class SignalingServer:
type="admin_roles_list", type="admin_roles_list",
roles=roles, roles=roles,
permissionKeys=self.auth_service.list_all_permissions(), permissionKeys=self.auth_service.list_all_permissions(),
permissionTooltips=self.auth_service.list_all_permission_descriptions(),
), ),
) )
return True return True