Extract main.ts controllers
This commit is contained in:
606
client/src/input/adminController.ts
Normal file
606
client/src/input/adminController.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
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<IncomingMessage, { type: 'admin_roles_list' }>) => void;
|
||||||
|
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
|
||||||
|
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => 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<string, string> = {};
|
||||||
|
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<IncomingMessage, { type: 'admin_roles_list' }>): 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<IncomingMessage, { type: 'admin_users_list' }>): 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<IncomingMessage, { type: 'admin_action_result' }>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
182
client/src/input/keyboardController.ts
Normal file
182
client/src/input/keyboardController.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import type { GameMode } from '../state/gameState';
|
||||||
|
import type { ModeInput } from './commandTypes';
|
||||||
|
|
||||||
|
type KeyboardControllerDeps = {
|
||||||
|
dom: {
|
||||||
|
settingsModal: HTMLDivElement;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
running: boolean;
|
||||||
|
mode: GameMode;
|
||||||
|
keysPressed: Record<string, boolean>;
|
||||||
|
nicknameInput: string;
|
||||||
|
cursorPos: number;
|
||||||
|
};
|
||||||
|
isTextEditingMode: (mode: GameMode) => boolean;
|
||||||
|
closeSettings: () => void;
|
||||||
|
hasBlockedArrowTeleport: (code: string) => boolean;
|
||||||
|
handleModeInput: (input: ModeInput) => void;
|
||||||
|
canOpenCommandPaletteInMode: (mode: GameMode) => boolean;
|
||||||
|
openCommandPalette: () => void;
|
||||||
|
shouldForwardModeKeyUp: (mode: GameMode) => boolean;
|
||||||
|
onModeKeyUp: (input: Pick<ModeInput, 'code' | 'shiftKey'>) => void;
|
||||||
|
pasteIntoActiveTextInput: (text: string) => boolean;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
setReplaceTextOnNextType: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires global keyboard/paste input handlers and leaves mode-specific behavior to injected callbacks.
|
||||||
|
*/
|
||||||
|
export function setupKeyboardInputHandlers(deps: KeyboardControllerDeps): void {
|
||||||
|
let internalClipboardText = '';
|
||||||
|
|
||||||
|
function isTypingKey(code: string): boolean {
|
||||||
|
return code.startsWith('Key') || code === 'Space';
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFromKey(key: string, location: number): string | null {
|
||||||
|
if (key === 'Escape' || key === 'Esc') return 'Escape';
|
||||||
|
if (key === 'Enter' || key === 'Return') return 'Enter';
|
||||||
|
if (key === 'Backspace') return 'Backspace';
|
||||||
|
if (key === 'Delete' || key === 'Del') return 'Delete';
|
||||||
|
if (key === 'ArrowUp' || key === 'Up') return 'ArrowUp';
|
||||||
|
if (key === 'ArrowDown' || key === 'Down') return 'ArrowDown';
|
||||||
|
if (key === 'ArrowLeft' || key === 'Left') return 'ArrowLeft';
|
||||||
|
if (key === 'ArrowRight' || key === 'Right') return 'ArrowRight';
|
||||||
|
if (key === 'Home') return 'Home';
|
||||||
|
if (key === 'End') return 'End';
|
||||||
|
if (key === 'PageUp') return 'PageUp';
|
||||||
|
if (key === 'PageDown') return 'PageDown';
|
||||||
|
if (key === 'Tab') return 'Tab';
|
||||||
|
if (key === ' ' || key === 'Spacebar') return 'Space';
|
||||||
|
if (key.length === 1) {
|
||||||
|
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
|
||||||
|
if (/^[0-9]$/.test(key)) return `Digit${key}`;
|
||||||
|
if (key === '!') return 'Digit1';
|
||||||
|
if (key === '@') return 'Digit2';
|
||||||
|
if (key === '#') return 'Digit3';
|
||||||
|
if (key === '$') return 'Digit4';
|
||||||
|
if (key === '%') return 'Digit5';
|
||||||
|
if (key === '^') return 'Digit6';
|
||||||
|
if (key === '&') return 'Digit7';
|
||||||
|
if (key === '*') return 'Digit8';
|
||||||
|
if (key === '(') return 'Digit9';
|
||||||
|
if (key === ')') return 'Digit0';
|
||||||
|
if (key === '+' && location === 3) return 'NumpadAdd';
|
||||||
|
if (key === '-' && location === 3) return 'NumpadSubtract';
|
||||||
|
if (key === '+' || key === '=') return 'Equal';
|
||||||
|
if (key === '-' || key === '_') return 'Minus';
|
||||||
|
if (key === '/' || key === '?') return 'Slash';
|
||||||
|
if (key === ',' || key === '<') return 'Comma';
|
||||||
|
if (key === '.' || key === '>') return 'Period';
|
||||||
|
if (key === ';' || key === ':') return 'Semicolon';
|
||||||
|
if (key === "'" || key === '"') return 'Quote';
|
||||||
|
if (key === '[' || key === '{') return 'BracketLeft';
|
||||||
|
if (key === ']' || key === '}') return 'BracketRight';
|
||||||
|
if (key === '\\' || key === '|') return 'Backslash';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInputCode(event: KeyboardEvent): string {
|
||||||
|
if (event.code && event.code !== 'Unidentified') {
|
||||||
|
return event.code;
|
||||||
|
}
|
||||||
|
return codeFromKey(event.key, event.location) ?? event.code ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const code = normalizeInputCode(event);
|
||||||
|
if (!code) return;
|
||||||
|
const hasShortcutModifier = event.ctrlKey || event.metaKey;
|
||||||
|
const input: ModeInput = {
|
||||||
|
code,
|
||||||
|
key: event.key,
|
||||||
|
ctrlKey: hasShortcutModifier,
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!deps.dom.settingsModal.classList.contains('hidden') && code === 'Escape') {
|
||||||
|
deps.closeSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.state.running) return;
|
||||||
|
if (document.activeElement !== deps.dom.canvas) return;
|
||||||
|
if (event.altKey) return;
|
||||||
|
if (hasShortcutModifier && !deps.isTextEditingMode(deps.state.mode)) return;
|
||||||
|
if (deps.hasBlockedArrowTeleport(code)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNativePasteShortcut = hasShortcutModifier && deps.isTextEditingMode(deps.state.mode) && code === 'KeyV';
|
||||||
|
if ((deps.state.mode !== 'normal' || !code.startsWith('Arrow')) && !isNativePasteShortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasShortcutModifier && deps.isTextEditingMode(deps.state.mode)) {
|
||||||
|
if (code === 'KeyV') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 'KeyC') {
|
||||||
|
const text = deps.state.nicknameInput;
|
||||||
|
internalClipboardText = text;
|
||||||
|
void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
|
deps.updateStatus('copied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 'KeyX') {
|
||||||
|
const text = deps.state.nicknameInput;
|
||||||
|
internalClipboardText = text;
|
||||||
|
void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
|
deps.state.nicknameInput = '';
|
||||||
|
deps.state.cursorPos = 0;
|
||||||
|
deps.setReplaceTextOnNextType(false);
|
||||||
|
deps.updateStatus('cut');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTypingKey(code) && deps.state.keysPressed[code]) return;
|
||||||
|
|
||||||
|
const opensCommandPalette =
|
||||||
|
deps.canOpenCommandPaletteInMode(deps.state.mode) &&
|
||||||
|
((code === 'KeyK' && event.shiftKey) || code === 'ContextMenu' || (code === 'F10' && event.shiftKey));
|
||||||
|
if (opensCommandPalette) {
|
||||||
|
deps.openCommandPalette();
|
||||||
|
deps.state.keysPressed[code] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.handleModeInput(input);
|
||||||
|
deps.state.keysPressed[code] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keyup', (event) => {
|
||||||
|
const code = normalizeInputCode(event);
|
||||||
|
if (code && deps.shouldForwardModeKeyUp(deps.state.mode)) {
|
||||||
|
deps.onModeKeyUp({
|
||||||
|
code,
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
deps.state.keysPressed[code] = false;
|
||||||
|
}
|
||||||
|
if (event.code && event.code !== code) {
|
||||||
|
deps.state.keysPressed[event.code] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('paste', (event) => {
|
||||||
|
if (document.activeElement !== deps.dom.canvas) return;
|
||||||
|
if (!deps.state.running) return;
|
||||||
|
const pasted = event.clipboardData?.getData('text') ?? internalClipboardText;
|
||||||
|
if (!deps.pasteIntoActiveTextInput(pasted)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
deps.updateStatus('pasted');
|
||||||
|
});
|
||||||
|
}
|
||||||
456
client/src/items/itemInteractionController.ts
Normal file
456
client/src/items/itemInteractionController.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { handleListControlKey } from '../input/listController';
|
||||||
|
import { handleYesNoMenuInput, YES_NO_OPTIONS } from '../input/yesNoMenu';
|
||||||
|
import type { IncomingMessage, OutgoingMessage } from '../network/protocol';
|
||||||
|
import type { GameMode, SelectionContext, WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
type ItemManagementAction = 'delete' | 'transfer';
|
||||||
|
|
||||||
|
export type ItemManagementOption = {
|
||||||
|
action: ItemManagementAction;
|
||||||
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemManagementConfirmContext = {
|
||||||
|
itemId: string;
|
||||||
|
action: ItemManagementAction;
|
||||||
|
prompt: string;
|
||||||
|
targetUserId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemTransferTarget = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ItemControllerDeps = {
|
||||||
|
state: {
|
||||||
|
mode: GameMode;
|
||||||
|
selectionContext: SelectionContext;
|
||||||
|
selectedItemIds: string[];
|
||||||
|
selectedItemIndex: number;
|
||||||
|
selectedItemId: string | null;
|
||||||
|
itemPropertyKeys: string[];
|
||||||
|
itemPropertyIndex: number;
|
||||||
|
editingPropertyKey: string | null;
|
||||||
|
itemPropertyOptionValues: string[];
|
||||||
|
itemPropertyOptionIndex: number;
|
||||||
|
items: Map<string, WorldItem>;
|
||||||
|
peers: Map<string, unknown>;
|
||||||
|
player: { id: string | null };
|
||||||
|
};
|
||||||
|
signalingSend: (message: OutgoingMessage) => void;
|
||||||
|
announceMenuEntry: (title: string, firstOption: string) => void;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
sfxUiBlip: () => void;
|
||||||
|
sfxUiCancel: () => void;
|
||||||
|
hasPermission: (key: string) => boolean;
|
||||||
|
getAuthUserId: () => string;
|
||||||
|
getItemManagementActionMetadata: (
|
||||||
|
action: ItemManagementAction,
|
||||||
|
) =>
|
||||||
|
| {
|
||||||
|
label?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
anyPermission?: string;
|
||||||
|
ownPermission?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
itemLabel: (item: WorldItem) => string;
|
||||||
|
getEditableItemPropertyKeys: (item: WorldItem) => string[];
|
||||||
|
getInspectItemPropertyKeys: (item: WorldItem) => string[];
|
||||||
|
getItemPropertyValue: (item: WorldItem, key: string) => string;
|
||||||
|
itemPropertyLabel: (key: string) => string;
|
||||||
|
useItem: (item: WorldItem) => void;
|
||||||
|
secondaryUseItem: (item: WorldItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the shared item selection/management/property flow controller.
|
||||||
|
*/
|
||||||
|
export function createItemInteractionController(deps: ItemControllerDeps): {
|
||||||
|
reset: () => void;
|
||||||
|
beginItemSelection: (context: Exclude<SelectionContext, 'drop' | null>, items: WorldItem[]) => void;
|
||||||
|
beginItemManagement: (item: WorldItem) => void;
|
||||||
|
beginItemProperties: (item: WorldItem, showAll?: boolean) => void;
|
||||||
|
recomputeActiveItemPropertyKeys: (itemId: string) => void;
|
||||||
|
getManagementOptions: (item: WorldItem) => ItemManagementOption[];
|
||||||
|
handleItemTransferTargets: (message: Extract<IncomingMessage, { type: 'item_transfer_targets' }>) => void;
|
||||||
|
handleSelectItemModeInput: (code: string, key: string) => void;
|
||||||
|
handleItemManageOptionsModeInput: (code: string, key: string) => void;
|
||||||
|
handleItemManageTransferUserModeInput: (code: string, key: string) => void;
|
||||||
|
handleConfirmYesNoModeInput: (code: string, key: string) => void;
|
||||||
|
} {
|
||||||
|
let itemManagementSelectedItemId: string | null = null;
|
||||||
|
let itemManagementOptions: ItemManagementOption[] = [];
|
||||||
|
let itemManagementOptionIndex = 0;
|
||||||
|
let itemManagementTargetUserIndex = 0;
|
||||||
|
let itemManagementTransferTargets: ItemTransferTarget[] = [];
|
||||||
|
let itemManagementConfirmIndex = 0;
|
||||||
|
let itemManagementConfirmContext: ItemManagementConfirmContext | null = null;
|
||||||
|
let itemPropertiesShowAll = false;
|
||||||
|
|
||||||
|
function canManageDeleteItem(item: WorldItem): boolean {
|
||||||
|
const metadata = deps.getItemManagementActionMetadata('delete');
|
||||||
|
if (metadata?.anyPermission && deps.hasPermission(metadata.anyPermission)) return true;
|
||||||
|
return Boolean(metadata?.ownPermission) && deps.hasPermission(metadata.ownPermission) && deps.getAuthUserId().length > 0 && item.createdBy === deps.getAuthUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageTransferItem(item: WorldItem): boolean {
|
||||||
|
const metadata = deps.getItemManagementActionMetadata('transfer');
|
||||||
|
if (metadata?.anyPermission && deps.hasPermission(metadata.anyPermission)) return true;
|
||||||
|
return Boolean(metadata?.ownPermission) && deps.hasPermission(metadata.ownPermission) && deps.getAuthUserId().length > 0 && item.createdBy === deps.getAuthUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManagementOptions(item: WorldItem): ItemManagementOption[] {
|
||||||
|
const options: ItemManagementOption[] = [];
|
||||||
|
const transferMetadata = deps.getItemManagementActionMetadata('transfer');
|
||||||
|
if (canManageTransferItem(item) && (deps.state.player.id !== null || deps.state.peers.size > 0)) {
|
||||||
|
options.push({
|
||||||
|
action: 'transfer',
|
||||||
|
label: transferMetadata?.label ?? 'Transfer item',
|
||||||
|
tooltip: transferMetadata?.tooltip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deleteMetadata = deps.getItemManagementActionMetadata('delete');
|
||||||
|
if (canManageDeleteItem(item)) {
|
||||||
|
options.push({
|
||||||
|
action: 'delete',
|
||||||
|
label: deleteMetadata?.label ?? 'Delete item',
|
||||||
|
tooltip: deleteMetadata?.tooltip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferTargetLabel(target: ItemTransferTarget): string {
|
||||||
|
return target.online ? `${target.username}, online` : `${target.username}, offline`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetItemManagementState(): void {
|
||||||
|
itemManagementSelectedItemId = null;
|
||||||
|
itemManagementOptions = [];
|
||||||
|
itemManagementOptionIndex = 0;
|
||||||
|
itemManagementTransferTargets = [];
|
||||||
|
itemManagementTargetUserIndex = 0;
|
||||||
|
itemManagementConfirmIndex = 0;
|
||||||
|
itemManagementConfirmContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openItemManagementConfirm(context: ItemManagementConfirmContext): void {
|
||||||
|
itemManagementConfirmContext = context;
|
||||||
|
itemManagementConfirmIndex = 0;
|
||||||
|
deps.state.mode = 'confirmYesNo';
|
||||||
|
deps.announceMenuEntry(context.prompt, YES_NO_OPTIONS[itemManagementConfirmIndex].label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginItemSelection(context: Exclude<SelectionContext, 'drop' | null>, items: WorldItem[]): void {
|
||||||
|
if (items.length === 0) {
|
||||||
|
deps.updateStatus('No items available.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.state.mode = 'selectItem';
|
||||||
|
deps.state.selectionContext = context;
|
||||||
|
deps.state.selectedItemIds = items.map((item) => item.id);
|
||||||
|
deps.state.selectedItemIndex = 0;
|
||||||
|
deps.announceMenuEntry('Select item', deps.itemLabel(items[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginItemManagement(item: WorldItem): void {
|
||||||
|
const options = getManagementOptions(item);
|
||||||
|
if (options.length === 0) {
|
||||||
|
deps.updateStatus('No item management actions available.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemManagementSelectedItemId = item.id;
|
||||||
|
itemManagementOptions = options;
|
||||||
|
itemManagementOptionIndex = 0;
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
deps.announceMenuEntry('Items', itemManagementOptions[0].label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginItemProperties(item: WorldItem, showAll = false): void {
|
||||||
|
itemPropertiesShowAll = showAll;
|
||||||
|
deps.state.selectedItemId = item.id;
|
||||||
|
deps.state.mode = 'itemProperties';
|
||||||
|
deps.state.editingPropertyKey = null;
|
||||||
|
deps.state.itemPropertyOptionValues = [];
|
||||||
|
deps.state.itemPropertyOptionIndex = 0;
|
||||||
|
deps.state.itemPropertyKeys = showAll ? deps.getInspectItemPropertyKeys(item) : deps.getEditableItemPropertyKeys(item);
|
||||||
|
deps.state.itemPropertyIndex = 0;
|
||||||
|
if (deps.state.itemPropertyKeys.length === 0) {
|
||||||
|
deps.updateStatus('No properties available.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.selectedItemId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = deps.state.itemPropertyKeys[0];
|
||||||
|
const value = deps.getItemPropertyValue(item, key);
|
||||||
|
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${value}`);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeActiveItemPropertyKeys(itemId: string): void {
|
||||||
|
if (deps.state.mode !== 'itemProperties' || deps.state.selectedItemId !== itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = deps.state.items.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousKey = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex] ?? null;
|
||||||
|
const nextKeys = itemPropertiesShowAll ? deps.getInspectItemPropertyKeys(item) : deps.getEditableItemPropertyKeys(item);
|
||||||
|
deps.state.itemPropertyKeys = nextKeys;
|
||||||
|
if (nextKeys.length === 0) {
|
||||||
|
deps.state.itemPropertyIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousKey && nextKeys.includes(previousKey)) {
|
||||||
|
deps.state.itemPropertyIndex = nextKeys.indexOf(previousKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.state.itemPropertyIndex = Math.max(0, Math.min(deps.state.itemPropertyIndex, nextKeys.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemTransferTargets(message: Extract<IncomingMessage, { type: 'item_transfer_targets' }>): void {
|
||||||
|
if (itemManagementSelectedItemId !== message.itemId) return;
|
||||||
|
itemManagementTransferTargets = [...message.targets].sort((a, b) =>
|
||||||
|
a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }),
|
||||||
|
);
|
||||||
|
if (itemManagementTransferTargets.length === 0) {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
deps.updateStatus('No users available to transfer to.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemManagementTargetUserIndex = 0;
|
||||||
|
deps.state.mode = 'itemManageTransferUser';
|
||||||
|
deps.announceMenuEntry('Users', transferTargetLabel(itemManagementTransferTargets[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectItemModeInput(code: string, key: string): void {
|
||||||
|
if (deps.state.selectedItemIds.length === 0) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.selectionContext = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const control = handleListControlKey(code, key, deps.state.selectedItemIds, deps.state.selectedItemIndex, (itemId) => {
|
||||||
|
const item = deps.state.items.get(itemId);
|
||||||
|
return item ? deps.itemLabel(item) : '';
|
||||||
|
});
|
||||||
|
if (control.type === 'move') {
|
||||||
|
deps.state.selectedItemIndex = control.index;
|
||||||
|
const current = deps.state.items.get(deps.state.selectedItemIds[deps.state.selectedItemIndex]);
|
||||||
|
if (current) {
|
||||||
|
deps.updateStatus(deps.itemLabel(current));
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const selected = deps.state.items.get(deps.state.selectedItemIds[deps.state.selectedItemIndex]);
|
||||||
|
if (!selected) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.selectionContext = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = deps.state.selectionContext;
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.selectionContext = null;
|
||||||
|
if (context === 'pickup') {
|
||||||
|
deps.signalingSend({ type: 'item_pickup', itemId: selected.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'delete') {
|
||||||
|
deps.signalingSend({ type: 'item_delete', itemId: selected.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'edit') {
|
||||||
|
beginItemProperties(selected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'use') {
|
||||||
|
deps.useItem(selected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'secondaryUse') {
|
||||||
|
deps.secondaryUseItem(selected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'inspect') {
|
||||||
|
beginItemProperties(selected, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context === 'manage') {
|
||||||
|
beginItemManagement(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
deps.state.selectionContext = null;
|
||||||
|
deps.updateStatus('Cancelled.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemManageOptionsModeInput(code: string, key: string): void {
|
||||||
|
if (!itemManagementSelectedItemId) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
resetItemManagementState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = deps.state.items.get(itemManagementSelectedItemId);
|
||||||
|
if (!item) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
resetItemManagementState();
|
||||||
|
deps.updateStatus('Item no longer exists.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemManagementOptions = getManagementOptions(item);
|
||||||
|
if (itemManagementOptions.length === 0) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
resetItemManagementState();
|
||||||
|
deps.updateStatus('No item management actions available.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemManagementOptionIndex = Math.max(0, Math.min(itemManagementOptionIndex, itemManagementOptions.length - 1));
|
||||||
|
const control = handleListControlKey(code, key, itemManagementOptions, itemManagementOptionIndex, (entry) => entry.label);
|
||||||
|
if (control.type === 'move') {
|
||||||
|
itemManagementOptionIndex = control.index;
|
||||||
|
deps.updateStatus(itemManagementOptions[itemManagementOptionIndex].label);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 'Space') {
|
||||||
|
deps.updateStatus(itemManagementOptions[itemManagementOptionIndex]?.tooltip ?? 'No tooltip available.');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const option = itemManagementOptions[itemManagementOptionIndex];
|
||||||
|
if (option.action === 'delete') {
|
||||||
|
openItemManagementConfirm({
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'delete',
|
||||||
|
prompt: `Delete ${deps.itemLabel(item)}?`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemManagementTransferTargets = [];
|
||||||
|
itemManagementTargetUserIndex = 0;
|
||||||
|
deps.signalingSend({ type: 'item_transfer_targets', itemId: item.id });
|
||||||
|
deps.updateStatus('Loading users...');
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
resetItemManagementState();
|
||||||
|
deps.updateStatus('Cancelled.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemManageTransferUserModeInput(code: string, key: string): void {
|
||||||
|
if (!itemManagementSelectedItemId || itemManagementTransferTargets.length === 0) {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const control = handleListControlKey(
|
||||||
|
code,
|
||||||
|
key,
|
||||||
|
itemManagementTransferTargets,
|
||||||
|
itemManagementTargetUserIndex,
|
||||||
|
(target) => transferTargetLabel(target),
|
||||||
|
);
|
||||||
|
if (control.type === 'move') {
|
||||||
|
itemManagementTargetUserIndex = control.index;
|
||||||
|
const label = transferTargetLabel(itemManagementTransferTargets[itemManagementTargetUserIndex]);
|
||||||
|
deps.updateStatus(label);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const item = deps.state.items.get(itemManagementSelectedItemId);
|
||||||
|
const target = itemManagementTransferTargets[itemManagementTargetUserIndex];
|
||||||
|
if (!item || !target) {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openItemManagementConfirm({
|
||||||
|
itemId: item.id,
|
||||||
|
action: 'transfer',
|
||||||
|
prompt: `Transfer ${deps.itemLabel(item)} to ${target.username}?`,
|
||||||
|
targetUserId: target.userId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
deps.updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Item management.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmYesNoModeInput(code: string, key: string): void {
|
||||||
|
if (!itemManagementConfirmContext) {
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
resetItemManagementState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const control = handleYesNoMenuInput(code, key, itemManagementConfirmIndex);
|
||||||
|
if (control.type === 'move') {
|
||||||
|
itemManagementConfirmIndex = control.index;
|
||||||
|
deps.updateStatus(YES_NO_OPTIONS[itemManagementConfirmIndex].label);
|
||||||
|
deps.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'cancel') {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
itemManagementConfirmContext = null;
|
||||||
|
deps.updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Item management.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const selected = YES_NO_OPTIONS[itemManagementConfirmIndex];
|
||||||
|
const context = itemManagementConfirmContext;
|
||||||
|
itemManagementConfirmContext = null;
|
||||||
|
if (selected.id === 'no') {
|
||||||
|
deps.state.mode = 'itemManageOptions';
|
||||||
|
deps.updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Cancelled.');
|
||||||
|
deps.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.state.mode = 'normal';
|
||||||
|
if (context.action === 'delete') {
|
||||||
|
deps.signalingSend({ type: 'item_delete', itemId: context.itemId });
|
||||||
|
} else if (context.action === 'transfer' && context.targetUserId) {
|
||||||
|
deps.signalingSend({ type: 'item_transfer', itemId: context.itemId, targetUserId: context.targetUserId });
|
||||||
|
}
|
||||||
|
resetItemManagementState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset: resetItemManagementState,
|
||||||
|
beginItemSelection,
|
||||||
|
beginItemManagement,
|
||||||
|
beginItemProperties,
|
||||||
|
recomputeActiveItemPropertyKeys,
|
||||||
|
getManagementOptions,
|
||||||
|
handleItemTransferTargets,
|
||||||
|
handleSelectItemModeInput,
|
||||||
|
handleItemManageOptionsModeInput,
|
||||||
|
handleItemManageTransferUserModeInput,
|
||||||
|
handleConfirmYesNoModeInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
1499
client/src/main.ts
1499
client/src/main.ts
File diff suppressed because it is too large
Load Diff
451
client/src/session/authController.ts
Normal file
451
client/src/session/authController.ts
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import type { IncomingMessage, OutgoingMessage } from '../network/protocol';
|
||||||
|
|
||||||
|
export type AuthPolicy = {
|
||||||
|
usernameMinLength: number;
|
||||||
|
usernameMaxLength: number;
|
||||||
|
passwordMinLength: number;
|
||||||
|
passwordMaxLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthMode = 'login' | 'register';
|
||||||
|
|
||||||
|
type AuthDom = {
|
||||||
|
loginView: HTMLElement;
|
||||||
|
registerView: HTMLElement;
|
||||||
|
authUsername: HTMLInputElement;
|
||||||
|
authPassword: HTMLInputElement;
|
||||||
|
registerUsername: HTMLInputElement;
|
||||||
|
registerPassword: HTMLInputElement;
|
||||||
|
registerPasswordConfirm: HTMLInputElement;
|
||||||
|
registerEmail: HTMLInputElement;
|
||||||
|
authPolicyHintRegister: HTMLParagraphElement;
|
||||||
|
authSessionView: HTMLElement;
|
||||||
|
authSessionText: HTMLParagraphElement;
|
||||||
|
authModeSeparator: HTMLElement;
|
||||||
|
showRegisterButton: HTMLButtonElement;
|
||||||
|
connectButton: HTMLButtonElement;
|
||||||
|
logoutButton: HTMLButtonElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthControllerDeps = {
|
||||||
|
dom: AuthDom;
|
||||||
|
authPolicyStorageKey: string;
|
||||||
|
authSessionCookieSetUrl: string;
|
||||||
|
authSessionCookieClearUrl: string;
|
||||||
|
authSessionCookieClientHeader: string;
|
||||||
|
initialAuthUsername: string;
|
||||||
|
isRunning: () => boolean;
|
||||||
|
isMuted: () => boolean;
|
||||||
|
isConnecting: () => boolean;
|
||||||
|
setConnecting: (value: boolean) => void;
|
||||||
|
applyMuteToTrack: (muted: boolean) => void;
|
||||||
|
signalingSend: (message: OutgoingMessage) => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
saveAuthUsername: (username: string) => void;
|
||||||
|
setConnectionStatus: (message: string) => void;
|
||||||
|
updateStatus: (message: string) => void;
|
||||||
|
pushChatMessage: (message: string) => void;
|
||||||
|
onServerAdminMenuActions: (actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthUiDeps = {
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WelcomeAuth = Extract<IncomingMessage, { type: 'welcome' }>['auth'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the auth/session controller used by the pre-connect UI and auth packet flow.
|
||||||
|
*/
|
||||||
|
export function createAuthController(deps: AuthControllerDeps): {
|
||||||
|
initializeUi: () => void;
|
||||||
|
setupUiHandlers: (uiDeps: AuthUiDeps) => void;
|
||||||
|
updateConnectAvailability: () => void;
|
||||||
|
hasPermission: (key: string) => boolean;
|
||||||
|
getVoiceSendAllowed: () => boolean;
|
||||||
|
getAuthUserId: () => string;
|
||||||
|
sendAuthRequest: () => void;
|
||||||
|
setAuthMode: (mode: AuthMode) => void;
|
||||||
|
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
|
||||||
|
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
||||||
|
handleAuthPermissions: (message: Extract<IncomingMessage, { type: 'auth_permissions' }>) => void;
|
||||||
|
applyWelcomeAuth: (
|
||||||
|
auth: WelcomeAuth,
|
||||||
|
adminMenuActions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined,
|
||||||
|
) => void;
|
||||||
|
logOutAccount: () => void;
|
||||||
|
} {
|
||||||
|
let authMode: AuthMode = 'login';
|
||||||
|
let authUsername = deps.initialAuthUsername;
|
||||||
|
let authUserId = '';
|
||||||
|
let authPolicy: AuthPolicy | null = null;
|
||||||
|
let authPermissions = new Set<string>();
|
||||||
|
let voiceSendAllowed = true;
|
||||||
|
let pendingAuthRequest = false;
|
||||||
|
|
||||||
|
function sanitizeAuthUsername(value: string): string {
|
||||||
|
const maxLength = authPolicy?.usernameMaxLength ?? 128;
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, '')
|
||||||
|
.slice(0, Math.max(1, maxLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVoiceSendPermission(): void {
|
||||||
|
voiceSendAllowed = authPermissions.has('voice.send');
|
||||||
|
if (voiceSendAllowed) {
|
||||||
|
deps.applyMuteToTrack(deps.isMuted());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.applyMuteToTrack(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAuthPermissions(role: string | null | undefined, permissions: string[] | null | undefined): void {
|
||||||
|
void role;
|
||||||
|
authPermissions = new Set((permissions || []).map((value) => String(value).trim()).filter((value) => value.length > 0));
|
||||||
|
applyVoiceSendPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAuthPolicy(policy: unknown): void {
|
||||||
|
if (!policy || typeof policy !== 'object') return;
|
||||||
|
const raw = policy as Partial<AuthPolicy>;
|
||||||
|
const usernameMin = Number(raw.usernameMinLength);
|
||||||
|
const usernameMax = Number(raw.usernameMaxLength);
|
||||||
|
const passwordMin = Number(raw.passwordMinLength);
|
||||||
|
const passwordMax = Number(raw.passwordMaxLength);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(usernameMin) ||
|
||||||
|
!Number.isInteger(usernameMax) ||
|
||||||
|
!Number.isInteger(passwordMin) ||
|
||||||
|
!Number.isInteger(passwordMax)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usernameMin < 1 || usernameMax < usernameMin || passwordMin < 1 || passwordMax < passwordMin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authPolicy = {
|
||||||
|
usernameMinLength: usernameMin,
|
||||||
|
usernameMaxLength: usernameMax,
|
||||||
|
passwordMinLength: passwordMin,
|
||||||
|
passwordMaxLength: passwordMax,
|
||||||
|
};
|
||||||
|
localStorage.setItem(deps.authPolicyStorageKey, JSON.stringify(authPolicy));
|
||||||
|
deps.dom.authPolicyHintRegister.textContent = `Username, ${usernameMin}-${usernameMax} characters. Password, ${passwordMin}-${passwordMax} characters.`;
|
||||||
|
deps.dom.authUsername.minLength = usernameMin;
|
||||||
|
deps.dom.authUsername.maxLength = usernameMax;
|
||||||
|
deps.dom.registerUsername.minLength = usernameMin;
|
||||||
|
deps.dom.registerUsername.maxLength = usernameMax;
|
||||||
|
deps.dom.authPassword.minLength = passwordMin;
|
||||||
|
deps.dom.authPassword.maxLength = passwordMax;
|
||||||
|
deps.dom.registerPassword.minLength = passwordMin;
|
||||||
|
deps.dom.registerPassword.maxLength = passwordMax;
|
||||||
|
deps.dom.registerPasswordConfirm.minLength = passwordMin;
|
||||||
|
deps.dom.registerPasswordConfirm.maxLength = passwordMax;
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedAuthPolicy(): void {
|
||||||
|
const raw = localStorage.getItem(deps.authPolicyStorageKey);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
applyAuthPolicy(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed persisted policy and keep live server policy source of truth.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectAvailability(): void {
|
||||||
|
const hasSavedSessionHint = sanitizeAuthUsername(authUsername).length > 0;
|
||||||
|
const showLogout = deps.isRunning() || hasSavedSessionHint;
|
||||||
|
deps.dom.logoutButton.classList.toggle('hidden', !showLogout);
|
||||||
|
deps.dom.logoutButton.disabled = !showLogout;
|
||||||
|
if (deps.isRunning()) {
|
||||||
|
deps.dom.connectButton.textContent = 'Connect';
|
||||||
|
deps.dom.connectButton.disabled = true;
|
||||||
|
deps.dom.loginView.classList.add('hidden');
|
||||||
|
deps.dom.registerView.classList.add('hidden');
|
||||||
|
deps.dom.authSessionView.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasSavedSessionHint) {
|
||||||
|
deps.dom.authSessionText.textContent = `Logged in as ${sanitizeAuthUsername(authUsername)}.`;
|
||||||
|
deps.dom.showRegisterButton.classList.add('hidden');
|
||||||
|
deps.dom.authModeSeparator.classList.add('hidden');
|
||||||
|
deps.dom.loginView.classList.add('hidden');
|
||||||
|
deps.dom.registerView.classList.add('hidden');
|
||||||
|
deps.dom.authSessionView.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
deps.dom.showRegisterButton.classList.remove('hidden');
|
||||||
|
deps.dom.authModeSeparator.classList.remove('hidden');
|
||||||
|
deps.dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login';
|
||||||
|
deps.dom.loginView.classList.toggle('hidden', authMode !== 'login');
|
||||||
|
deps.dom.registerView.classList.toggle('hidden', authMode !== 'register');
|
||||||
|
deps.dom.authSessionView.classList.add('hidden');
|
||||||
|
}
|
||||||
|
const usernameMin = authPolicy?.usernameMinLength ?? 1;
|
||||||
|
const passwordMin = authPolicy?.passwordMinLength ?? 1;
|
||||||
|
const hasLoginCredentials =
|
||||||
|
sanitizeAuthUsername(deps.dom.authUsername.value).length >= usernameMin &&
|
||||||
|
deps.dom.authPassword.value.trim().length >= passwordMin;
|
||||||
|
const hasRegisterCredentials =
|
||||||
|
sanitizeAuthUsername(deps.dom.registerUsername.value).length >= usernameMin &&
|
||||||
|
deps.dom.registerPassword.value.trim().length >= passwordMin &&
|
||||||
|
deps.dom.registerPassword.value === deps.dom.registerPasswordConfirm.value;
|
||||||
|
const authReady = authMode === 'login' ? true : hasRegisterCredentials;
|
||||||
|
deps.dom.connectButton.textContent = hasSavedSessionHint
|
||||||
|
? 'Connect'
|
||||||
|
: authMode === 'register'
|
||||||
|
? 'Register & Connect'
|
||||||
|
: hasLoginCredentials
|
||||||
|
? 'Log In & Connect'
|
||||||
|
: 'Connect';
|
||||||
|
deps.dom.connectButton.disabled = deps.isConnecting() || !authReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthMode(mode: AuthMode): void {
|
||||||
|
authMode = mode;
|
||||||
|
deps.dom.loginView.classList.toggle('hidden', mode !== 'login');
|
||||||
|
deps.dom.registerView.classList.toggle('hidden', mode !== 'register');
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthRequestPacket(): OutgoingMessage | null {
|
||||||
|
if (authMode === 'register') {
|
||||||
|
const username = sanitizeAuthUsername(deps.dom.registerUsername.value);
|
||||||
|
const password = deps.dom.registerPassword.value;
|
||||||
|
const email = deps.dom.registerEmail.value.trim();
|
||||||
|
if (!username || !password || password !== deps.dom.registerPasswordConfirm.value) return null;
|
||||||
|
return { type: 'auth_register', username, password, ...(email ? { email } : {}) };
|
||||||
|
}
|
||||||
|
const username = sanitizeAuthUsername(deps.dom.authUsername.value);
|
||||||
|
const password = deps.dom.authPassword.value;
|
||||||
|
if (!username || !password) return null;
|
||||||
|
return { type: 'auth_login', username, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAuthRequest(): void {
|
||||||
|
const packet = buildAuthRequestPacket();
|
||||||
|
if (!packet) {
|
||||||
|
pendingAuthRequest = false;
|
||||||
|
deps.setConnectionStatus('Attempting saved session...');
|
||||||
|
deps.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingAuthRequest = true;
|
||||||
|
deps.setConnectionStatus('Authenticating...');
|
||||||
|
deps.signalingSend(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
||||||
|
const hadPendingRequest = pendingAuthRequest;
|
||||||
|
pendingAuthRequest = false;
|
||||||
|
authUserId = '';
|
||||||
|
applyAuthPolicy(message.authPolicy);
|
||||||
|
applyAuthPermissions('user', []);
|
||||||
|
deps.onServerAdminMenuActions([]);
|
||||||
|
deps.setConnectionStatus('Authentication required.');
|
||||||
|
deps.updateStatus(message.message);
|
||||||
|
if (!hadPendingRequest) {
|
||||||
|
const packet = buildAuthRequestPacket();
|
||||||
|
if (packet) {
|
||||||
|
pendingAuthRequest = true;
|
||||||
|
deps.setConnectionStatus('Authenticating...');
|
||||||
|
deps.signalingSend(packet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistHttpOnlySessionCookie(sessionToken: string): Promise<void> {
|
||||||
|
const token = sessionToken.trim();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(deps.authSessionCookieSetUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
[deps.authSessionCookieClientHeader]: '1',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to persist auth cookie.', error);
|
||||||
|
deps.pushChatMessage('Session save failed. You may need to log in again after refresh.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearHttpOnlySessionCookie(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(deps.authSessionCookieClearUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
[deps.authSessionCookieClientHeader]: '1',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to clear auth cookie.', error);
|
||||||
|
deps.pushChatMessage('Session clear failed. Your browser may retain an old login cookie.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> {
|
||||||
|
pendingAuthRequest = false;
|
||||||
|
applyAuthPolicy(message.authPolicy);
|
||||||
|
if (!message.ok) {
|
||||||
|
authUserId = '';
|
||||||
|
deps.dom.authPassword.value = '';
|
||||||
|
deps.dom.registerPassword.value = '';
|
||||||
|
deps.dom.registerPasswordConfirm.value = '';
|
||||||
|
if (message.message.toLowerCase().includes('session')) {
|
||||||
|
void clearHttpOnlySessionCookie();
|
||||||
|
}
|
||||||
|
applyAuthPermissions('user', []);
|
||||||
|
deps.onServerAdminMenuActions([]);
|
||||||
|
deps.setConnectionStatus(message.message);
|
||||||
|
deps.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
deps.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.sessionToken) {
|
||||||
|
void persistHttpOnlySessionCookie(message.sessionToken);
|
||||||
|
}
|
||||||
|
if (message.username) {
|
||||||
|
authUsername = message.username;
|
||||||
|
deps.saveAuthUsername(message.username);
|
||||||
|
deps.dom.authUsername.value = message.username;
|
||||||
|
deps.dom.registerUsername.value = message.username;
|
||||||
|
}
|
||||||
|
applyAuthPermissions(message.role, message.permissions);
|
||||||
|
deps.onServerAdminMenuActions(message.adminMenuActions);
|
||||||
|
deps.dom.authPassword.value = '';
|
||||||
|
deps.dom.registerPassword.value = '';
|
||||||
|
deps.dom.registerPasswordConfirm.value = '';
|
||||||
|
deps.setConnectionStatus('Authenticated. Joining world...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
|
||||||
|
const hadVoiceSend = voiceSendAllowed;
|
||||||
|
applyAuthPermissions(message.role, message.permissions);
|
||||||
|
deps.onServerAdminMenuActions(message.adminMenuActions);
|
||||||
|
if (hadVoiceSend && !voiceSendAllowed) {
|
||||||
|
deps.updateStatus('Voice send permission revoked.');
|
||||||
|
}
|
||||||
|
if (!hadVoiceSend && voiceSendAllowed) {
|
||||||
|
deps.updateStatus('Voice send permission granted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWelcomeAuth(
|
||||||
|
auth: WelcomeAuth,
|
||||||
|
adminMenuActions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined,
|
||||||
|
): void {
|
||||||
|
authUserId = String(auth?.userId || '').trim();
|
||||||
|
applyAuthPolicy(auth?.policy);
|
||||||
|
applyAuthPermissions(auth?.role, auth?.permissions);
|
||||||
|
deps.onServerAdminMenuActions(adminMenuActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logOutAccount(): void {
|
||||||
|
authUserId = '';
|
||||||
|
authUsername = '';
|
||||||
|
void clearHttpOnlySessionCookie();
|
||||||
|
deps.saveAuthUsername('');
|
||||||
|
applyAuthPermissions('user', []);
|
||||||
|
deps.onServerAdminMenuActions([]);
|
||||||
|
if (deps.isRunning()) {
|
||||||
|
deps.signalingSend({ type: 'auth_logout' });
|
||||||
|
deps.disconnect();
|
||||||
|
}
|
||||||
|
setAuthMode('login');
|
||||||
|
deps.updateStatus('Logged out.');
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUiHandlers(uiDeps: AuthUiDeps): void {
|
||||||
|
deps.dom.showRegisterButton.addEventListener('click', () => {
|
||||||
|
if (authMode === 'login') {
|
||||||
|
setAuthMode('register');
|
||||||
|
deps.dom.registerUsername.focus();
|
||||||
|
} else {
|
||||||
|
setAuthMode('login');
|
||||||
|
deps.dom.authUsername.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
deps.dom.logoutButton.addEventListener('click', () => {
|
||||||
|
logOutAccount();
|
||||||
|
});
|
||||||
|
deps.dom.authUsername.addEventListener('input', () => {
|
||||||
|
deps.dom.authUsername.value = sanitizeAuthUsername(deps.dom.authUsername.value);
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
deps.dom.authPassword.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
deps.dom.registerUsername.addEventListener('input', () => {
|
||||||
|
deps.dom.registerUsername.value = sanitizeAuthUsername(deps.dom.registerUsername.value);
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
deps.dom.registerPassword.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
deps.dom.registerPasswordConfirm.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
deps.dom.registerEmail.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitAuthOnEnter = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
if (deps.dom.connectButton.disabled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void uiDeps.connect();
|
||||||
|
};
|
||||||
|
deps.dom.authUsername.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
deps.dom.authPassword.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
deps.dom.registerUsername.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
deps.dom.registerPassword.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
deps.dom.registerPasswordConfirm.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
deps.dom.registerEmail.addEventListener('keydown', submitAuthOnEnter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeUi(): void {
|
||||||
|
deps.dom.authUsername.value = sanitizeAuthUsername(authUsername);
|
||||||
|
deps.dom.registerUsername.value = sanitizeAuthUsername(authUsername);
|
||||||
|
loadPersistedAuthPolicy();
|
||||||
|
setAuthMode('login');
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initializeUi,
|
||||||
|
setupUiHandlers,
|
||||||
|
updateConnectAvailability,
|
||||||
|
hasPermission: (key: string) => authPermissions.has(key),
|
||||||
|
getVoiceSendAllowed: () => voiceSendAllowed,
|
||||||
|
getAuthUserId: () => authUserId,
|
||||||
|
sendAuthRequest,
|
||||||
|
setAuthMode,
|
||||||
|
handleAuthRequired,
|
||||||
|
handleAuthResult,
|
||||||
|
handleAuthPermissions,
|
||||||
|
applyWelcomeAuth,
|
||||||
|
logOutAccount,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user