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 34928cd..d676369 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 5aa4d93..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
@@ -148,9 +150,9 @@ defmodule MvWeb.MemberLive.Show do
<%!-- Custom Fields Section --%>
- <%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %>
+ <%= if Enum.any?(@custom_fields) do %>
- <.section_box title={gettext("Additional Data Fields")}>
+ <.section_box title={gettext("Custom Fields")}>
<%= 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 %>
@@ -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)
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 d8c49eb..0b32efe 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
@@ -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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 182a428..369d014 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -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
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 1be771a..681a498 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 8b01f61..7ca3c1e 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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"