mitgliederverwaltung/lib/mv_web/live/role_live/show.ex

322 lines
10 KiB
Elixir

defmodule MvWeb.RoleLive.Show do
@moduledoc """
LiveView for displaying a single role's details.
## Features
- Display role information (name, description, permission_set_name, is_system_role)
- Navigate to edit form
- Return to role list
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Accounts
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true
def mount(%{"id" => id}, _session, socket) do
try do
case Ash.get(
Mv.Authorization.Role,
id,
domain: Mv.Authorization,
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
user_count = load_user_count(role, socket.assigns[:current_user])
{:ok,
socket
|> assign(:page_title, gettext("Show Role"))
|> assign(:role, role)
|> assign(:user_count, user_count)
|> assign(:show_delete_modal, false)}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
{:error, error} ->
{:ok,
socket
|> put_flash(:error, format_error(error))
|> redirect(to: ~p"/admin/roles")}
end
rescue
e in [Ash.Error.Invalid] ->
# Handle exceptions that Ash.get might throw (e.g., policy violations)
case e do
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
_ ->
reraise e, __STACKTRACE__
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Mv.Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)
|> assign(:show_delete_modal, false)}
end
end
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp handle_delete_role(role, socket) do
if role.is_system_role do
{:noreply,
socket
|> put_flash(:error, gettext("System roles cannot be deleted."))
|> assign(:show_delete_modal, false)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
socket
|> put_flash(
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)
|> assign(:show_delete_modal, false)}
else
perform_role_deletion(role, socket)
end
end
end
defp perform_role_deletion(role, socket) do
case Mv.Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
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
# Loads user count for initial display (uses same logic as recalculate)
@spec load_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp load_user_count(role, actor) do
recalculate_user_count(role, actor)
end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-role-trigger"})
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button
navigate={~p"/admin/roles"}
variant="neutral"
aria-label={gettext("Back to roles list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button
variant="primary"
navigate={~p"/admin/roles/#{@role}/edit"}
data-testid="role-show-edit-btn"
>
<.icon name="hero-pencil-square" /> {gettext("Edit role")}
</.button>
<% end %>
</:actions>
</.header>
<div
id="role-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
<%= if @role.description do %>
{@role.description}
<% else %>
<span class="text-base-content/70 italic">{gettext("No description")}</span>
<% end %>
</:item>
<:item title={gettext("Permission Set")}>
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
{@role.permission_set_name}
</.badge>
</:item>
<:item title={gettext("System Role")}>
<.badge :if={@role.is_system_role} variant="warning">
{gettext("Yes")}
</.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)}
</p>
<.button
id="delete-role-trigger"
variant="danger"
phx-click="open_delete_modal"
data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-role-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-role-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-role-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
aria-label={gettext("Delete role")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end
end