"""WebSocket handlers for user management.""" from __future__ import annotations from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from ..const import ( CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_META_LENGTH, ) from ..helpers.permissions import require_write, user_may_write from . import ( _build_task_summary, _get_merged_tasks, _get_object_entries, _get_runtime_data, _load_object_entry, ) @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/users/list"}) @websocket_api.async_response async def ws_list_users( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return list of HA users for task assignment. Returns users with their basic info for frontend selector. Filters out system users (is_active=False, system_generated=True). """ users_data = [] # Only admins receive the is_admin / is_owner flags. A non-admin caller gets # just id + name (needed for the assignee selector and name display) so they # cannot enumerate who is an admin/owner via this command. caller_is_admin = connection.user is not None and connection.user.is_admin for user in await hass.auth.async_get_users(): # Filter out system users and inactive users if not user.is_active or user.system_generated: continue entry: dict[str, Any] = {"id": user.id, "name": user.name} if caller_is_admin: entry["is_admin"] = user.is_admin entry["is_owner"] = user.is_owner users_data.append(entry) connection.send_result(msg["id"], {"users": users_data}) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/task/assign_user", vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)), vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)), vol.Optional("user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None), # None = unassign } ) @require_write @websocket_api.async_response async def ws_assign_user( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Assign or unassign a user to a task.""" entry = _load_object_entry(hass, connection, msg) if entry is None: return task_id = msg["task_id"] if task_id not in entry.data.get(CONF_TASKS, {}): connection.send_error(msg["id"], "not_found", "Task not found") return user_id = msg.get("user_id") # Validate user exists if provided — BEFORE touching entry.data. This is # the handler's only await; reading the tasks after it means a concurrent # writer landing during the auth lookup (e.g. a completing task persisting # its rotation) can't be reverted by a stale whole-map write (the # migration-race class, bug audit 2026-07-11). if user_id: user = await hass.auth.async_get_user(user_id) if user is None: connection.send_error(msg["id"], "invalid_user", "User not found") return tasks_data = entry.data.get(CONF_TASKS, {}) if task_id not in tasks_data: connection.send_error(msg["id"], "not_found", "Task not found") return task = dict(tasks_data[task_id]) if user_id is None: # Unassign user - remove field if it exists task.pop("responsible_user_id", None) else: task["responsible_user_id"] = user_id # Patch only this task's key onto a fresh read — never write back a map # snapshot from before an await. new_data = dict(entry.data) new_tasks = dict(new_data.get(CONF_TASKS, {})) new_tasks[task_id] = task new_data[CONF_TASKS] = new_tasks hass.config_entries.async_update_entry(entry, data=new_data) # Refresh coordinator rd = _get_runtime_data(hass, entry.entry_id) if rd and rd.coordinator: await rd.coordinator.async_refresh_now() connection.send_result(msg["id"], {"success": True, "user_id": user_id}) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/tasks/by_user", vol.Required("user_id"): vol.All(str, vol.Length(max=MAX_META_LENGTH)), } ) @websocket_api.async_response async def ws_tasks_by_user( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all tasks assigned to a specific user across all objects.""" user_id = msg["user_id"] # Authorization: a user may query their OWN assignments; querying another # user's requires write access (admin or operator) — prevents a plain user # from enumerating another user's task assignments. if connection.user is None or (user_id != connection.user.id and not user_may_write(hass, connection)): connection.send_error(msg["id"], "unauthorized", "Not authorized to view another user's tasks") return entries = _get_object_entries(hass) result = [] for entry in entries: rd = _get_runtime_data(hass, entry.entry_id) coord_data = rd.coordinator.data if rd and rd.coordinator else None ct_tasks = (coord_data or {}).get(CONF_TASKS, {}) tasks_data = _get_merged_tasks(entry) obj_data = entry.data.get(CONF_OBJECT, {}) for tid, tdata in tasks_data.items(): if tdata.get("responsible_user_id") == user_id: task_summary = _build_task_summary(hass, tid, tdata, ct_tasks.get(tid)) task_summary["object_name"] = obj_data.get("name", "") task_summary["entry_id"] = entry.entry_id result.append(task_summary) connection.send_result(msg["id"], {"tasks": result})