"""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_TASKS, DOMAIN, MAX_META_LENGTH, ) from ..helpers.aggregate import get_coordinator_data, get_runtime_data, object_name from ..helpers.entry_tasks import write_task from ..helpers.permissions import require_write, user_may_write from . import ( ID_FIELD, _build_task_summary, _get_merged_tasks, _get_object_entries, _load_object_task, ) @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 # #169 follow-up: every caller gets the avatar (initials + colour) — the # resolved value, i.e. the admin override or the name/id-derived default. from ..helpers.global_options import get_global_options from ..helpers.member_display import member_display options = get_global_options(hass) 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, **member_display(options, user.id, 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"): ID_FIELD, vol.Required("task_id"): ID_FIELD, 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.""" ctx = _load_object_task(hass, connection, msg) if ctx is None: return entry, _rd, _task = ctx task_id = msg["task_id"] 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 # Fresh read AFTER the await (see above) — the task may have vanished. fresh = entry.data.get(CONF_TASKS, {}).get(task_id) if fresh is None: connection.send_error(msg["id"], "not_found", "Task not found") return task = dict(fresh) 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. write_task(hass, entry, task_id, task) # 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: ct_tasks = (get_coordinator_data(hass, entry.entry_id) or {}).get(CONF_TASKS, {}) tasks_data = _get_merged_tasks(entry) obj_name = object_name(entry) 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_name task_summary["entry_id"] = entry.entry_id result.append(task_summary) connection.send_result(msg["id"], {"tasks": result})