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 @impl true def mount(_params, _session, socket) do socket = ensure_user_role_loaded(socket) actor = socket.assigns[:current_user] roles = load_roles(actor) user_counts = load_user_counts(roles) {:ok, socket |> assign(:page_title, gettext("Listing Roles")) |> assign(:roles, roles) |> assign(:user_counts, user_counts)} end defp ensure_user_role_loaded(socket) do if socket.assigns[:current_user] do user = socket.assigns.current_user user_with_role = load_user_role(user) assign(socket, :current_user, user_with_role) else socket end end defp load_user_role(user) do case Map.get(user, :role) do %Ash.NotLoaded{} -> load_role_safely(user) nil -> load_role_safely(user) _role -> user end end defp load_role_safely(user) do case Ash.load(user, :role, domain: Mv.Accounts) do {:ok, loaded_user} -> loaded_user {:error, _} -> user end end @impl true def handle_event("delete", %{"id" => id}, socket) do case Authorization.get_role(id) 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 cond do role.is_system_role -> {:noreply, put_flash( socket, :error, gettext("System roles cannot be deleted.") )} recalculate_user_count(role) > 0 -> user_count = recalculate_user_count(role) {: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 ) )} true -> perform_role_deletion(role, id, socket) 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 defp load_roles(actor) do opts = if actor, do: [actor: actor], else: [] case Authorization.list_roles(opts) do {:ok, roles} -> Enum.sort_by(roles, & &1.name) {:error, _} -> [] end end # Loads all user counts for roles in a single query to avoid N+1 queries defp load_user_counts(roles) do role_ids = Enum.map(roles, & &1.id) # Load all users with role_id in a single query users = Accounts.User |> Ash.Query.filter(role_id in ^role_ids) |> Ash.Query.select([:role_id]) |> Ash.read!(domain: Mv.Accounts) # Group by role_id and count users |> Enum.group_by(& &1.role_id) |> Enum.map(fn {role_id, users_list} -> {role_id, length(users_list)} end) |> Map.new() end # Gets user count from preloaded assigns map 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) defp recalculate_user_count(role) do case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id)) do {:ok, count} -> count _ -> 0 end end defp format_error(%Ash.Error.Invalid{} = error) do Enum.map_join(error.errors, ", ", fn e -> e.message end) end defp format_error(error) when is_binary(error), do: error defp format_error(_error), do: gettext("An error occurred") defp permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm" defp permission_set_badge_class("read_only"), do: "badge badge-info badge-sm" defp permission_set_badge_class("normal_user"), do: "badge badge-success badge-sm" defp permission_set_badge_class("admin"), do: "badge badge-error badge-sm" defp permission_set_badge_class(_), do: "badge badge-ghost badge-sm" end