import { handleListControlKey } from './listController'; import { getEditSessionAction } from './editSession'; import { handleYesNoMenuInput, YES_NO_OPTIONS } from './yesNoMenu'; import type { IncomingMessage, OutgoingMessage } from '../network/protocol'; import type { GameMode } from '../state/gameState'; export type AdminMenuAction = { id: string; label: string; tooltip?: string; }; export type AdminRoleSummary = { id: number; name: string; isSystem: boolean; userCount: number; permissions: string[]; }; export type AdminUserSummary = { id: string; username: string; role: string; status: 'active' | 'disabled'; }; export type AdminPendingUserMutation = | { action: 'set_role'; username: string; role: string } | { action: 'ban'; username: string } | { action: 'unban'; username: string } | { action: 'delete_account'; username: string }; type AdminPendingUserAction = 'set_role' | 'ban' | 'unban' | 'delete_account' | null; type AdminControllerDeps = { state: { mode: GameMode; nicknameInput: string; cursorPos: number; }; signalingSend: (message: OutgoingMessage) => void; announceMenuEntry: (title: string, firstOption: string) => void; updateStatus: (message: string) => void; sfxUiBlip: () => void; sfxUiCancel: () => void; applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void; setReplaceTextOnNextType: (value: boolean) => void; }; /** * Creates the admin menu/runtime controller so `main.ts` can treat admin flows as one subsystem. */ export function createAdminController(deps: AdminControllerDeps): { setServerAdminMenuActions: (actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined) => void; getAvailableAdminActions: () => AdminMenuAction[]; openAdminMenu: () => void; handleAdminRolesList: (message: Extract) => void; handleAdminUsersList: (message: Extract) => void; handleAdminActionResult: (message: Extract) => void; handleAdminMenuModeInput: (code: string, key: string) => void; handleAdminRoleListModeInput: (code: string, key: string) => void; handleAdminRolePermissionListModeInput: (code: string, key: string) => void; handleAdminRoleDeleteReplacementModeInput: (code: string, key: string) => void; handleAdminUserListModeInput: (code: string, key: string) => void; handleAdminUserRoleSelectModeInput: (code: string, key: string) => void; handleAdminUserDeleteConfirmModeInput: (code: string, key: string) => void; handleAdminRoleNameEditModeInput: (code: string, key: string, ctrlKey: boolean) => void; } { const adminMenuActions: AdminMenuAction[] = []; let serverAdminMenuActions: AdminMenuAction[] = []; let adminMenuIndex = 0; let adminRoles: AdminRoleSummary[] = []; let adminRoleIndex = 0; let adminPermissionKeys: string[] = []; let adminPermissionTooltips: Record = {}; let adminRolePermissionIndex = 0; let adminRoleDeleteReplacementIndex = 0; let adminUsers: AdminUserSummary[] = []; let adminUserIndex = 0; let adminPendingUserAction: AdminPendingUserAction = null; let adminSelectedRoleName = ''; let adminSelectedUsername = ''; let adminPendingUserMutation: AdminPendingUserMutation | null = null; let adminDeleteConfirmIndex = 0; function setServerAdminMenuActions(actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined): void { serverAdminMenuActions = (actions || []) .map((entry) => ({ id: String(entry.id || '').trim(), label: String(entry.label || '').trim(), tooltip: typeof entry.tooltip === 'string' && entry.tooltip.trim().length > 0 ? entry.tooltip.trim() : undefined, })) .filter((entry) => entry.id.length > 0 && entry.label.length > 0); } function getAvailableAdminActions(): AdminMenuAction[] { return [...serverAdminMenuActions]; } function openAdminMenu(): void { const actions = getAvailableAdminActions(); if (actions.length === 0) { deps.updateStatus('No admin actions available.'); deps.sfxUiCancel(); return; } adminMenuActions.splice(0, adminMenuActions.length, ...actions); adminMenuIndex = 0; deps.state.mode = 'adminMenu'; deps.announceMenuEntry('Admin', adminMenuActions[0].label); } 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) { deps.state.mode = 'adminUserRoleSelect'; const selectedUser = adminUsers.find((entry) => entry.username === adminSelectedUsername); const currentRoleIndex = selectedUser ? adminRoles.findIndex((entry) => entry.name === selectedUser.role) : -1; adminRoleIndex = currentRoleIndex >= 0 ? currentRoleIndex : 0; const first = adminRoles[0]; if (first && adminRoles[adminRoleIndex]) { deps.announceMenuEntry('Roles', adminRoles[adminRoleIndex].name); } else { deps.updateStatus('No roles available.'); deps.sfxUiCancel(); deps.state.mode = 'normal'; adminPendingUserAction = null; adminSelectedUsername = ''; } return; } deps.state.mode = 'adminRoleList'; adminRoleIndex = 0; const first = adminRoles[0]; if (first) { deps.announceMenuEntry('Roles', `${first.name}, ${first.userCount}`); } else { deps.updateStatus('No roles found.'); deps.sfxUiCancel(); } } function handleAdminUsersList(message: Extract): void { adminUsers = [...message.users].sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })); if (adminUsers.length === 0) { deps.updateStatus('No users available.'); deps.sfxUiCancel(); deps.state.mode = 'normal'; adminPendingUserAction = null; return; } deps.state.mode = 'adminUserList'; adminUserIndex = 0; const first = adminUsers[0]; deps.announceMenuEntry('Users', `${first.username}, ${first.role}, ${first.status}`); } function handleAdminActionResult(message: Extract): void { if (message.action === 'role_update_permissions') { return; } const suppressStatusMessage = message.ok && message.action === 'user_set_role' && adminPendingUserMutation?.action === 'set_role'; if (!suppressStatusMessage) { deps.updateStatus(message.message); } if (!message.ok) { adminPendingUserMutation = null; deps.sfxUiCancel(); return; } if (adminPendingUserMutation) { if (adminPendingUserMutation.action === 'set_role') { const target = adminUsers.find((entry) => entry.username === adminPendingUserMutation.username); if (target) { target.role = adminPendingUserMutation.role; } if (deps.state.mode === 'adminUserRoleSelect') { deps.state.mode = 'adminUserList'; adminPendingUserAction = 'set_role'; const userIndex = adminUsers.findIndex((entry) => entry.username === adminPendingUserMutation.username); if (userIndex >= 0) { adminUserIndex = userIndex; const selected = adminUsers[adminUserIndex]; deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); } } } else if (adminPendingUserMutation.action === 'ban') { adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username); if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'ban') { if (adminUsers.length > 0) { adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); } else { deps.state.mode = 'adminMenu'; adminPendingUserAction = null; } } } else if (adminPendingUserMutation.action === 'unban') { adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username); if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'unban') { if (adminUsers.length > 0) { adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); } else { deps.state.mode = 'adminMenu'; adminPendingUserAction = null; } } } else if (adminPendingUserMutation.action === 'delete_account') { adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username); if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'delete_account') { if (adminUsers.length > 0) { adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1)); } else { deps.state.mode = 'adminMenu'; adminPendingUserAction = null; } } } adminPendingUserMutation = null; } if (message.action === 'role_create' && message.role) { adminRoles.push({ id: message.role.id, name: message.role.name, isSystem: message.role.isSystem, userCount: message.role.userCount, permissions: [...message.role.permissions], }); adminRoles.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); } if (message.action === 'role_delete' && message.roleName) { adminRoles = adminRoles.filter((entry) => entry.name !== message.roleName); if (deps.state.mode === 'adminRoleList') { adminRoleIndex = Math.max(0, Math.min(adminRoleIndex, Math.max(0, adminRoles.length))); } } } function handleAdminMenuModeInput(code: string, key: string): void { if (adminMenuActions.length === 0) { deps.state.mode = 'normal'; return; } const control = handleListControlKey(code, key, adminMenuActions, adminMenuIndex, (entry) => entry.label); if (control.type === 'move') { adminMenuIndex = control.index; deps.updateStatus(adminMenuActions[adminMenuIndex].label); deps.sfxUiBlip(); return; } if (code === 'Space') { deps.updateStatus(adminMenuActions[adminMenuIndex]?.tooltip ?? 'No tooltip available.'); deps.sfxUiBlip(); return; } if (control.type === 'select') { const selected = adminMenuActions[adminMenuIndex]; if (!selected) return; if (selected.id === 'manage_roles') { deps.signalingSend({ type: 'admin_roles_list' }); deps.updateStatus('Loading roles...'); return; } if (selected.id === 'change_user_role') { adminPendingUserAction = 'set_role'; deps.signalingSend({ type: 'admin_users_list', action: 'set_role' }); deps.updateStatus('Loading users...'); return; } if (selected.id === 'ban_user') { adminPendingUserAction = 'ban'; deps.signalingSend({ type: 'admin_users_list', action: 'ban' }); deps.updateStatus('Loading users...'); return; } if (selected.id === 'unban_user') { adminPendingUserAction = 'unban'; deps.signalingSend({ type: 'admin_users_list', action: 'unban' }); deps.updateStatus('Loading users...'); return; } if (selected.id === 'delete_account') { adminPendingUserAction = 'delete_account'; deps.signalingSend({ type: 'admin_users_list', action: 'delete_account' }); deps.updateStatus('Loading users...'); } return; } if (control.type === 'cancel') { deps.state.mode = 'normal'; deps.updateStatus('Cancelled.'); deps.sfxUiCancel(); } } function handleAdminRoleListModeInput(code: string, key: string): void { const entries: Array<{ label: string; role?: AdminRoleSummary }> = [ ...adminRoles.map((role) => ({ label: `${role.name}, ${role.userCount}`, role })), { label: 'Add role' }, ]; const control = handleListControlKey(code, key, entries, adminRoleIndex, (entry) => entry.label); if (control.type === 'move') { adminRoleIndex = control.index; deps.updateStatus(entries[adminRoleIndex]?.label || ''); deps.sfxUiBlip(); return; } if (control.type === 'select') { const selected = entries[adminRoleIndex]; if (!selected) return; if (!selected.role) { deps.state.mode = 'adminRoleNameEdit'; deps.state.nicknameInput = ''; deps.state.cursorPos = 0; deps.setReplaceTextOnNextType(false); deps.updateStatus('New role name.'); deps.sfxUiBlip(); return; } adminSelectedRoleName = selected.role.name; adminRolePermissionIndex = 0; deps.state.mode = 'adminRolePermissionList'; deps.updateStatus(`${adminSelectedRoleName} permissions.`); deps.sfxUiBlip(); return; } if (control.type === 'cancel') { deps.state.mode = 'normal'; deps.updateStatus('Cancelled.'); deps.sfxUiCancel(); } } function handleAdminRolePermissionListModeInput(code: string, key: string): void { const role = adminRoles.find((entry) => entry.name === adminSelectedRoleName); if (!role) { deps.state.mode = 'adminRoleList'; return; } 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'}`, ); if (control.type === 'move') { adminRolePermissionIndex = control.index; const value = entries[adminRolePermissionIndex]; if (value === '__delete_role__') { deps.updateStatus(`Delete role ${role.name}.`); } else { deps.updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`); } deps.sfxUiBlip(); return; } if (code === 'Space') { const value = entries[adminRolePermissionIndex]; if (value === '__delete_role__') { deps.updateStatus('Delete the current role and reassign affected users.'); } else { deps.updateStatus(adminPermissionTooltips[value] || 'No tooltip available.'); } deps.sfxUiBlip(); return; } if (control.type === 'select') { const value = entries[adminRolePermissionIndex]; if (value === '__delete_role__') { if (role.name === 'admin' || role.name === 'user') { deps.updateStatus('Admin and user roles cannot be deleted.'); deps.sfxUiCancel(); return; } const replacementCandidates = adminRoles.filter((entry) => entry.name !== role.name); if (replacementCandidates.length === 0) { deps.updateStatus('No replacement role available.'); deps.sfxUiCancel(); return; } adminRoleDeleteReplacementIndex = 0; deps.state.mode = 'adminRoleDeleteReplacement'; deps.updateStatus(`Replacement role: ${replacementCandidates[0].name}.`); deps.sfxUiBlip(); return; } const nextPermissions = new Set(role.permissions); if (nextPermissions.has(value)) { nextPermissions.delete(value); } else { nextPermissions.add(value); } role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b)); deps.signalingSend({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions }); deps.updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`); deps.sfxUiBlip(); return; } if (control.type === 'cancel') { deps.state.mode = 'adminRoleList'; deps.updateStatus('Roles.'); deps.sfxUiCancel(); } } function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): void { const candidates = adminRoles.filter((entry) => entry.name !== adminSelectedRoleName); if (candidates.length === 0) { deps.state.mode = 'adminRolePermissionList'; return; } const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name); if (control.type === 'move') { adminRoleDeleteReplacementIndex = control.index; deps.updateStatus(candidates[adminRoleDeleteReplacementIndex].name); deps.sfxUiBlip(); return; } if (control.type === 'select') { const replacement = candidates[adminRoleDeleteReplacementIndex]; deps.signalingSend({ type: 'admin_role_delete', role: adminSelectedRoleName, replacementRole: replacement.name, }); deps.state.mode = 'adminRoleList'; deps.updateStatus(`Deleting ${adminSelectedRoleName}...`); return; } if (control.type === 'cancel') { deps.state.mode = 'adminRolePermissionList'; deps.updateStatus(`${adminSelectedRoleName} permissions.`); deps.sfxUiCancel(); } } function handleAdminUserListModeInput(code: string, key: string): void { if (adminUsers.length === 0) { deps.state.mode = 'normal'; adminPendingUserAction = null; return; } const control = handleListControlKey(code, key, adminUsers, adminUserIndex, (entry) => `${entry.username}, ${entry.role}, ${entry.status}`); if (control.type === 'move') { adminUserIndex = control.index; const selected = adminUsers[adminUserIndex]; deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); deps.sfxUiBlip(); return; } if (control.type === 'select') { const selected = adminUsers[adminUserIndex]; if (!selected) return; adminSelectedUsername = selected.username; if (adminPendingUserAction === 'set_role') { deps.signalingSend({ type: 'admin_roles_list' }); deps.updateStatus(`Select new role for ${selected.username}.`); return; } if (adminPendingUserAction === 'ban') { adminPendingUserMutation = { action: 'ban', username: selected.username }; deps.signalingSend({ type: 'admin_user_ban', username: selected.username }); adminPendingUserAction = 'ban'; return; } if (adminPendingUserAction === 'unban') { adminPendingUserMutation = { action: 'unban', username: selected.username }; deps.signalingSend({ type: 'admin_user_unban', username: selected.username }); adminPendingUserAction = 'unban'; return; } if (adminPendingUserAction === 'delete_account') { adminDeleteConfirmIndex = 0; deps.state.mode = 'adminUserDeleteConfirm'; deps.announceMenuEntry(`Delete account ${selected.username}?`, YES_NO_OPTIONS[adminDeleteConfirmIndex].label); return; } return; } if (control.type === 'cancel') { deps.state.mode = 'adminMenu'; adminPendingUserAction = null; deps.updateStatus('Admin menu.'); deps.sfxUiCancel(); } } function handleAdminUserRoleSelectModeInput(code: string, key: string): void { if (adminRoles.length === 0) { deps.state.mode = 'normal'; adminPendingUserAction = null; return; } const control = handleListControlKey(code, key, adminRoles, adminRoleIndex, (entry) => entry.name); if (control.type === 'move') { adminRoleIndex = control.index; deps.updateStatus(adminRoles[adminRoleIndex].name); deps.sfxUiBlip(); return; } if (control.type === 'select') { const selectedRole = adminRoles[adminRoleIndex]; adminPendingUserMutation = { action: 'set_role', username: adminSelectedUsername, role: selectedRole.name }; deps.signalingSend({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name }); return; } if (control.type === 'cancel') { deps.state.mode = 'adminUserList'; deps.updateStatus('Select user.'); deps.sfxUiCancel(); } } function handleAdminUserDeleteConfirmModeInput(code: string, key: string): void { if (!adminSelectedUsername || adminPendingUserAction !== 'delete_account') { deps.state.mode = 'adminUserList'; return; } const control = handleYesNoMenuInput(code, key, adminDeleteConfirmIndex); if (control.type === 'move') { adminDeleteConfirmIndex = control.index; deps.updateStatus(YES_NO_OPTIONS[adminDeleteConfirmIndex].label); deps.sfxUiBlip(); return; } if (control.type === 'cancel') { deps.state.mode = 'adminUserList'; const selected = adminUsers[adminUserIndex]; if (selected) { deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); } else { deps.updateStatus('Select user.'); } deps.sfxUiCancel(); return; } if (control.type === 'select') { const choice = YES_NO_OPTIONS[adminDeleteConfirmIndex]; if (choice.id === 'no') { deps.state.mode = 'adminUserList'; const selected = adminUsers[adminUserIndex]; if (selected) { deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); } else { deps.updateStatus('Select user.'); } deps.sfxUiCancel(); return; } deps.state.mode = 'adminUserList'; deps.updateStatus(`Deleting account ${adminSelectedUsername}...`); adminPendingUserMutation = { action: 'delete_account', username: adminSelectedUsername }; deps.signalingSend({ type: 'admin_user_delete', username: adminSelectedUsername }); } } function handleAdminRoleNameEditModeInput(code: string, key: string, ctrlKey: boolean): void { const editAction = getEditSessionAction(code); if (editAction === 'submit') { const name = deps.state.nicknameInput.trim().toLowerCase(); if (!name) { deps.updateStatus('Role name required.'); deps.sfxUiCancel(); return; } deps.signalingSend({ type: 'admin_role_create', name }); deps.state.mode = 'adminRoleList'; deps.state.nicknameInput = ''; deps.state.cursorPos = 0; deps.setReplaceTextOnNextType(false); deps.updateStatus(`Creating role ${name}...`); return; } if (editAction === 'cancel') { deps.state.mode = 'adminRoleList'; deps.state.nicknameInput = ''; deps.state.cursorPos = 0; deps.setReplaceTextOnNextType(false); deps.updateStatus('Cancelled.'); deps.sfxUiCancel(); return; } deps.applyTextInputEdit(code, key, 32, ctrlKey, true); } return { setServerAdminMenuActions, getAvailableAdminActions, openAdminMenu, handleAdminRolesList, handleAdminUsersList, handleAdminActionResult, handleAdminMenuModeInput, handleAdminRoleListModeInput, handleAdminRolePermissionListModeInput, handleAdminRoleDeleteReplacementModeInput, handleAdminUserListModeInput, handleAdminUserRoleSelectModeInput, handleAdminUserDeleteConfirmModeInput, handleAdminRoleNameEditModeInput, }; }