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