All checks were successful
continuous-integration/drone/push Build is passing
Replace Elixir-side counting with Ecto GROUP BY COUNT query for better performance. This avoids loading all users into memory and performs the aggregation directly in the database.
170 lines
4.8 KiB
Elixir
170 lines
4.8 KiB
Elixir
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 = 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 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
|