CustomField Resource Policies closes #386 #387

Merged
moritz merged 6 commits from feature/386_customfield_policy into main 2026-01-29 16:17:12 +01:00
5 changed files with 67 additions and 19 deletions
Showing only changes of commit 5a2f035ecc - Show all commits

View file

@ -486,6 +486,24 @@ defmodule Mv.Membership.Member do
build_custom_field_validation_error(missing_fields) build_custom_field_validation_error(missing_fields)
end end
{:error, %Ash.Error.Forbidden{}} ->
Logger.warning(
"Required custom fields validation: actor not authorized to read CustomField"
)
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, :missing_actor} ->
Logger.warning("Required custom fields validation: no actor in context")
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, error} -> {:error, error} ->
Logger.error( Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed." "Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."

View file

@ -14,7 +14,7 @@ defmodule Mv.Membership do
The domain exposes these main actions: The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc. - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3` - Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1` - Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1` - Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
@ -156,7 +156,7 @@ defmodule Mv.Membership do
This is an optimized version that filters at the database level instead of This is an optimized version that filters at the database level instead of
loading all custom fields and filtering in memory. Requires an actor for loading all custom fields and filtering in memory. Requires an actor for
authorization (CustomField read policy). authorization (CustomField read policy). Callers must pass `actor:`; no default.
## Options ## Options
@ -166,22 +166,26 @@ defmodule Mv.Membership do
## Returns ## Returns
- `{:ok, required_custom_fields}` - List of required custom fields - `{:ok, required_custom_fields}` - List of required custom fields
- `{:error, error}` - Error reading custom fields (e.g. Forbidden when no actor) - `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
## Examples ## Examples
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor) iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
iex> Enum.all?(required_fields, & &1.required) iex> Enum.all?(required_fields, & &1.required)
true true
"""
def list_required_custom_fields(opts \\ []) do
actor = Keyword.get(opts, :actor)
iex> Mv.Membership.list_required_custom_fields(actor: nil)
{:error, :missing_actor}
"""
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true)) |> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__, actor: actor) |> Ash.read(domain: __MODULE__, actor: actor)
end end
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
@doc """ @doc """
Updates the member field visibility configuration. Updates the member field visibility configuration.

View file

@ -12,6 +12,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
""" """
use MvWeb, :live_component use MvWeb, :live_component
require Logger
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
@ -177,10 +179,18 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
""" """
end end
defp stream_custom_fields(actor) do defp stream_custom_fields(actor, parent_pid) do
case Ash.read(Mv.Membership.CustomField, actor: actor) do case Ash.read(Mv.Membership.CustomField, actor: actor) do
{:ok, custom_fields} -> custom_fields {:ok, custom_fields} ->
{:error, _} -> [] custom_fields
{:error, error} ->
Logger.warning(
"CustomFieldLive.IndexComponent: failed to load custom fields: #{inspect(error)}"
)
send(parent_pid, {:custom_fields_load_error, error})
[]
end end
end end
@ -215,7 +225,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign_new(:show_delete_modal, fn -> false end) |> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end) |> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end) |> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, stream_custom_fields(assigns[:actor]), reset: true)} |> stream(:custom_fields, stream_custom_fields(assigns[:actor], self()), reset: true)}
end end
@impl true @impl true

View file

@ -504,6 +504,15 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))} {:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end end
def handle_info({:custom_fields_load_error, _error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Could not load data fields. Please check your permissions.")
)}
end
@impl true @impl true
def handle_info({:editing_section_changed, section}, socket) do def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)} {:noreply, assign(socket, :active_editing_section, section)}

View file

@ -381,12 +381,16 @@ Enum.each(member_attrs_list, fn member_attrs ->
final_member = final_member =
if is_nil(member.membership_fee_type_id) and if is_nil(member.membership_fee_type_id) and
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
member {:ok, updated} =
|> Ash.Changeset.for_update(:update_member, %{ Membership.update_member(
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id member,
}) %{
|> Ash.Changeset.put_context(:actor, admin_user_with_role) membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|> Ash.update!(actor: admin_user_with_role) },
actor: admin_user_with_role
)
updated
else else
member member
end end
@ -546,9 +550,12 @@ Enum.with_index(linked_members)
fee_type_index = rem(3 + index, length(all_fee_types)) fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index) fee_type = Enum.at(all_fee_types, fee_type_index)
member {:ok, updated} =
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) Membership.update_member(member, %{membership_fee_type_id: fee_type.id},
|> Ash.update!(actor: admin_user_with_role) actor: admin_user_with_role
)
updated
else else
member member
end end