Integrate Member policies in LiveViews

- Add on_mount hook to ensure user role is loaded in all Member LiveViews
- Pass actor parameter to all Ash operations (read, get, create, update, destroy, load)
This commit is contained in:
Moritz 2026-01-09 00:02:19 +01:00
parent a42fc8a6eb
commit c544cdc07c
Signed by: moritz
GPG key ID: 1020A035E5DD0824
7 changed files with 160 additions and 65 deletions

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -222,6 +224,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = socket.assigns[:current_user] || socket.assigns.current_user
{:ok, custom_fields} = Mv.Membership.list_custom_fields() {:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_custom_field_values = initial_custom_field_values =
@ -239,14 +243,14 @@ defmodule MvWeb.MemberLive.Form do
member = member =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type]) id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
end end
page_title = page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types # Load available membership fee types
available_fee_types = load_available_fee_types(member) available_fee_types = load_available_fee_types(member, actor)
{:ok, {:ok,
socket socket
@ -283,35 +287,59 @@ defmodule MvWeb.MemberLive.Form do
end end
def handle_event("save", %{"member" => member_params}, socket) do def handle_event("save", %{"member" => member_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do try do
{:ok, member} -> actor = socket.assigns[:current_user] || socket.assigns.current_user
notify_parent({:saved, member})
action = case AshPhoenix.Form.submit(socket.assigns.form, params: member_params, actor: actor) do
case socket.assigns.form.source.type do {:ok, member} ->
:create -> gettext("create") handle_save_success(socket, member)
:update -> gettext("update")
other -> to_string(other)
end
socket = {:error, form} ->
socket {:noreply, assign(socket, form: form)}
|> put_flash(:info, gettext("Member %{action} successfully", action: action)) end
|> push_navigate(to: return_path(socket.assigns.return_to, member)) rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
{:noreply, socket} handle_save_forbidden(socket)
{:error, form} ->
{:noreply, assign(socket, form: form)}
end end
end end
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
action = get_action_name(socket.assigns.form.source.type)
socket =
socket
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
end
defp handle_save_forbidden(socket) do
# Handle policy violations that aren't properly displayed in forms
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
action = get_action_name(socket.assigns.form.source.type)
error_message =
gettext("You do not have permission to %{action} members.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end
defp get_action_name(:create), do: gettext("create")
defp get_action_name(:update), do: gettext("update")
defp get_action_name(other), do: to_string(other)
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{member: member}} = socket) do defp assign_form(%{assigns: assigns} = socket) do
member = assigns.member
actor = assigns[:current_user] || assigns.current_user
form = form =
if member do if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field]) {:ok, member} = Ash.load(member, custom_field_values: [:custom_field], actor: actor)
existing_custom_field_values = existing_custom_field_values =
member.custom_field_values member.custom_field_values
@ -342,7 +370,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: params, params: params,
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
missing_custom_field_values = missing_custom_field_values =
@ -360,7 +389,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]}, params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
end end
@ -375,11 +405,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp load_available_fee_types(member) do defp load_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees) |> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval # If member has a fee type, filter to same interval
if member && member.membership_fee_type do if member && member.membership_fee_type do

View file

@ -27,6 +27,8 @@ defmodule MvWeb.MemberLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
@ -58,17 +60,19 @@ defmodule MvWeb.MemberLive.Index do
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors # and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing. # should be visible to the user rather than silently failing.
actor = socket.assigns[:current_user]
custom_fields_visible = custom_fields_visible =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true)) |> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields) # Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields = all_custom_fields =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries # Load settings once to avoid N+1 queries
settings = settings =
@ -132,8 +136,9 @@ defmodule MvWeb.MemberLive.Index do
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied) # This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id) actor = socket.assigns[:current_user]
Ash.destroy!(member) member = Ash.get!(Mv.Membership.Member, id, actor: actor)
Ash.destroy!(member, actor: actor)
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)} {:noreply, assign(socket, :members, updated_members)}
@ -678,7 +683,8 @@ defmodule MvWeb.MemberLive.Index do
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView # Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews # This is appropriate for data loading in LiveViews
members = Ash.read!(query) actor = socket.assigns[:current_user]
members = Ash.read!(query, actor: actor)
# Custom field values are already filtered at the database level in load_custom_field_values/2 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Query import Ash.Query
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -220,6 +222,7 @@ defmodule MvWeb.MemberLive.Show do
module={MvWeb.MemberLive.Show.MembershipFeesComponent} module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -233,12 +236,14 @@ defmodule MvWeb.MemberLive.Show do
@impl true @impl true
def handle_params(%{"id" => id}, _, socket) do def handle_params(%{"id" => id}, _, socket) do
actor = socket.assigns[:current_user]
# Load custom fields once using assign_new to avoid repeated queries # Load custom fields once using assign_new to avoid repeated queries
socket = socket =
assign_new(socket, :custom_fields, fn -> assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
end) end)
query = query =
@ -251,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ])
member = Ash.read_one!(query) member = Ash.read_one!(query, actor: actor)
# Calculate last and current cycle status from loaded cycles # Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member) last_cycle_status = get_last_cycle_status(member)

View file

