Secure regenerate_cycles: require can?(:create, MembershipFeeCycle) in handler

- Handler returns flash error when non-admin triggers event (e.g. DevTools).
- Test: read_only cannot create MembershipFeeCycle so handler rejects.
This commit is contained in:
Moritz 2026-02-04 09:19:37 +01:00
parent 03d3a7eb1b
commit dbd0a57292
2 changed files with 55 additions and 33 deletions

View file

@ -568,45 +568,54 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
# 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
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
actor = current_actor(socket)
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
actor = current_actor(socket)
if can?(actor, :create, MembershipFeeCycle) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
actor = current_actor(socket)
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
send(self(), {:member_updated, updated_member})
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
send(self(), {:member_updated, updated_member})
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
{: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
|> assign(:regenerating, false)
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
end