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
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
require Ash.Query
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees
|
||||
|
|
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Action Buttons --%>
|
||||
<%!-- Action Buttons (only when user has permission) --%>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<.button
|
||||
:if={@can_create_cycle}
|
||||
phx-click="regenerate_cycles"
|
||||
phx-target={@myself}
|
||||
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"))}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@cycles)}
|
||||
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
{gettext("Delete All Cycles")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={@member.membership_fee_type}
|
||||
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||
phx-click="open_create_cycle_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-primary"
|
||||
|
|
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Amount")}>
|
||||
<span
|
||||
class="font-mono cursor-pointer hover:text-primary"
|
||||
phx-click="edit_cycle_amount"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
title={gettext("Click to edit amount")}
|
||||
>
|
||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||
</span>
|
||||
<%= if @can_update_cycle do %>
|
||||
<span
|
||||
class="font-mono cursor-pointer hover:text-primary"
|
||||
phx-click="edit_cycle_amount"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
title={gettext("Click to edit amount")}
|
||||
>
|
||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Status")}>
|
||||
|
|
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
<:action :let={cycle}>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
<%= if @can_update_cycle do %>
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @can_destroy_cycle do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</:action>
|
||||
</.table>
|
||||
|
|
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
# Get available fee types (filtered to same interval if member has a type)
|
||||
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,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:cycles, cycles)
|
||||
|> 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(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|
|
@ -554,55 +572,45 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
end
|
||||
|
||||
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
|
||||
# Cycle generation itself uses system actor, but UI access should be restricted
|
||||
if actor.role && actor.role.permission_set_name == "admin" do
|
||||
socket = assign(socket, :regenerating, true)
|
||||
member = socket.assigns.member
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
actor = current_actor(socket)
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
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,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:regenerating, false)
|
||||
|> 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"))}
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -940,6 +948,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
Enum.map_join(error.errors, ", ", fn e -> e.message 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), do: gettext("An error occurred")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue