feat: conistent danger zone delete flow
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
e5a6003ace
commit
91cf7cca6a
19 changed files with 499 additions and 287 deletions
|
|
@ -365,3 +365,46 @@ Detail pages should not drift into random layouts.
|
|||
Add to this glossary when new terminology appears.
|
||||
|
||||
---
|
||||
|
||||
## 14) Destructive actions: Delete flow (canonical)
|
||||
|
||||
This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.
|
||||
|
||||
### Tables: no row action buttons
|
||||
- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
|
||||
- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.
|
||||
|
||||
### Navigation: row click → details
|
||||
- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
|
||||
- **MUST NOT:** Use the table for primary edit/delete actions.
|
||||
|
||||
### Edit: from details header, not from table
|
||||
- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
|
||||
- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.
|
||||
|
||||
### Delete: only via “Danger zone”
|
||||
- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
|
||||
- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
|
||||
- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.
|
||||
|
||||
### Danger zone layout and wording (canonical pattern)
|
||||
- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
|
||||
- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
|
||||
- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
|
||||
- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.
|
||||
|
||||
### Confirmation and button semantics
|
||||
- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
|
||||
- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
|
||||
- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.
|
||||
|
||||
### Accessibility
|
||||
- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
|
||||
- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
|
||||
- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).
|
||||
|
||||
### Authorization visibility
|
||||
- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
|
||||
- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -98,6 +98,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
label={gettext("Show in overview")}
|
||||
/>
|
||||
|
||||
<%= if @custom_field do %>
|
||||
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||
<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 data field cannot be undone. All custom field values for this field will be permanently removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click="request_delete"
|
||||
phx-target={@myself}
|
||||
data-testid="custom-field-delete"
|
||||
aria-label={gettext("Delete data field %{name}", name: @custom_field.name)}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete data field")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
|
|
@ -170,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("request_delete", _params, socket) do
|
||||
if custom_field = socket.assigns[:custom_field] do
|
||||
send(self(), {:open_delete_modal_for, custom_field})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
row_tooltip={gettext("Click for dataield details")}
|
||||
row_tooltip={gettext("Click to edit datafield")}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
|
|
@ -96,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Edit datafield")}
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
|
|
@ -223,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
# Get actor from assigns or fall back to socket assigns
|
||||
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)}
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)
|
||||
|
||||
# Open delete modal when requested from form (e.g. Danger zone in FormComponent)
|
||||
socket =
|
||||
case Map.get(assigns, :open_delete_for_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
id ->
|
||||
custom_field =
|
||||
Ash.get!(Mv.Membership.CustomField, id,
|
||||
load: [:assigned_members_count],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> assign(:open_delete_for_id, nil)
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do
|
|||
{:noreply, assign(socket, :active_editing_section, section)}
|
||||
end
|
||||
|
||||
# Open delete modal for custom field (triggered from Danger zone in FormComponent)
|
||||
@impl true
|
||||
def handle_info({:open_delete_modal_for, custom_field}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
open_delete_for_id: custom_field.id
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,31 @@ defmodule MvWeb.GroupLive.Form do
|
|||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||
<%= if @group && can?(@current_user, :destroy, @group) 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 group cannot be undone. All member-group associations will be permanently removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
navigate={~p"/groups/#{@group.slug}?confirm_delete=1"}
|
||||
data-testid="group-form-delete-btn"
|
||||
aria-label={gettext("Delete group %{name}", name: @group.name)}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete group")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -77,24 +77,6 @@ defmodule MvWeb.GroupLive.Index do
|
|||
<:col :let={group} label={gettext("Members")} class="text-right">
|
||||
{group.member_count || 0}
|
||||
</:col>
|
||||
<:action :let={group}>
|
||||
<.button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
navigate={~p"/groups/#{group.slug}"}
|
||||
>
|
||||
{gettext("View")}
|
||||
</.button>
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<.button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
navigate={~p"/groups/#{group.slug}/edit"}
|
||||
>
|
||||
{gettext("Edit group")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</:action>
|
||||
</.table>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"slug" => slug}, _url, socket) do
|
||||
def handle_params(%{"slug" => slug} = params, _url, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
# Check if user can read groups
|
||||
if can?(actor, :read, Mv.Membership.Group) do
|
||||
load_group_by_slug(socket, slug, actor)
|
||||
load_group_by_slug(socket, slug, actor, params)
|
||||
else
|
||||
{:noreply, redirect(socket, to: ~p"/members")}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_group_by_slug(socket, slug, actor) do
|
||||
defp load_group_by_slug(socket, slug, actor, params \\ %{}) do
|
||||
# Load group with members and member_count
|
||||
# Using explicit load ensures efficient preloading of members relationship
|
||||
require Ash.Query
|
||||
|
|
@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> redirect(to: ~p"/groups")}
|
||||
|
||||
{:ok, group} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, group.name)
|
||||
|> assign(:group, group)}
|
||||
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, group.name)
|
||||
|> assign(:group, group)
|
||||
|> assign(:show_delete_modal, open_delete)
|
||||
|> assign(:name_confirmation, "")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _error} ->
|
||||
{:noreply,
|
||||
|
|
@ -105,15 +111,6 @@ defmodule MvWeb.GroupLive.Show do
|
|||
{gettext("Edit group")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if can?(@current_user, :destroy, @group) do %>
|
||||
<.button
|
||||
variant="danger"
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="group-show-delete-btn"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
|
|
@ -339,6 +336,32 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||
<%= if can?(@current_user, :destroy, @group) 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 group cannot be undone. All member-group associations will be permanently removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="group-show-delete-btn"
|
||||
aria-label={gettext("Delete group %{name}", name: @group.name)}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete group")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||
end
|
||||
}
|
||||
row_tooltip={gettext("Click for datafield details")}
|
||||
row_tooltip={gettext("Click to edit datafield")}
|
||||
>
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
|
||||
{MemberFields.label(field_data.field)}
|
||||
|
|
@ -92,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_field_name, field_data}}>
|
||||
<.link
|
||||
phx-click="edit_member_field"
|
||||
phx-value-field={Atom.to_string(field_data.field)}
|
||||
phx-target={@myself}
|
||||
>
|
||||
{gettext("Edit datafield")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
|
|
@ -246,6 +247,42 @@ defmodule MvWeb.MemberLive.Form do
|
|||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||
<%= if @member && can?(@current_user, :destroy, @member) 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 member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete member")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
@ -366,6 +403,40 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
|
||||
if is_nil(member) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
else
|
||||
if to_string(id) != to_string(member.id) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
else
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, gettext("Member deleted successfully"))
|
||||
|> push_navigate(to: ~p"/members")}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, member) do
|
||||
notify_parent({:saved, member})
|
||||
|
||||
|
|
@ -413,6 +484,19 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
Enum.map(errors, fn
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
%{message: message} -> message
|
||||
_ -> inspect(errors)
|
||||
end)
|
||||
|
||||
Enum.join(error_messages, ", ")
|
||||
end
|
||||
|
||||
defp format_destroy_error(error), do: inspect(error)
|
||||
|
||||
defp handle_save_error(socket, form) do
|
||||
# Always show a flash message when save fails
|
||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||
|
|
|
|||
|
|
@ -418,6 +418,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
)}
|
||||
|
||||
{:error, error} ->
|
||||
require Logger
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,53 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
describe "danger zone on edit" do
|
||||
@tag :ui
|
||||
test "edit form shows Danger zone and delete button when user can destroy member", %{
|
||||
conn: conn
|
||||
} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Delete", last_name: "FromEdit", email: "delete.from.edit@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, ~p"/members/#{member}/edit")
|
||||
|
||||
assert html =~ gettext("Danger zone")
|
||||
assert has_element?(view, "[data-testid='member-delete']")
|
||||
end
|
||||
|
||||
test "delete event from edit form removes member and redirects to /members", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "ToDelete",
|
||||
last_name: "FromForm",
|
||||
email: "todelete.from.form.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member}/edit")
|
||||
|
||||
view
|
||||
|> render_click("delete", %{"id" => member.id})
|
||||
|
||||
assert_redirect(view, ~p"/members")
|
||||
|
||||
refute Mv.Membership.Member
|
||||
|> Ash.Query.filter(id == ^member.id)
|
||||
|> Ash.exists?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "tab visibility" do
|
||||
@tag :ui
|
||||
test "Payments tab is not visible on new member form", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -135,14 +135,16 @@ defmodule MvWeb.MemberLive.ShowTest do
|
|||
end
|
||||
|
||||
describe "delete action" do
|
||||
test "renders Delete button when user can destroy member", %{
|
||||
test "renders Danger zone section and Delete button when user can destroy member", %{
|
||||
conn: conn,
|
||||
member: member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||
{:ok, view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
assert has_element?(view, "[data-testid='member-delete']")
|
||||
assert html =~ gettext("Danger zone")
|
||||
assert has_element?(view, "section[aria-labelledby='danger-zone-heading']")
|
||||
end
|
||||
|
||||
test "delete event removes member and redirects to index", %{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue