defmodule MvWeb.RoleLive.Index do @moduledoc """ LiveView for displaying and managing the role list. ## Features - List all roles with name, description, permission_set_name, is_system_role - Create new roles - Navigate to role details and edit forms - Delete non-system roles ## Events - `delete` - Remove a role from the database (only non-system roles) ## Security Only admins can access this page (enforced by authorization). """ use MvWeb, :live_view alias Mv.Accounts alias Mv.Authorization require Ash.Query import MvWeb.RoleLive.Helpers, only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3] on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} @impl true def mount(_params, _session, socket) do actor = socket.assigns[:current_user] roles = load_roles(actor) user_counts = load_user_counts(roles, actor) {:ok, socket |> assign(:page_title, gettext("Listing Roles")) |> assign(:roles, roles) |> assign(:user_counts, user_counts)} end @impl true def handle_event("delete", %{"id" => id}, socket) do case Authorization.get_role(id, actor: socket.assigns.current_user) do {:ok, role} -> handle_delete_role(role, id, socket) {:error, %Ash.Error.Query.NotFound{}} -> {:noreply, put_flash( socket, :error, gettext("Role not found.") )} {:error, error} -> error_message = format_error(error) {:noreply, put_flash( socket, :error, gettext("Failed to delete role: %{error}", error: error_message) )} end end defp handle_delete_role(role, id, socket) do if role.is_system_role do {:noreply, put_flash( socket, :error, gettext("System roles cannot be deleted.") )} else user_count = recalculate_user_count(role, socket.assigns.current_user) if user_count > 0 do {:noreply, put_flash( socket, :error, gettext( "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.", count: user_count ) )} else perform_role_deletion(role, id, socket) end end end defp perform_role_deletion(role, id, socket) do case Authorization.destroy_role(role, actor: socket.assigns.current_user) do :ok -> updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id)) updated_counts = Map.delete(socket.assigns.user_counts, id) {:noreply, socket |> assign(:roles, updated_roles) |> assign(:user_counts, updated_counts) |> put_flash(:info, gettext("Role deleted successfully."))} {:error, error} -> error_message = format_error(error) {:noreply, put_flash( socket, :error, gettext("Failed to delete role: %{error}", error: error_message) )} end end @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()] defp load_roles(actor) do opts = MvWeb.LiveHelpers.ash_actor_opts(actor) case Authorization.list_roles(opts) do {:ok, roles} -> Enum.sort_by(roles, & &1.name) {:error, _} -> [] end end # Loads all user counts for roles using DB-side aggregation for better performance @spec load_user_counts([Mv.Authorization.Role.t()], map() | nil) :: %{ Ecto.UUID.t() => non_neg_integer() } defp load_user_counts(roles, _actor) do role_ids = Enum.map(roles, & &1.id) # Use Ecto directly for efficient GROUP BY COUNT query # This is much more performant than loading all users and counting in Elixir # Note: We bypass Ash here for performance, but this is a simple read-only query import Ecto.Query query = from u in Accounts.User, where: u.role_id in ^role_ids, group_by: u.role_id, select: {u.role_id, count(u.id)} results = Mv.Repo.all(query) results |> Enum.into(%{}, fn {role_id, count} -> {role_id, count} end) end # Gets user count from preloaded assigns map @spec get_user_count(Mv.Authorization.Role.t(), %{Ecto.UUID.t() => non_neg_integer()}) :: non_neg_integer() defp get_user_count(role, user_counts) do Map.get(user_counts, role.id, 0) end # Recalculates user count for a specific role (used before deletion) @spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer() defp recalculate_user_count(role, actor) do opts = opts_with_actor([], actor, Mv.Accounts) case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do {:ok, count} -> count _ -> 0 end end end