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:
Moritz 2026-02-03 23:52:24 +01:00
parent 8ec4a07103
commit e3bea17827
4 changed files with 219 additions and 123 deletions

View file

@ -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 %>

View file

@ -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")

View file

@ -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")

View file

@ -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