Move admin menu wiring server-side and filter ban/unban lists

This commit is contained in:
Jage9
2026-02-27 03:49:28 -05:00
parent 0edc9b9a3f
commit aba319751b
8 changed files with 103 additions and 50 deletions

View File

@@ -758,7 +758,6 @@ class AuthService:
password_hash TEXT NOT NULL,
email TEXT UNIQUE,
role_id INTEGER,
role TEXT,
status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
@@ -866,26 +865,12 @@ class AuthService:
)
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()
default_user_role_id = role_id_by_name.get("user")
if default_user_role_id is None:
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(
"UPDATE users SET role_id = ?, updated_at_ms = ? WHERE role_id IS NULL",
(default_user_role_id, self.now_ms()),

View File

@@ -86,6 +86,7 @@ class AdminRoleDeletePacket(BasePacket):
class AdminUsersListPacket(BasePacket):
type: Literal["admin_users_list"]
action: Literal["set_role", "ban", "unban"] | None = None
class AdminUserSetRolePacket(BasePacket):
@@ -226,6 +227,7 @@ class AuthResultPacket(BasePacket):
username: str | None = None
role: str | None = None
permissions: list[str] | None = None
adminMenuActions: list[dict[str, str]] | None = None
nickname: str | None = None
authPolicy: dict | None = None
@@ -234,6 +236,7 @@ class AuthPermissionsPacket(BasePacket):
type: Literal["auth_permissions"]
role: str
permissions: list[str]
adminMenuActions: list[dict[str, str]] | None = None
class UserLeftPacket(BasePacket):

View File

@@ -117,6 +117,12 @@ AUTH_FAILURE_JITTER_MAX_MS = 0.08
RADIO_METADATA_POLL_INTERVAL_S = 10.0
RADIO_METADATA_TIMEOUT_S = 6.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:
@@ -237,6 +243,18 @@ class SignalingServer:
"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
def _sorted_permissions(values: set[str] | tuple[str, ...] | None) -> list[str]:
"""Return deterministic sorted permission list."""
@@ -278,6 +296,7 @@ class SignalingServer:
type="auth_permissions",
role=client.role,
permissions=permissions,
adminMenuActions=self._build_admin_menu_actions_for_client(client),
),
)
@@ -1279,7 +1298,7 @@ class SignalingServer:
"movementTickMs": self.movement_tick_ms,
"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},
auth={
"authenticated": client.authenticated,
@@ -1480,6 +1499,7 @@ class SignalingServer:
username=session.user.username,
role=session.user.role,
permissions=self._sorted_permissions(session.user.permissions),
adminMenuActions=self._build_admin_menu_actions_for_client(client),
nickname=client.nickname,
authPolicy=self._auth_policy(),
),
@@ -1487,7 +1507,7 @@ class SignalingServer:
await self._activate_authenticated_client(client)
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."""
item_types: list[dict] = []
@@ -1507,6 +1527,7 @@ class SignalingServer:
return {
"itemTypeOrder": list(ITEM_TYPE_SEQUENCE),
"itemTypes": item_types,
"adminMenu": {"actions": self._build_admin_menu_actions_for_client(client)},
}
async def _broadcast_wheel_result_after_delay(
@@ -1598,6 +1619,10 @@ class SignalingServer:
await deny("user_set_role", "Not authorized.")
return True
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))
return True
@@ -1758,8 +1783,8 @@ class SignalingServer:
PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc)
return
# Compatibility path for local tests injecting pre-authenticated clients
# directly into server.clients without running websocket auth handshake.
# Test-harness compatibility: some unit tests inject clients directly into
# `server.clients` without running auth handshake packets.
if not client.authenticated and client.websocket in self.clients:
client.authenticated = True
client.user_id = client.user_id or client.id