@ -390,6 +390,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
member = assigns.member member = assigns.member
actor = assigns.current_user
# Load cycles if not already loaded # Load cycles if not already loaded
cycles = cycles =
@ -403,7 +404,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date}) cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# 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) available_fee_types = get_available_fee_types(member, actor)
{:ok, {:ok,
socket socket
@ -424,7 +425,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true @impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type # Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do actor = socket.assigns.current_user
case update_member_fee_type(socket.assigns.member, nil, actor) do
{:ok, updated_member} -> {:ok, updated_member} ->
send(self(), {:member_updated, updated_member}) send(self(), {:member_updated, updated_member})
@ -432,7 +435,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, []) |> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member)) |> assign(
:available_fee_types,
get_available_fee_types(updated_member, socket.assigns.current_user)
)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))} |> put_flash(:info, gettext("Membership fee type removed"))}
@ -443,7 +449,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees) actor = socket.assigns.current_user
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
# Check if interval matches # Check if interval matches
interval_warning = interval_warning =
@ -461,15 +468,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)} {:noreply, assign(socket, :interval_warning, interval_warning)}
else else
case update_member_fee_type(member, fee_type_id) do actor = socket.assigns.current_user
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} -> {:ok, updated_member} ->
# Reload member with cycles # Reload member with cycles
actor = socket.assigns.current_user
updated_member = updated_member =
updated_member updated_member
|> Ash.load!([ |> Ash.load!(
:membership_fee_type, [
membership_fee_cycles: [:membership_fee_type] :membership_fee_type,
]) membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -484,7 +498,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, cycles) |> assign(:cycles, cycles)
|> assign(:available_fee_types, get_available_fee_types(updated_member)) |> assign(
:available_fee_types,
get_available_fee_types(updated_member, socket.assigns.current_user)
)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -505,7 +522,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended :suspended -> :mark_as_suspended
end end
case Ash.update(cycle, action: action, domain: MembershipFees) do actor = socket.assigns.current_user
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} -> {:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle) updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -539,12 +558,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case CycleGenerator.generate_cycles_for_member(member.id) do case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} -> {:ok, _new_cycles, _notifications} ->
# Reload member with cycles # Reload member with cycles
actor = socket.assigns.current_user
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
:membership_fee_type, [
membership_fee_cycles: [:membership_fee_type] :membership_fee_type,
]) membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -574,7 +598,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display # Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type) actor = socket.assigns.current_user
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :editing_cycle, cycle)} {:noreply, assign(socket, :editing_cycle, cycle)}
end end
@ -591,9 +616,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case Decimal.parse(normalized_amount_str) do case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) -> {amount, _} when is_struct(amount, Decimal) ->
actor = socket.assigns.current_user
case cycle case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount}) |> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do |> Ash.update(domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} -> {:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle) updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -618,7 +645,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display # Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type) actor = socket.assigns.current_user
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :deleting_cycle, cycle)} {:noreply, assign(socket, :deleting_cycle, cycle)}
end end
@ -629,8 +657,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
actor = socket.assigns.current_user
case Ash.destroy(cycle, domain: MembershipFees) do case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok -> :ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id)) updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -701,12 +730,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if deleted_count > 0 do if deleted_count > 0 do
# Reload member to get updated cycles # Reload member to get updated cycles
actor = socket.assigns.current_user
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
:membership_fee_type, [
membership_fee_cycles: [:membership_fee_type] :membership_fee_type,
]) membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles = updated_cycles =
Enum.sort_by( Enum.sort_by(
@ -788,15 +822,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
membership_fee_type_id: member.membership_fee_type_id membership_fee_type_id: member.membership_fee_type_id
} }
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do actor = socket.assigns.current_user
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
{:ok, _new_cycle} -> {:ok, _new_cycle} ->
# Reload member with cycles # Reload member with cycles
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
:membership_fee_type, [
membership_fee_cycles: [:membership_fee_type] :membership_fee_type,
]) membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -844,11 +883,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions # Helper functions
defp get_available_fee_types(member) do defp get_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval # If member has a fee type, filter to same interval
if member.membership_fee_type do if member.membership_fee_type do
@ -860,12 +899,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end end
end end
defp update_member_fee_type(member, fee_type_id) do defp update_member_fee_type(member, fee_type_id, actor) do
attrs = %{membership_fee_type_id: fee_type_id} attrs = %{membership_fee_type_id: fee_type_id}
member member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership) |> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership) |> Ash.update(domain: Membership, actor: actor)
end end
defp find_cycle(cycles, cycle_id) do defp find_cycle(cycles, cycle_id) do

View file

@ -1997,6 +1997,11 @@ msgstr "Rolle erfolgreich gespeichert"
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
msgstr "System-Rollen können nicht gelöscht werden." msgstr "System-Rollen können nicht gelöscht werden."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to %{action} members."
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex #~ #: lib/mv_web/translations/member_fields.ex

View file

@ -1997,3 +1997,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to %{action} members."
msgstr ""

View file

@ -1998,6 +1998,11 @@ msgstr ""
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to %{action} members."
msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex #~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses" #~ msgid "All payment statuses"