Merge branch 'main' into feature/export_csv
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-06 08:02:05 +01:00
commit 36e57b24be
102 changed files with 5332 additions and 1219 deletions

View file

@ -177,7 +177,8 @@ defmodule MvWeb.MemberLive.Form do
phx-change="validate"
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 %>
<option
value={fee_type.id}
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
</option>
<% end %>
</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>
<% end %>
<%= if @interval_warning do %>

View file

@ -37,9 +37,11 @@
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<% end %>
</:actions>
</.header>
@ -98,6 +100,7 @@
<.table
id="members"
rows={@members}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
@ -312,16 +315,23 @@
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={member}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="member-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
<%= if can?(@current_user, :update, @member) do %>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit Member")}
</.button>
<% end %>
</div>
<%!-- Tab Navigation --%>
@ -119,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
@ -281,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
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(:edit), do: gettext("Edit Member")

View file

@ -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={@member.membership_fee_type != nil and @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)
@ -439,7 +457,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, [])
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -470,13 +488,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
updated_member
|> Ash.load!(
@ -502,7 +516,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, cycles)
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -554,17 +568,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
actor = current_actor(socket)
# 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
if can?(actor, :create, MembershipFeeCycle) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
else
{:noreply,
socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|> assign(:regenerating, false)
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
end
@ -722,61 +735,31 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
expected = String.downcase(gettext("Yes"))
if confirmation != expected do
if confirmation == expected do
member = socket.assigns.member
actor = current_actor(socket)
cycles = socket.assigns.cycles
reset_modal = fn s ->
s
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
end
if can?(actor, :destroy, MembershipFeeCycle) do
do_delete_all_cycles(socket, member, actor, cycles, reset_modal)
else
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Confirmation text does not match"))}
else
member = socket.assigns.member
# Delete all cycles atomically using Ecto query
import Ecto.Query
deleted_count =
Mv.Repo.delete_all(
from c in Mv.MembershipFees.MembershipFeeCycle,
where: c.member_id == ^member.id
)
if deleted_count > 0 do
# Reload member to get updated cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles =
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, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("No cycles to delete"))}
end
end
end
@ -895,6 +878,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions
defp do_delete_all_cycles(socket, member, actor, cycles, reset_modal) do
result =
Enum.reduce_while(cycles, {:ok, 0}, fn cycle, {:ok, count} ->
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok -> {:cont, {:ok, count + 1}}
{:ok, _} -> {:cont, {:ok, count + 1}}
{:error, error} -> {:halt, {:error, error}}
end
end)
case result do
{:ok, deleted_count} when deleted_count > 0 ->
updated_member =
member
|> Ash.load!(
[:membership_fee_type, membership_fee_cycles: [:membership_fee_type]],
actor: actor
)
updated_cycles =
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, updated_cycles)
|> reset_modal.()
|> put_flash(:info, gettext("All cycles deleted"))}
{:ok, _} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:info, gettext("No cycles to delete"))}
{:error, error} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(error))}
end
end
defp get_available_fee_types(member, actor) do
all_types =
MembershipFeeType
@ -940,6 +972,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")