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 dc3268cbf4
commit bc87893134
Signed by: moritz
GPG key ID: 1020A035E5DD0824
7 changed files with 167 additions and 74 deletions

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@ -222,6 +224,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true
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()
initial_custom_field_values =
@ -239,14 +243,14 @@ defmodule MvWeb.MemberLive.Form do
member =
case params["id"] do
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
page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
available_fee_types = load_available_fee_types(member)
available_fee_types = load_available_fee_types(member, actor)
{:ok,
socket
@ -283,35 +287,59 @@ defmodule MvWeb.MemberLive.Form do
end
def handle_event("save", %{"member" => member_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
{:ok, member} ->
notify_parent({:saved, member})
try do
actor = socket.assigns[:current_user] || socket.assigns.current_user
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params, actor: actor) do
{:ok, member} ->
handle_save_success(socket, member)
socket =
socket
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
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 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 =
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 =
member.custom_field_values
@ -342,7 +370,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: params,
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
missing_custom_field_values =
@ -360,7 +389,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
end
@ -375,11 +405,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions
# -----------------------------------------------------------------
defp load_available_fee_types(member) do
defp load_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> 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 && member.membership_fee_type do

View file

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

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view
import Ash.Query
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -148,9 +150,9 @@ defmodule MvWeb.MemberLive.Show do
</div>
<%!-- Custom Fields Section --%>
<%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Additional Data Fields")}>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
@ -220,6 +222,7 @@ defmodule MvWeb.MemberLive.Show do
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
/>
<% end %>
</Layouts.app>
@ -233,15 +236,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
# Load custom fields for display
# Note: Each page load starts a new LiveView process, so caching with
# assign_new is not necessary here (mount creates a fresh socket each time)
custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
actor = socket.assigns[:current_user]
socket = assign(socket, :custom_fields, custom_fields)
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
end)
query =
Mv.Membership.Member
@ -253,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do
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
last_cycle_status = get_last_cycle_status(member)

View file

@ -388,6 +388,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def update(assigns, socket) do
member = assigns.member
actor = assigns.current_user
# Load cycles if not already loaded
cycles =
@ -401,7 +402,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# 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,
socket
@ -422,7 +423,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# 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} ->
send(self(), {:member_updated, updated_member})
@ -430,7 +433,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> 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)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -441,7 +447,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
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
interval_warning =
@ -459,15 +466,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
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} ->
# Reload member with cycles
actor = socket.assigns.current_user
updated_member =
updated_member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -482,7 +496,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> 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)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -503,7 +520,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended
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} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -537,12 +556,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = socket.assigns.current_user
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -572,7 +596,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# 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)}
end
@ -589,9 +614,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
actor = socket.assigns.current_user
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do
|> Ash.update(domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -616,7 +643,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# 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)}
end
@ -627,8 +655,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
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 ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -699,12 +728,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if deleted_count > 0 do
# Reload member to get updated cycles
actor = socket.assigns.current_user
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles =
Enum.sort_by(
@ -786,15 +820,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
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} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -842,11 +881,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions
defp get_available_fee_types(member) do
defp get_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> 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.membership_fee_type do
@ -858,12 +897,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
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}
member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership)
|> Ash.update(domain: Membership, actor: actor)
end
defp find_cycle(cycles, cycle_id) do

View file

@ -2125,6 +2125,11 @@ msgstr "Zusätzliche Datenfelder"
#~ msgid "Pending"
#~ msgstr "Ausstehend"
#: 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/show.ex
#~ #: lib/mv_web/translations/member_fields.ex

View file

@ -2063,3 +2063,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Additional Data Fields"
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

@ -2064,6 +2064,11 @@ msgstr ""
msgid "Additional Data Fields"
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
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"