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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue