Member show & MembershipFees: permissions, delete all, regenerate, errors
- Show: handle_info :member_updated and :put_flash; Linked User only when can_access_page? /users - MembershipFeesComponent: can_create_cycle/can_destroy_cycle/can_update_cycle; buttons gated - Delete all cycles via Ash.destroy (policy enforced); format_error Forbidden - Regenerate cycles for normal_user and admin (no admin-only check) - Member form: format_error tuple for membership_fee_type_id; Select a membership fee type (no None) - show_membership_fees_test: read_only UI and policy tests
This commit is contained in:
parent
8ec4a07103
commit
e3bea17827
4 changed files with 219 additions and 123 deletions
|
|
@ -177,7 +177,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
phx-change="validate"
|
phx-change="validate"
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
>
|
>
|
||||||
<option value="">{gettext("None")}</option>
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
<%= for fee_type <- @available_fee_types do %>
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
<option
|
<option
|
||||||
value={fee_type.id}
|
value={fee_type.id}
|
||||||
|
|
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</option>
|
</option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @interval_warning do %>
|
<%= if @interval_warning do %>
|
||||||
|
|
|
||||||
|
|
@ -125,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Linked User --%>
|
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||||
<div>
|
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||||
<.data_field label={gettext("Linked User")}>
|
a user is linked but not visible. --%>
|
||||||
<%= if @member.user do %>
|
<%= if can_access_page?(@current_user, "/users") do %>
|
||||||
<.link
|
<div>
|
||||||
navigate={~p"/users/#{@member.user}"}
|
<.data_field label={gettext("Linked User")}>
|
||||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
<%= if @member.user do %>
|
||||||
>
|
<.link
|
||||||
<.icon name="hero-user" class="size-4" />
|
navigate={~p"/users/#{@member.user}"}
|
||||||
{@member.user.email}
|
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||||
</.link>
|
>
|
||||||
<% else %>
|
<.icon name="hero-user" class="size-4" />
|
||||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
{@member.user.email}
|
||||||
<% end %>
|
</.link>
|
||||||
</.data_field>
|
<% else %>
|
||||||
</div>
|
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||||
|
<% end %>
|
||||||
|
</.data_field>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%!-- Notes --%>
|
<%!-- Notes --%>
|
||||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||||
|
|
@ -287,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||||
|
@impl true
|
||||||
|
def handle_info({:put_flash, type, message}, socket) do
|
||||||
|
{:noreply, put_flash(socket, type, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
|
||||||
|
@impl true
|
||||||
|
def handle_info({:member_updated, updated_member}, socket) do
|
||||||
|
member =
|
||||||
|
updated_member
|
||||||
|
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|
||||||
|
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :member, member)}
|
||||||
|
end
|
||||||
|
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit Member")
|
defp page_title(:edit), do: gettext("Edit Member")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
|
|
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Action Buttons --%>
|
<%!-- Action Buttons (only when user has permission) --%>
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<.button
|
<.button
|
||||||
|
:if={@can_create_cycle}
|
||||||
phx-click="regenerate_cycles"
|
phx-click="regenerate_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
||||||
|
|
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={Enum.any?(@cycles)}
|
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||||
phx-click="delete_all_cycles"
|
phx-click="delete_all_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
class="btn btn-sm btn-error btn-outline"
|
||||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
{gettext("Delete All Cycles")}
|
{gettext("Delete All Cycles")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={@member.membership_fee_type}
|
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||||
phx-click="open_create_cycle_modal"
|
phx-click="open_create_cycle_modal"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
|
|
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Amount")}>
|
<:col :let={cycle} label={gettext("Amount")}>
|
||||||
<span
|
<%= if @can_update_cycle do %>
|
||||||
class="font-mono cursor-pointer hover:text-primary"
|
<span
|
||||||
phx-click="edit_cycle_amount"
|
class="font-mono cursor-pointer hover:text-primary"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="edit_cycle_amount"
|
||||||
phx-target={@myself}
|
phx-value-cycle_id={cycle.id}
|
||||||
title={gettext("Click to edit amount")}
|
phx-target={@myself}
|
||||||
>
|
title={gettext("Click to edit amount")}
|
||||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
>
|
||||||
</span>
|
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
|
||||||
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Status")}>
|
<:col :let={cycle} label={gettext("Status")}>
|
||||||
|
|
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
<:action :let={cycle}>
|
<:action :let={cycle}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<%= if @can_update_cycle do %>
|
||||||
:if={cycle.status != :paid}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :paid}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="paid"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="paid"
|
||||||
class="btn btn-sm btn-success"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as paid")}
|
class="btn btn-sm btn-success"
|
||||||
>
|
title={gettext("Mark as paid")}
|
||||||
<.icon name="hero-check-circle" class="size-4" />
|
>
|
||||||
{gettext("Paid")}
|
<.icon name="hero-check-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Paid")}
|
||||||
<button
|
</button>
|
||||||
:if={cycle.status != :suspended}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :suspended}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="suspended"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="suspended"
|
||||||
class="btn btn-sm btn-outline btn-warning"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as suspended")}
|
class="btn btn-sm btn-outline btn-warning"
|
||||||
>
|
title={gettext("Mark as suspended")}
|
||||||
<.icon name="hero-pause-circle" class="size-4" />
|
>
|
||||||
{gettext("Suspended")}
|
<.icon name="hero-pause-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Suspended")}
|
||||||
<button
|
</button>
|
||||||
:if={cycle.status != :unpaid}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :unpaid}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="unpaid"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="unpaid"
|
||||||
class="btn btn-sm btn-error"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as unpaid")}
|
class="btn btn-sm btn-error"
|
||||||
>
|
title={gettext("Mark as unpaid")}
|
||||||
<.icon name="hero-x-circle" class="size-4" />
|
>
|
||||||
{gettext("Unpaid")}
|
<.icon name="hero-x-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Unpaid")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<% end %>
|
||||||
phx-click="delete_cycle"
|
<%= if @can_destroy_cycle do %>
|
||||||
phx-value-cycle_id={cycle.id}
|
<button
|
||||||
phx-target={@myself}
|
type="button"
|
||||||
class="btn btn-sm btn-error btn-outline"
|
phx-click="delete_cycle"
|
||||||
title={gettext("Delete cycle")}
|
phx-value-cycle_id={cycle.id}
|
||||||
>
|
phx-target={@myself}
|
||||||
<.icon name="hero-trash" class="size-4" />
|
class="btn btn-sm btn-error btn-outline"
|
||||||
{gettext("Delete")}
|
title={gettext("Delete cycle")}
|
||||||
</button>
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete")}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
# Get available fee types (filtered to same interval if member has a type)
|
# Get available fee types (filtered to same interval if member has a type)
|
||||||
available_fee_types = get_available_fee_types(member, actor)
|
available_fee_types = get_available_fee_types(member, actor)
|
||||||
|
|
||||||
|
# Permission flags for cycle actions (so read_only does not see create/update/destroy UI)
|
||||||
|
can_create_cycle = can?(actor, :create, MembershipFeeCycle)
|
||||||
|
can_destroy_cycle = can?(actor, :destroy, MembershipFeeCycle)
|
||||||
|
can_update_cycle = can?(actor, :update, MembershipFeeCycle)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:cycles, cycles)
|
||||||
|> assign(:available_fee_types, available_fee_types)
|
|> assign(:available_fee_types, available_fee_types)
|
||||||
|
|> assign(:can_create_cycle, can_create_cycle)
|
||||||
|
|> assign(:can_destroy_cycle, can_destroy_cycle)
|
||||||
|
|> assign(:can_update_cycle, can_update_cycle)
|
||||||
|> assign_new(:interval_warning, fn -> nil end)
|
|> assign_new(:interval_warning, fn -> nil end)
|
||||||
|> assign_new(:editing_cycle, fn -> nil end)
|
|> assign_new(:editing_cycle, fn -> nil end)
|
||||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||||
|
|
@ -554,55 +572,45 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("regenerate_cycles", _params, socket) do
|
def handle_event("regenerate_cycles", _params, socket) do
|
||||||
actor = current_actor(socket)
|
# Button is only shown when can_create_cycle (normal_user and admin). Cycle generation uses system actor.
|
||||||
|
socket = assign(socket, :regenerating, true)
|
||||||
|
member = socket.assigns.member
|
||||||
|
|
||||||
# SECURITY: Only admins can manually regenerate cycles via UI
|
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||||
# Cycle generation itself uses system actor, but UI access should be restricted
|
{:ok, _new_cycles, _notifications} ->
|
||||||
if actor.role && actor.role.permission_set_name == "admin" do
|
actor = current_actor(socket)
|
||||||
socket = assign(socket, :regenerating, true)
|
|
||||||
member = socket.assigns.member
|
|
||||||
|
|
||||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
updated_member =
|
||||||
{:ok, _new_cycles, _notifications} ->
|
member
|
||||||
# Reload member with cycles
|
|> Ash.load!(
|
||||||
actor = current_actor(socket)
|
[
|
||||||
|
:membership_fee_type,
|
||||||
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
updated_member =
|
cycles =
|
||||||
member
|
Enum.sort_by(
|
||||||
|> Ash.load!(
|
updated_member.membership_fee_cycles || [],
|
||||||
[
|
& &1.cycle_start,
|
||||||
:membership_fee_type,
|
{:desc, Date}
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
)
|
||||||
],
|
|
||||||
actor: actor
|
|
||||||
)
|
|
||||||
|
|
||||||
cycles =
|
send(self(), {:member_updated, updated_member})
|
||||||
Enum.sort_by(
|
|
||||||
updated_member.membership_fee_cycles || [],
|
|
||||||
& &1.cycle_start,
|
|
||||||
{:desc, Date}
|
|
||||||
)
|
|
||||||
|
|
||||||
send(self(), {:member_updated, updated_member})
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:member, updated_member)
|
||||||
|
|> assign(:cycles, cycles)
|
||||||
|
|> assign(:regenerating, false)
|
||||||
|
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
||||||
|
|
||||||
{:noreply,
|
{:error, error} ->
|
||||||
socket
|
{:noreply,
|
||||||
|> assign(:member, updated_member)
|
socket
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:regenerating, false)
|
||||||
|> assign(:regenerating, false)
|
|> put_flash(:error, format_error(error))}
|
||||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:regenerating, false)
|
|
||||||
|> put_flash(:error, format_error(error))}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -940,6 +948,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_error(%Ash.Error.Forbidden{}) do
|
||||||
|
gettext("You are not allowed to perform this action.")
|
||||||
|
end
|
||||||
|
|
||||||
defp format_error(error) when is_binary(error), do: error
|
defp format_error(error) when is_binary(error), do: error
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
defp format_error(_error), do: gettext("An error occurred")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,4 +274,65 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
assert html =~ member.first_name
|
assert html =~ member.first_name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
||||||
|
@tag role: :read_only
|
||||||
|
test "read_only does not see Regenerate Cycles, Delete All Cycles, or Create Cycle buttons",
|
||||||
|
%{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||||
|
_cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
refute has_element?(view, "button[phx-click='regenerate_cycles']")
|
||||||
|
refute has_element?(view, "button[phx-click='delete_all_cycles']")
|
||||||
|
refute has_element?(view, "button[phx-click='open_create_cycle_modal']")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "read_only does not see Paid, Unpaid, Suspended, or Delete buttons in cycles table", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||||
|
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
# Row action buttons must not be present for read_only
|
||||||
|
refute has_element?(view, "button[phx-click='mark_cycle_status']")
|
||||||
|
refute has_element?(view, "button[phx-click='delete_cycle']")
|
||||||
|
# Sanity: cycle row is present (read is allowed)
|
||||||
|
assert has_element?(view, "tr[id='cycle-#{cycle.id}']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only cannot delete all cycles (policy enforced via Ash.destroy)" do
|
||||||
|
@tag role: :read_only
|
||||||
|
test "confirm_delete_all_cycles returns error for read_only user", %{
|
||||||
|
current_user: read_only_user
|
||||||
|
} do
|
||||||
|
# Backend policy test: read_only cannot destroy any cycle.
|
||||||
|
# The UI hides the Delete All button for read_only; this test ensures
|
||||||
|
# that if the handler were triggered (e.g. via dev tools), the server
|
||||||
|
# would enforce policy and return Forbidden.
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||||
|
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Forbidden{}} =
|
||||||
|
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue