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

@ -39,6 +39,7 @@ defmodule MvWeb.UserLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
@ -281,6 +282,38 @@ defmodule MvWeb.UserLive.Form do
</div>
<% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) 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 user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
type="button"
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
<div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
@ -404,6 +437,44 @@ defmodule MvWeb.UserLive.Form do
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
actor = current_actor(socket)
if is_nil(user) do
{:noreply, put_flash(socket, :error, gettext("User not found"))}
else
if to_string(id) != to_string(user.id) do
{:noreply, put_flash(socket, :error, gettext("User not found"))}
else
if Mv.Helpers.SystemActor.system_user?(user) do
{:noreply,
put_flash(socket, :error, gettext("System user cannot be deleted."))}
else
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("User deleted successfully"))
|> push_navigate(to: ~p"/users")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
end
end
end
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}

View file

@ -5,15 +5,14 @@ defmodule MvWeb.UserLive.Index do
## Features
- List all users with email and linked member
- Sort users by email (default)
- Delete users
- Navigate to user details and edit forms
- Navigate to user details (row click) and edit from details header
- Delete only via Danger zone on user show/edit
- Bulk selection for future batch operations
## Relationships
Displays linked member information when a user is connected to a member account.
## Events
- `delete` - Remove a user from the database
- `select_user` - Toggle individual user selection
- `select_all` - Toggle selection of all visible users
@ -26,7 +25,6 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def mount(_params, _session, socket) do
@ -48,45 +46,6 @@ defmodule MvWeb.UserLive.Index do
|> assign(:selected_users, [])}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:ok, user} ->
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply,
socket
|> assign(:users, updated_users)
|> put_flash(:success, gettext("User deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
end
# Selects one user in the list of users
@impl true
def handle_event("select_user", %{"id" => id}, socket) do

View file

@ -84,29 +84,5 @@
<span class="text-base-content/70">—</span>
<% end %>
</:col>
<:action :let={user}>
<div class="sr-only">
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit user")}
</.link>
<% end %>
</:action>
<:action :let={user}>
<%= if can?(@current_user, :destroy, user) do %>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="user-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -27,6 +27,7 @@ defmodule MvWeb.UserLive.Show do
use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
@ -80,6 +81,37 @@ defmodule MvWeb.UserLive.Show do
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) 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 user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
</Layouts.app>
"""
end
@ -103,4 +135,39 @@ defmodule MvWeb.UserLive.Show do
|> assign(:user, user)}
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
actor = current_actor(socket)
if to_string(id) != to_string(user.id) do
{:noreply, put_flash(socket, :error, gettext("User not found"))}
else
if Mv.Helpers.SystemActor.system_user?(user) do
{:noreply,
put_flash(socket, :error, gettext("System user cannot be deleted."))}
else
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("User deleted successfully"))
|> push_navigate(to: ~p"/users")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply,
put_flash(socket, :error, format_ash_error(error))}
end
end
end
end
end