From c544cdc07c6b607372345fef867f5dfdaee684ed Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 9 Jan 2026 00:02:19 +0100 Subject: [PATCH] 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) --- lib/mv_web/live/member_live/form.ex | 82 +++++++++----- lib/mv_web/live/member_live/index.ex | 16 ++- lib/mv_web/live/member_live/show.ex | 9 +- .../show/membership_fees_component.ex | 103 ++++++++++++------ priv/gettext/de/LC_MESSAGES/default.po | 5 + priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 5 + 7 files changed, 160 insertions(+), 65 deletions(-) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 0a05e1f..c7c718e 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index fff5517..07398af 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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 diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 997cb1a..7917e43 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -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 @@ -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 %> @@ -233,12 +236,14 @@ defmodule MvWeb.MemberLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do + actor = socket.assigns[:current_user] + # 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!() + |> Ash.read!(actor: actor) end) query = @@ -251,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) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index f96fd73..ae5151c 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -390,6 +390,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 = @@ -403,7 +404,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 @@ -424,7 +425,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}) @@ -432,7 +435,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"))} @@ -443,7 +449,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 = @@ -461,15 +468,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( @@ -484,7 +498,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."))} @@ -505,7 +522,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) @@ -539,12 +558,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( @@ -574,7 +598,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 @@ -591,9 +616,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) @@ -618,7 +645,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 @@ -629,8 +657,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)) @@ -701,12 +730,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( @@ -788,15 +822,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( @@ -844,11 +883,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 @@ -860,12 +899,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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f1dc9e9..f415aba 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1997,6 +1997,11 @@ msgstr "Rolle erfolgreich gespeichert" msgid "System roles cannot be deleted." 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/show.ex #~ #: lib/mv_web/translations/member_fields.ex diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 03dad7f..32c2901 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1997,3 +1997,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "System roles cannot be deleted." msgstr "" + +#: lib/mv_web/live/member_live/form.ex +#, elixir-autogen, elixir-format +msgid "You do not have permission to %{action} members." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 8fd50a6..82b2935 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1998,6 +1998,11 @@ msgstr "" msgid "System roles cannot be deleted." 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"