feat: conistent danger zone delete flow
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-25 15:09:37 +01:00
parent e5a6003ace
commit 91cf7cca6a
19 changed files with 499 additions and 287 deletions

View file

@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do
## 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)
- Navigate to role details (row click) and edit from details header
- Delete only via Danger zone on role show page
## Security
Only admins can access this page (enforced by authorization).
@ -21,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
@impl true
def mount(_params, _session, socket) do
@ -37,83 +33,6 @@ defmodule MvWeb.RoleLive.Index do
|> 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(:success, 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)
@ -154,15 +73,4 @@ defmodule MvWeb.RoleLive.Index do
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

View file

@ -53,47 +53,5 @@
<:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
</:col>
<:action :let={role}>
<div class="sr-only">
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}>
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit role")}
</.button>
<% end %>
</:action>
<:action :let={role}>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
<.button
variant="danger"
size="sm"
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
data-confirm={gettext("Are you sure?")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</.button>
<% else %>
<.tooltip
:if={role.is_system_role}
content={gettext("System roles cannot be deleted")}
position="left"
>
<button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
disabled={true}
aria-label={gettext("Cannot delete system role")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</.tooltip>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -182,15 +182,6 @@ defmodule MvWeb.RoleLive.Show do
{gettext("Edit role")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<.button
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={gettext("Are you sure?")}
>
<.icon name="hero-trash" /> {gettext("Delete Role")}
</.button>
<% end %>
</:actions>
</.header>
@ -216,6 +207,37 @@ defmodule MvWeb.RoleLive.Show do
<% end %>
</: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
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={
gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)
}
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 %>
</Layouts.app>
"""
end