Merge branch 'main' into feature/335_csv_import_ui
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-01-23 10:33:56 +01:00
commit 465fe5a5b1
80 changed files with 4742 additions and 6541 deletions

View file

@ -1,6 +1,15 @@
defmodule Mv.Accounts do
@moduledoc """
AshAuthentication specific domain to handle Authentication for users.
## Resources
- `User` - User accounts with authentication methods (password, OIDC)
- `Token` - Session tokens for authentication
## Public API
The domain exposes these main actions:
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]

View file

@ -1,6 +1,10 @@
defmodule Mv.Accounts.Token do
@moduledoc """
AshAuthentication specific ressource
AshAuthentication Token Resource for session management.
This resource is used by AshAuthentication to manage authentication tokens
for user sessions. Tokens are automatically created and managed by the
authentication system.
"""
use Ash.Resource,
data_layer: AshPostgres.DataLayer,

View file

@ -5,9 +5,8 @@ defmodule Mv.Accounts.User do
use Ash.Resource,
domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication]
# authorizers: [Ash.Policy.Authorizer]
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
postgres do
table "users"
@ -267,6 +266,36 @@ defmodule Mv.Accounts.User do
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
authorize_if always()
end
# NoActor bypass (test fixtures only, see no_actor.ex)
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# READ bypass for list queries (scope :own via expr)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Global validations - applied to all relevant actions
validations do
# Password strength policy: minimum 8 characters for all password-related actions

View file

@ -42,25 +42,29 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
if email && oidc_id && user_info do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
existing_oidc_user =
case Mv.Accounts.User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
_ -> nil
end
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
check_email_collision(email, oidc_id, user_info, existing_oidc_user, system_actor)
else
:ok
end
end
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
case Mv.Accounts.User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
# No user exists with this email - OK to create new user
:ok

View file

@ -124,8 +124,9 @@ defmodule Mv.Membership.Member do
case result do
{:ok, member} ->
if member.membership_fee_type_id && member.join_date do
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -196,16 +197,12 @@ defmodule Mv.Membership.Member do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
actor = Map.get(changeset.context, :actor)
case regenerate_cycles_on_type_change(member, actor: actor) do
case regenerate_cycles_on_type_change(member) do
{:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
@ -230,8 +227,9 @@ defmodule Mv.Membership.Member do
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -409,8 +407,16 @@ defmodule Mv.Membership.Member do
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database
# Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# Check if authorization is disabled in the parent operation's context
# Access private context where authorize? flag is stored
authorize? =
case get_in(changeset.context, [:private, :authorize?]) do
false -> false
_ -> true
end
# Pass actor and authorize? to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor, authorize?: authorize?) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
@ -790,37 +796,37 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits
@doc false
def regenerate_cycles_on_type_change(member, opts \\ []) do
# Uses system actor for cycle regeneration (mandatory side effect)
def regenerate_cycles_on_type_change(member, _opts \\ []) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
today = Date.utc_today()
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
regenerate_cycles_new_transaction(member, today, lock_key)
end
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
@ -838,11 +844,16 @@ defmodule Mv.Membership.Member do
# Performs the actual cycle deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
# notifications are collected to be sent after transaction commits
# Uses system actor for all operations
defp do_regenerate_cycles_on_type_change(member, today, opts) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
system_actor = SystemActor.get_system_actor()
actor_opts = Helpers.ash_actor_opts(system_actor)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
@ -852,20 +863,16 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
result =
if actor do
Ash.read(all_unpaid_cycles_query, actor: actor)
else
Ash.read(all_unpaid_cycles_query)
end
case result do
case Ash.read(all_unpaid_cycles_query, actor_opts) do
{:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
skip_lock?: skip_lock?,
actor: actor
delete_and_regenerate_cycles(
cycles_to_delete,
member.id,
today,
actor_opts,
skip_lock?: skip_lock?
)
{:error, reason} ->
@ -893,26 +900,27 @@ defmodule Mv.Membership.Member do
# Deletes future cycles and regenerates them with the new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
# Uses system actor for cycle generation and deletion
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
case delete_cycles(cycles_to_delete, actor_opts) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
{:error, reason} -> {:error, reason}
end
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
defp delete_cycles(cycles_to_delete) do
# Uses system actor for authorization to ensure deletion always works
defp delete_cycles(cycles_to_delete, actor_opts) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle)
Ash.destroy(cycle, actor_opts)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
@ -928,13 +936,11 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?,
actor: actor
skip_lock?: skip_lock?
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
@ -948,49 +954,57 @@ defmodule Mv.Membership.Member do
# based on environment (test vs production)
# This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks
# Uses system actor for cycle generation (mandatory side effect)
# Captures initiator for audit trail (if available in opts)
defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
initiator = Keyword.get(opts, :initiator)
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member, actor: actor)
handle_cycle_generation_sync(member, initiator)
else
handle_cycle_generation_async(member, actor: actor)
handle_cycle_generation_async(member, initiator)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, opts) do
require Logger
actor = Keyword.get(opts, :actor)
defp handle_cycle_generation_sync(member, initiator) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
actor: actor
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: true)
log_cycle_generation_success(member, cycles, notifications,
sync: true,
initiator: initiator
)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: true)
log_cycle_generation_error(member, reason, sync: true, initiator: initiator)
end
end
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member, opts) do
actor = Keyword.get(opts, :actor)
defp handle_cycle_generation_async(member, initiator) do
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false)
log_cycle_generation_success(member, cycles, notifications,
sync: false,
initiator: initiator
)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: false)
log_cycle_generation_error(member, reason, sync: false, initiator: initiator)
end
end)
|> Task.await(:infinity)
end
# Sends notifications if any are present
@ -1001,13 +1015,15 @@ defmodule Mv.Membership.Member do
end
# Logs successful cycle generation
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
require Logger
defp log_cycle_generation_success(member, cycles, notifications,
sync: sync?,
initiator: initiator
) do
sync_label = if sync?, do: "", else: " (async)"
initiator_info = get_initiator_info(initiator)
Logger.debug(
"Successfully generated cycles for member#{sync_label}",
"Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})",
member_id: member.id,
cycles_count: length(cycles),
notifications_count: length(notifications)
@ -1015,13 +1031,12 @@ defmodule Mv.Membership.Member do
end
# Logs cycle generation errors
defp log_cycle_generation_error(member, reason, sync: sync?) do
require Logger
defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
sync_label = if sync?, do: "", else: " (async)"
initiator_info = get_initiator_info(initiator)
Logger.error(
"Failed to generate cycles for member#{sync_label}",
"Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})",
member_id: member.id,
member_email: member.email,
error: inspect(reason),
@ -1029,6 +1044,11 @@ defmodule Mv.Membership.Member do
)
end
# Extracts initiator information for audit trail
defp get_initiator_info(nil), do: "system"
defp get_initiator_info(%{email: email}), do: email
defp get_initiator_info(_), do: "unknown"
# Helper to extract error type for structured logging
defp error_type(%{__struct__: struct_name}), do: struct_name
defp error_type(error) when is_atom(error), do: error

View file

@ -12,8 +12,8 @@ defmodule Mv.Membership do
The domain exposes these main actions:
- 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 management: `create_custom_field/1`, `list_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
## Admin Interface
The domain is configured with AshAdmin for management UI.

View file

@ -6,6 +6,13 @@ defmodule Mv.MembershipFees do
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
- `MembershipFeeCycle` - Individual membership fee cycles per member
## Public API
The domain exposes these main actions:
- MembershipFeeType CRUD: `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
- MembershipFeeCycle CRUD: `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`
Note: LiveViews may use direct Ash calls instead of these domain functions for performance or flexibility.
## Overview
This domain handles the complete membership fee lifecycle including:
- Fee type definitions (monthly, quarterly, half-yearly, yearly)

View file

@ -9,6 +9,8 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
"""
use Ash.Resource.Validation
require Logger
@doc """
Validates email uniqueness across linked User-Member pairs.
@ -73,19 +75,29 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
end
defp check_email_uniqueness(email, exclude_member_id) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end

View file

@ -14,6 +14,7 @@ defmodule Mv.Application do
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my},
Mv.Helpers.SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg},
# Start to serve requests, typically the last entry

View file

@ -0,0 +1,99 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
Helper functions for ensuring User actors have required data loaded.
## Actor Invariant
Authorization policies (especially HasPermission) require that the User actor
has their `:role` relationship loaded. This module provides helpers to
ensure this invariant is maintained across all entry points:
- LiveView on_mount hooks
- Plug pipelines
- Background jobs
- Tests
## Scope
This module ONLY handles `Mv.Accounts.User` resources. Other resources with
a `:role` field are ignored (returned as-is). This prevents accidental
authorization bypasses and keeps the logic focused.
## Usage
# In LiveView on_mount
def ensure_user_role_loaded(_name, socket) do
user = Actor.ensure_loaded(socket.assigns[:current_user])
assign(socket, :current_user, user)
end
# In tests
user = Actor.ensure_loaded(user)
## Security Note
`ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
dependency (actor needs role loaded to be authorized, but loading role requires
authorization). This is safe because:
- The actor (User) is loading their OWN role (user.role relationship)
- This load is needed FOR authorization checks to work
- The role itself contains no sensitive data (just permission_set reference)
- The actor is already authenticated (passed auth boundary)
Alternative would be to denormalize permission_set_name on User, but that
adds complexity and potential for inconsistency.
"""
require Logger
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
- If actor is nil, returns nil
- If role is already loaded, returns actor as-is
- If role is %Ash.NotLoaded{}, loads it and returns updated actor
- If actor is not a User, returns as-is (no-op)
## Examples
iex> Actor.ensure_loaded(nil)
nil
iex> Actor.ensure_loaded(%User{role: %Role{}})
%User{role: %Role{}}
iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
%User{role: %Role{}} # role loaded
"""
def ensure_loaded(nil), do: nil
# Only handle Mv.Accounts.User - clear intention, no accidental other resources
def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
load_role(user)
end
def ensure_loaded(actor), do: actor
defp load_role(actor) do
# SECURITY: We skip authorization here because this is a bootstrap scenario:
# - The actor is loading their OWN role (actor.role relationship)
# - This load is needed FOR authorization checks to work (circular dependency)
# - The role itself contains no sensitive data (just permission_set reference)
# - The actor is already authenticated (passed auth boundary)
# Alternative would be to denormalize permission_set_name on User.
case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded_actor} ->
loaded_actor
{:error, error} ->
# Log error but don't crash - fail-closed for authorization
Logger.warning(
"Failed to load actor role: #{inspect(error)}. " <>
"Authorization may fail if role is required."
)
actor
end
end
end

View file

@ -7,7 +7,7 @@ defmodule Mv.Authorization do
## Public API
The domain exposes these main actions:
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
- Role CRUD: `create_role/1`, `list_roles/0`, `get_role/1`, `update_role/2`, `destroy_role/1`
## Admin Interface
The domain is configured with AshAdmin for management UI.

View file

@ -8,10 +8,37 @@ defmodule Mv.Authorization.Checks.HasPermission do
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Important: strict_check Behavior
For filter-based scopes (`:own`, `:linked`):
- **WITH record**: Evaluates filter against record (returns `true`/`false`)
- **WITHOUT record** (queries/lists): Returns `false`
**Why `false` instead of `:unknown`?**
Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check`
returns `:unknown`. To ensure list queries work correctly, resources **MUST** use
bypass policies with `expr()` for READ operations (see `docs/policy-bypass-vs-haspermission.md`).
This means `HasPermission` is **NOT** generically reusable for query authorization
with filter scopes - it requires companion bypass policies.
## Usage Pattern
See `docs/policy-bypass-vs-haspermission.md` for the two-tier pattern:
- **READ**: `bypass` with `expr()` (handles auto_filter)
- **UPDATE/CREATE/DESTROY**: `HasPermission` (handles scope evaluation)
## Usage in Ash Resource
policies do
policy action_type(:read) do
# READ: Bypass for list queries
bypass action_type(:read) do
authorize_if expr(id == ^actor(:id))
end
# UPDATE: HasPermission for scope evaluation
policy action_type([:update, :create, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
@ -34,6 +61,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
All errors result in Forbidden (policy fails).
## Role Loading Fallback
If the actor's `:role` relationship is `%Ash.NotLoaded{}`, this check will
attempt to load it automatically. This provides a fallback if `on_mount` hooks
didn't run (e.g., in non-LiveView contexts).
## Examples
# In a resource policy
@ -83,6 +116,9 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action, record) do
# Ensure role is loaded (fallback if on_mount didn't run)
actor = ensure_role_loaded(actor)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
@ -95,11 +131,25 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource_name
) do
:authorized ->
# For :all scope, authorize directly
{:ok, true}
{:filter, filter_expr} ->
# For strict_check on single records, evaluate the filter against the record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
# For :own/:linked scope:
# - With a record, evaluate filter against record for strict authorization
# - Without a record (queries/lists), return false
#
# NOTE: Returning false here forces the use of expr-based bypass policies.
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
# when strict_check returns :unknown. Instead, resources should use bypass policies
# with expr() directly for filter-based authorization (see User resource).
if record do
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
# No record yet (e.g., read/list queries) - deny at strict_check level
# Resources must use expr-based bypass policies for list filtering
{:ok, false}
end
false ->
{:ok, false}
@ -224,9 +274,18 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Evaluate filter expression for strict_check on single records
# For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"User", %{id: user_id}} when not is_nil(user_id) ->
# Check if this user's ID matches the actor's ID (scope :own)
if user_id == actor.id do
{:ok, true}
else
{:ok, false}
end
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
@ -330,4 +389,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp get_resource_name_for_logging(_resource) do
"unknown"
end
# Fallback: Load role if not loaded (in case on_mount didn't run)
# Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do
Mv.Authorization.Actor.ensure_loaded(actor)
end
end

View file

@ -42,9 +42,9 @@ defmodule Mv.Authorization.Checks.NoActor do
use Ash.Policy.SimpleCheck
# Compile-time check: Only allow no-actor bypass in test environment
@allow_no_actor_bypass Mix.env() == :test
# Alternative (if you want to control via config):
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
# Using compile_env instead of Mix.env() for release-safety
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
@impl true
def describe(_opts) do
@ -58,13 +58,9 @@ defmodule Mv.Authorization.Checks.NoActor do
@impl true
def match?(nil, _context, _opts) do
# Actor is nil
if @allow_no_actor_bypass do
# Test environment: Allow all operations
true
else
# Production/dev: Deny all operations (fail-closed for security)
false
end
# SECURITY: Only allow if compile_env flag is set (test.exs only)
# No runtime Mix.env() check - fail-closed by default (false)
@allow_no_actor_bypass
end
def match?(_actor, _context, _opts) do

View file

@ -95,7 +95,9 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do
%{
resources: [
# User: Can always read/update own credentials
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -125,6 +127,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -157,6 +161,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},

View file

@ -41,10 +41,8 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
actor = Map.get(changeset.context, :actor)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member, actor) do
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result

View file

@ -33,17 +33,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
if Map.get(context, :syncing_email, false) do
changeset
else
# Ensure actor is in changeset context - get it from context if available
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
changeset_with_actor =
if actor && !Map.has_key?(changeset.context, :actor) do
Ash.Changeset.put_context(changeset, :actor, actor)
else
changeset
end
sync_email(changeset_with_actor)
sync_email(changeset)
end
end
@ -52,7 +42,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record, cs) do
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
@ -71,19 +61,16 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.get_linked_member(user, actor) do
# Uses system actor via Loader functions
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.load_linked_user!(member, actor) do
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end

View file

@ -5,25 +5,26 @@ defmodule Mv.EmailSync.Loader do
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
This module runs systemically and uses the system actor for all operations.
This ensures that email synchronization always works, regardless of user permissions.
All functions accept an optional `actor` parameter that is passed to Ash operations
to ensure proper authorization checks are performed.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as email sync is a mandatory side effect.
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def get_linked_member(user, actor \\ nil)
def get_linked_member(%{member_id: nil}, _actor), do: nil
def get_linked_member(user)
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}, actor) do
opts = Helpers.ash_actor_opts(actor)
def get_linked_member(%{member_id: id}) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
@ -34,10 +35,11 @@ defmodule Mv.EmailSync.Loader do
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def get_linked_user(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
def get_linked_user(member) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user
@ -49,10 +51,11 @@ defmodule Mv.EmailSync.Loader do
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
Accepts optional actor for authorization.
Uses system actor for authorization to ensure email sync always works.
"""
def load_linked_user!(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
def load_linked_user!(member) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}

View file

@ -0,0 +1,436 @@
defmodule Mv.Helpers.SystemActor do
@moduledoc """
Provides access to the system actor for systemic operations.
The system actor is a user with admin permissions that is used
for operations that must always run regardless of user permissions:
- Email synchronization
- Email uniqueness validation
- Cycle generation (if mandatory)
- Background jobs
- Seeds
## Usage
# Get system actor for systemic operations
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Ash.read(query, actor: system_actor)
## Implementation
The system actor is cached in an Agent for performance. On first access,
it attempts to load a user with email "system@mila.local" and admin role.
If that user doesn't exist, it falls back to the admin user from seeds
(identified by ADMIN_EMAIL environment variable or "admin@localhost").
## Caching
The system actor is cached in an Agent to avoid repeated database queries.
The cache is invalidated on application restart. For long-running applications,
consider implementing cache invalidation on role changes.
## Race Conditions
The system actor creation uses `upsert?: true` with `upsert_identity: :unique_email`
to prevent race conditions when multiple processes try to create the system user
simultaneously. This ensures idempotent creation and prevents database constraint errors.
## Security
The system actor should NEVER be used for user-initiated actions. It is
only for systemic operations that must bypass user permissions.
The system user is created without a password (`hashed_password = nil`) and
without an OIDC ID (`oidc_id = nil`) to prevent login. This ensures the
system user cannot be used for authentication, even if credentials are
somehow obtained.
"""
use Agent
require Ash.Query
alias Mv.Config
@doc """
Starts the SystemActor Agent.
This is called automatically by the application supervisor.
The agent starts with nil state and loads the system actor lazily on first access.
"""
def start_link(_opts) do
# Start with nil - lazy initialization on first get_system_actor call
# This prevents database access during application startup (important for tests)
Agent.start_link(fn -> nil end, name: __MODULE__)
end
@doc """
Returns the system actor (user with admin role).
The system actor is cached in an Agent for performance. On first access,
it loads the system user from the database or falls back to the admin user.
## Returns
- `%Mv.Accounts.User{}` - User with admin role loaded
- Raises if system actor cannot be found or loaded
## Examples
iex> system_actor = Mv.Helpers.SystemActor.get_system_actor()
iex> system_actor.role.permission_set_name
"admin"
"""
@spec get_system_actor() :: Mv.Accounts.User.t()
def get_system_actor do
case get_system_actor_result() do
{:ok, actor} -> actor
{:error, reason} -> raise "Failed to load system actor: #{inspect(reason)}"
end
end
@doc """
Returns the system actor as a result tuple.
This variant returns `{:ok, actor}` or `{:error, reason}` instead of raising,
which is useful for error handling in pipes or when you want to handle errors explicitly.
## Returns
- `{:ok, %Mv.Accounts.User{}}` - Successfully loaded system actor
- `{:error, term()}` - Error loading system actor
## Examples
case SystemActor.get_system_actor_result() do
{:ok, actor} -> use_actor(actor)
{:error, reason} -> handle_error(reason)
end
"""
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
def get_system_actor_result do
# In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues
if Config.sql_sandbox?() do
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
else
try do
result =
Agent.get_and_update(__MODULE__, fn
nil ->
# Cache miss - load system actor
try do
actor = load_system_actor()
{actor, actor}
rescue
e -> {{:error, e}, nil}
end
cached_actor ->
# Cache hit - return cached actor
{cached_actor, cached_actor}
end)
case result do
{:error, reason} -> {:error, reason}
actor -> {:ok, actor}
end
catch
:exit, {:noproc, _} ->
# Agent not started - load directly without caching
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
end
end
end
@doc """
Invalidates the system actor cache.
This forces a reload of the system actor on the next call to `get_system_actor/0`.
Useful when the system user's role might have changed.
## Examples
iex> Mv.Helpers.SystemActor.invalidate_cache()
:ok
"""
@spec invalidate_cache() :: :ok
def invalidate_cache do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
end
end
@doc """
Returns the email address of the system user.
This is useful for other modules that need to reference the system user
without loading the full user record.
## Returns
- `String.t()` - The system user email address ("system@mila.local")
## Examples
iex> Mv.Helpers.SystemActor.system_user_email()
"system@mila.local"
"""
@spec system_user_email() :: String.t()
def system_user_email, do: system_user_email_config()
# Returns the system user email from environment variable or default
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
@spec system_user_email_config() :: String.t()
defp system_user_email_config do
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
end
# Loads the system actor from the database
# First tries to find system@mila.local, then falls back to admin user
@spec load_system_actor() :: Mv.Accounts.User.t() | no_return()
defp load_system_actor do
case find_user_by_email(system_user_email_config()) do
{:ok, user} when not is_nil(user) ->
load_user_with_role(user)
{:ok, nil} ->
handle_system_user_not_found("no system user or admin user found")
{:error, _reason} = error ->
handle_system_user_error(error)
end
end
# Handles case when system user doesn't exist
@spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_not_found(message) do
case load_admin_user_fallback() do
{:ok, admin_user} ->
admin_user
{:error, _} ->
handle_fallback_error(message)
end
end
# Handles database error when loading system user
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_error(error) do
case load_admin_user_fallback() do
{:ok, admin_user} ->
admin_user
{:error, _} ->
handle_fallback_error("Failed to load system actor: #{inspect(error)}")
end
end
# Handles fallback error - creates test actor or raises
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
defp handle_fallback_error(message) do
if Config.sql_sandbox?() do
create_test_system_actor()
else
raise "Failed to load system actor: #{message}"
end
end
# Creates a temporary admin user for tests when no system/admin user exists
@spec create_test_system_actor() :: Mv.Accounts.User.t() | no_return()
defp create_test_system_actor do
alias Mv.Accounts
alias Mv.Authorization
admin_role = ensure_admin_role_exists()
create_system_user_with_role(admin_role)
end
# Ensures admin role exists - finds or creates it
@spec ensure_admin_role_exists() :: Mv.Authorization.Role.t() | no_return()
defp ensure_admin_role_exists do
case find_admin_role() do
{:ok, role} ->
role
{:error, :not_found} ->
create_admin_role_with_retry()
end
end
# Finds admin role in existing roles
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
defp find_admin_role do
alias Mv.Authorization
case Authorization.list_roles() do
{:ok, roles} ->
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
nil -> {:error, :not_found}
role -> {:ok, role}
end
_ ->
{:error, :not_found}
end
end
# Creates admin role, handling race conditions
@spec create_admin_role_with_retry() :: Mv.Authorization.Role.t() | no_return()
defp create_admin_role_with_retry do
alias Mv.Authorization
case create_admin_role() do
{:ok, role} ->
role
{:error, :already_exists} ->
find_existing_admin_role()
{:error, error} ->
raise "Failed to create admin role: #{inspect(error)}"
end
end
# Attempts to create admin role
@spec create_admin_role() ::
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
defp create_admin_role do
alias Mv.Authorization
case Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
}) do
{:ok, role} ->
{:ok, role}
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
{:error, :already_exists}
{:error, error} ->
{:error, error}
end
end
# Finds existing admin role after creation attempt failed due to race condition
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
defp find_existing_admin_role do
alias Mv.Authorization
case Authorization.list_roles() do
{:ok, roles} ->
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
raise "Admin role should exist but was not found"
_ ->
raise "Failed to find admin role after creation attempt"
end
end
# Creates system user with admin role assigned
# SECURITY: System user is created without password (hashed_password = nil) and
# without OIDC ID (oidc_id = nil) to prevent login. This user is ONLY for
# internal system operations via SystemActor and should never be used for authentication.
@spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
Mv.Accounts.User.t() | no_return()
defp create_system_user_with_role(admin_role) do
alias Mv.Accounts
Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true,
upsert_identity: :unique_email
)
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.load!(:role, domain: Mv.Accounts)
end
# Finds a user by email address
# SECURITY: Uses authorize?: false for bootstrap lookup only.
# This is necessary because we need to find the system/admin user before
# we can load the system actor. If User policies require an actor, this
# would create a chicken-and-egg problem. This is safe because:
# 1. We only query by email (no sensitive data exposed)
# 2. This is only used during system actor initialization (bootstrap phase)
# 3. Once system actor is loaded, all subsequent operations use proper authorization
@spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()}
defp find_user_by_email(email) do
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
end
# Loads a user with their role preloaded (required for authorization)
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
defp load_user_with_role(user) do
case Ash.load(user, :role, domain: Mv.Accounts) do
{:ok, user_with_role} ->
validate_admin_role(user_with_role)
{:error, reason} ->
raise "Failed to load role for system actor: #{inspect(reason)}"
end
end
# Validates that the user has an admin role
@spec validate_admin_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
user
end
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
raise """
System actor must have admin role, but has permission_set_name: #{permission_set}
Please assign the "Admin" role to the system user.
"""
end
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
defp validate_admin_role(%{role: nil}) do
raise """
System actor must have a role assigned, but role is nil.
Please assign the "Admin" role to the system user.
"""
end
@spec validate_admin_role(term()) :: no_return()
defp validate_admin_role(_user) do
raise """
System actor must have a role with admin permissions.
Please assign the "Admin" role to the system user.
"""
end
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
@spec load_admin_user_fallback() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
defp load_admin_user_fallback do
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
case find_user_by_email(admin_email) do
{:ok, user} when not is_nil(user) ->
{:ok, load_user_with_role(user)}
{:ok, nil} ->
{:error, :admin_user_not_found}
{:error, _reason} = error ->
error
end
end
end

View file

@ -310,20 +310,10 @@ defmodule Mv.Membership.Import.MemberCSV do
case process_row(row_map, line_number, custom_field_lookup, actor) do
{:ok, _member} ->
{acc_inserted + 1, acc_failed, acc_errors, acc_error_count, acc_truncated?}
update_inserted(acc)
{:error, error} ->
new_acc_failed = acc_failed + 1
# Only collect errors if under limit
{new_acc_errors, new_error_count, new_truncated?} =
if current_error_count < max_errors do
{[error | acc_errors], acc_error_count + 1, acc_truncated?}
else
{acc_errors, acc_error_count, true}
end
{acc_inserted, new_acc_failed, new_acc_errors, new_error_count, new_truncated?}
handle_row_error(acc, error, current_error_count, max_errors)
end
end)
@ -397,11 +387,9 @@ defmodule Mv.Membership.Import.MemberCSV do
# Extracts the first error from a changeset and converts it to a MemberCSV.Error struct
defp extract_changeset_error(changeset, csv_line_number) do
case Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end) do
errors = Ecto.Changeset.traverse_errors(changeset, &format_error_message/1)
case errors do
%{email: [message | _]} ->
# Email-specific error
%Error{
@ -430,6 +418,56 @@ defmodule Mv.Membership.Import.MemberCSV do
end
end
# Helper function to update accumulator when row is successfully inserted
defp update_inserted({acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}) do
{acc_inserted + 1, acc_failed, acc_errors, acc_error_count, acc_truncated?}
end
# Helper function to handle row error with error count limit checking
defp handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
) do
new_acc_failed = acc_failed + 1
{new_acc_errors, new_error_count, new_truncated?} =
collect_error_if_under_limit(
error,
acc_errors,
acc_error_count,
acc_truncated?,
current_error_count,
max_errors
)
{acc_inserted, new_acc_failed, new_acc_errors, new_error_count, new_truncated?}
end
# Helper function to collect error only if under limit
defp collect_error_if_under_limit(
error,
acc_errors,
acc_error_count,
acc_truncated?,
current_error_count,
max_errors
) do
if current_error_count < max_errors do
{[error | acc_errors], acc_error_count + 1, acc_truncated?}
else
{acc_errors, acc_error_count, true}
end
end
# Formats error message by replacing placeholders
defp format_error_message({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
# Maps changeset error messages to appropriate Gettext messages
defp gettext_error_message(message) when is_binary(message) do
cond do

View file

@ -10,6 +10,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
use Ash.Resource.Validation
alias Mv.Helpers
require Logger
@doc """
Validates email uniqueness across linked Member-User pairs.
@ -30,8 +32,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
actor = Map.get(changeset.context || %{}, :actor)
linked_user_id = get_linked_user_id(changeset.data, actor)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@ -40,19 +41,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id, actor)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id, actor) do
defp check_email_uniqueness(email, exclude_user_id) do
alias Mv.Helpers.SystemActor
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
opts = Helpers.ash_actor_opts(actor)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.read(query, opts) do
{:ok, []} ->
@ -61,7 +65,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
@ -69,8 +77,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data, actor) do
opts = Helpers.ash_actor_opts(actor)
defp get_linked_user_id(member_data) do
alias Mv.Helpers.SystemActor
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id

View file

@ -30,12 +30,11 @@ defmodule Mv.MembershipFees.CycleGenerator do
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
This module runs systemically and uses the system actor for all operations.
This ensures that cycle generation always works, regardless of user permissions.
All functions accept an optional `actor` parameter in the `opts` keyword list
that is passed to Ash operations to ensure proper authorization checks are performed.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as cycle generation is a mandatory side effect.
## Examples
@ -47,6 +46,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
@ -86,9 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
case load_member(member_id) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -98,27 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
do_generate_cycles_with_lock(member, today, skip_lock?)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
do_generate_cycles(member, today)
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
defp do_generate_cycles_with_lock(member, today, false) do
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, actor: actor) do
case do_generate_cycles(member, today) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -168,12 +165,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Query ALL members with fee type assigned (including inactive/left members)
# The exit_date boundary is applied during cycle generation, not here.
# This allows catch-up generation for members who left but are missing cycles.
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
case Ash.read(query) do
case Ash.read(query, opts) do
{:ok, members} ->
results = process_members_in_batches(members, batch_size, today)
{:ok, build_results_summary(results)}
@ -235,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id, opts) do
actor = Keyword.get(opts, :actor)
defp load_member(member_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
result =
if actor do
Ash.read_one(query, actor: actor)
else
Ash.read_one(query)
end
case result do
case Ash.read_one(query, opts) do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp do_generate_cycles(member, today) do
# Reload member with relationships to ensure fresh data
case load_member(member.id, actor: actor) do
case load_member(member.id) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today, actor: actor)
generate_missing_cycles(member, today)
end
{:error, reason} ->
@ -279,8 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp generate_missing_cycles(member, today) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -296,7 +287,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
else
{:ok, [], []}
end
@ -391,8 +382,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :actor)
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
@ -407,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
cycle_start
)
end)

View file

@ -52,7 +52,8 @@ defmodule MvWeb do
quote do
use Phoenix.LiveView
on_mount MvWeb.LiveHelpers
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
unquote(html_helpers())
end

View file

@ -1,4 +1,10 @@
defmodule MvWeb.PageController do
@moduledoc """
Controller for rendering the homepage.
This controller handles the root route and renders the application's
homepage view.
"""
use MvWeb, :controller
def home(conn, _params) do

View file

@ -20,10 +20,13 @@ defmodule MvWeb.LinkOidcAccountLive do
@impl true
def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
@ -46,9 +49,12 @@ defmodule MvWeb.LinkOidcAccountLive do
end
defp reload_user!(user_id) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Mv.Accounts.User
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!()
|> Ash.read_one!(actor: system_actor)
end
defp reset_password_form(socket) do
@ -58,13 +64,16 @@ defmodule MvWeb.LinkOidcAccountLive do
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (passwordless user auto-linking)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
Logger.info(
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
@ -187,6 +196,9 @@ defmodule MvWeb.LinkOidcAccountLive do
defp link_oidc_account(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (user just verified password but is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Update the user with the OIDC ID
case user.id
|> reload_user!()
@ -194,7 +206,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
# After successful linking, redirect to OIDC login
# Since the user now has an oidc_id, the next OIDC login will succeed

View file

@ -1,345 +0,0 @@
defmodule MvWeb.ContributionPeriodLive.Show do
@moduledoc """
Mock-up LiveView for Member Contribution Periods (Admin/Treasurer View).
This is a preview-only page that displays the planned UI for viewing
and managing contribution periods for a specific member.
It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- Display all contribution periods for a member
- Show period dates, interval, amount, and status
- Quick status change (paid/unpaid/suspended)
- Bulk marking of multiple periods
- Notes per period
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Member Contributions"))
|> assign(:member, mock_member())
|> assign(:periods, mock_periods())
|> assign(:selected_periods, MapSet.new())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
<:subtitle>
{gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span>
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
</:subtitle>
<:actions>
<.link navigate={~p"/membership_fee_settings"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back to Settings")}
</.link>
</:actions>
</.header>
<%!-- Member Info Card --%>
<div class="mb-6 shadow card bg-base-100">
<div class="card-body">
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<span class="text-sm text-base-content/60">{gettext("Email")}</span>
<p class="font-medium">{@member.email}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Contribution Start")}</span>
<p class="font-mono">{@member.contribution_start}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Total Contributions")}</span>
<p class="font-semibold">{length(@periods)}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Open Contributions")}</span>
<p class="font-semibold text-error">
{Enum.count(@periods, &(&1.status == :unpaid))}
</p>
</div>
</div>
</div>
</div>
<%!-- Contribution Type Change --%>
<div class="mb-6 card bg-base-200">
<div class="py-4 card-body">
<div class="flex flex-wrap items-center gap-4">
<span class="font-semibold">{gettext("Change Contribution Type")}:</span>
<select class="w-64 select select-bordered select-sm" disabled>
<option selected>{@member.contribution_type} (60,00 , {gettext("Yearly")})</option>
<option>{gettext("Reduced")} (30,00 , {gettext("Yearly")})</option>
<option>{gettext("Honorary")} (0,00 , {gettext("Yearly")})</option>
</select>
<span
class="text-sm text-base-content/60 cursor-help tooltip tooltip-bottom"
data-tip={
gettext(
"Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
)
}
>
<.icon name="hero-question-mark-circle" class="inline size-4" />
{gettext("Why are not all contribution types shown?")}
</span>
</div>
</div>
</div>
<%!-- Bulk Actions --%>
<div class="flex flex-wrap items-center gap-4 mb-4">
<span class="text-sm text-base-content/60">
{ngettext(
"%{count} period selected",
"%{count} periods selected",
MapSet.size(@selected_periods),
count: MapSet.size(@selected_periods)
)}
</span>
<button class="btn btn-sm btn-success" disabled>
<.icon name="hero-check" class="size-4" />
{gettext("Mark as Paid")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-minus-circle" class="size-4" />
{gettext("Mark as Suspended")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Mark as Unpaid")}
</button>
</div>
<%!-- Periods Table --%>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-sm" disabled />
</th>
<th>{gettext("Time Period")}</th>
<th>{gettext("Interval")}</th>
<th>{gettext("Amount")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Notes")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<tr :for={period <- @periods} class={period_row_class(period.status)}>
<td>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_periods, period.id)}
disabled
/>
</td>
<td>
<div class="font-mono">
{period.period_start} {period.period_end}
</div>
<div :if={period.is_current} class="mt-1 badge badge-info badge-sm">
{gettext("Current")}
</div>
</td>
<td>
<span class="badge badge-outline badge-sm">{format_interval(period.interval)}</span>
</td>
<td>
<span class="font-mono">{format_currency(period.amount)}</span>
</td>
<td>
<.status_badge status={period.status} />
</td>
<td>
<span :if={period.notes} class="text-sm italic text-base-content/60">
{period.notes}
</span>
<span :if={!period.notes} class="text-base-content/30"></span>
</td>
<td class="w-0 font-semibold whitespace-nowrap">
<div class="flex gap-4">
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Paid")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :suspended, do: "invisible", else: "opacity-50")
]}
>
{gettext("Suspend")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status != :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Reopen")}
</.link>
<.link href="#" class="opacity-50 cursor-not-allowed">
{gettext("Note")}
</.link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="flex items-center gap-3 px-4 py-3 mb-6 border rounded-lg border-warning text-warning bg-base-100">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="ml-2 text-sm text-base-content/70">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Status badge component
attr :status, :atom, required: true
defp status_badge(%{status: :paid} = assigns) do
~H"""
<span class="gap-1 badge badge-success">
<.icon name="hero-check-circle-mini" class="size-3" />
{gettext("Paid")}
</span>
"""
end
defp status_badge(%{status: :unpaid} = assigns) do
~H"""
<span class="gap-1 badge badge-error">
<.icon name="hero-x-circle-mini" class="size-3" />
{gettext("Unpaid")}
</span>
"""
end
defp status_badge(%{status: :suspended} = assigns) do
~H"""
<span class="gap-1 badge badge-neutral">
<.icon name="hero-pause-circle-mini" class="size-3" />
{gettext("Suspended")}
</span>
"""
end
defp period_row_class(:unpaid), do: "bg-error/5"
defp period_row_class(:suspended), do: "bg-base-200/50"
defp period_row_class(_), do: ""
# Mock member data
defp mock_member do
%{
id: "123",
first_name: "Maria",
last_name: "Weber",
email: "maria.weber@example.de",
contribution_type: gettext("Regular"),
joined_at: "15.03.2021",
contribution_start: "01.01.2021"
}
end
# Mock periods data
defp mock_periods do
[
%{
id: "p1",
period_start: "01.01.2025",
period_end: "31.12.2025",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :unpaid,
notes: nil,
is_current: true
},
%{
id: "p2",
period_start: "01.01.2024",
period_end: "31.12.2024",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :paid,
notes: gettext("Paid via bank transfer"),
is_current: false
},
%{
id: "p3",
period_start: "01.01.2023",
period_end: "31.12.2023",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p4",
period_start: "01.01.2022",
period_end: "31.12.2022",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p5",
period_start: "01.01.2021",
period_end: "31.12.2021",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :suspended,
notes: gettext("Joining year - reduced to 0"),
is_current: false
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -1,205 +0,0 @@
defmodule MvWeb.ContributionTypeLive.Index do
@moduledoc """
Mock-up LiveView for Contribution Types Management (Admin).
This is a preview-only page that displays the planned UI for managing
contribution types. It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- List all contribution types
- Display: Name, Amount, Interval, Member count
- Create new contribution types
- Edit existing contribution types (name, amount, description - NOT interval)
- Delete contribution types (if no members assigned)
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Contribution Types"))
|> assign(:contribution_types, mock_contribution_types())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contribution Types")}
<:subtitle>
{gettext("Manage contribution types for membership fees.")}
</:subtitle>
<:actions>
<button class="btn btn-primary" disabled>
<.icon name="hero-plus" /> {gettext("New Contribution Type")}
</button>
</:actions>
</.header>
<.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}>
<:col :let={ct} label={gettext("Name")}>
<span class="font-medium">{ct.name}</span>
<p :if={ct.description} class="text-sm text-base-content/60">{ct.description}</p>
</:col>
<:col :let={ct} label={gettext("Amount")}>
<span class="font-mono">{format_currency(ct.amount)}</span>
</:col>
<:col :let={ct} label={gettext("Interval")}>
<span class="badge badge-outline">{format_interval(ct.interval)}</span>
</:col>
<:col :let={ct} label={gettext("Members")}>
<span class="badge badge-ghost">{ct.member_count}</span>
</:col>
<:action :let={_ct}>
<button class="btn btn-ghost btn-xs" disabled title={gettext("Edit")}>
<.icon name="hero-pencil" class="size-4" />
</button>
</:action>
<:action :let={ct}>
<button
class="btn btn-ghost btn-xs text-error"
disabled
title={
if ct.member_count > 0,
do: gettext("Cannot delete - members assigned"),
else: gettext("Delete")
}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<.info_card />
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="text-sm text-base-content/70 ml-2">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Info card explaining the contribution type concept
defp info_card(assigns) do
~H"""
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Contribution Types")}
</h2>
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</div>
</div>
"""
end
# Mock data for demonstration
defp mock_contribution_types do
[
%{
id: "1",
name: gettext("Regular"),
description: gettext("Standard membership fee for regular members"),
amount: Decimal.new("60.00"),
interval: :yearly,
member_count: 45
},
%{
id: "2",
name: gettext("Reduced"),
description: gettext("Reduced fee for unemployed, pensioners, or low income"),
amount: Decimal.new("30.00"),
interval: :yearly,
member_count: 12
},
%{
id: "3",
name: gettext("Student"),
description: gettext("Monthly fee for students and trainees"),
amount: Decimal.new("5.00"),
interval: :monthly,
member_count: 8
},
%{
id: "4",
name: gettext("Family"),
description: gettext("Quarterly fee for family memberships"),
amount: Decimal.new("25.00"),
interval: :quarterly,
member_count: 15
},
%{
id: "5",
name: gettext("Supporting Member"),
description: gettext("Half-yearly contribution for supporting members"),
amount: Decimal.new("100.00"),
interval: :half_yearly,
member_count: 3
},
%{
id: "6",
name: gettext("Honorary"),
description: gettext("No fee for honorary members"),
amount: Decimal.new("0.00"),
interval: :yearly,
member_count: 2
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -1,300 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Form do
@moduledoc """
LiveView form for creating and editing custom field values.
## Features
- Create new custom field values with member and type selection
- Edit existing custom field values
- Value input adapts to custom field type (string, integer, boolean, date, email)
- Real-time validation
## Form Fields
**Required:**
- member - Select which member owns this custom field value
- custom_field - Select the type (defines value type)
- value - The actual value (input type depends on custom field type)
## Value Types
The form dynamically renders appropriate inputs based on custom field type:
- String: text input
- Integer: number input
- Boolean: checkbox
- Date: date picker
- Email: email input with validation
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field value)
## Note
Custom field values are typically managed through the member edit form,
not through this standalone form.
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage Custom Field Value records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
<!-- Custom Field Selection -->
<.input
field={@form[:custom_field_id]}
type="select"
label={gettext("Custom field")}
options={custom_field_options(@custom_fields)}
prompt={gettext("Choose a custom field")}
/>
<!-- Member Selection -->
<.input
field={@form[:member_id]}
type="select"
label={gettext("Member")}
options={member_options(@members)}
prompt={gettext("Choose a member")}
/>
<!-- Value Input - handles Union type -->
<%= if @selected_custom_field do %>
<.union_value_input form={@form} custom_field={@selected_custom_field} />
<% else %>
<div class="text-sm text-gray-600">
{gettext("Please select a custom field first")}
</div>
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field Value")}
</.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
# Helper function for Union-Value Input
defp union_value_input(assigns) do
# Extract the current value from the CustomFieldValue
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
assigns = assign(assigns, :current_value, current_value)
~H"""
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
{gettext("Value")}
</label>
<%= case @custom_field.value_type do %>
<% :string -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="string" />
</.inputs_for>
<% :integer -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="number" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="integer" />
</.inputs_for>
<% :boolean -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="checkbox" label="" checked={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="boolean" />
</.inputs_for>
<% :date -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input
field={value_form[:value]}
type="date"
label=""
value={format_date_value(@current_value)}
/>
<input type="hidden" name={value_form[:_union_type].name} value="date" />
</.inputs_for>
<% :email -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="email" label="" value={@current_value} />
<input type="hidden" name={value_form[:_union_type].name} value="email" />
</.inputs_for>
<% _ -> %>
<div class="text-sm text-red-600">
{gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
</div>
<% end %>
</div>
"""
end
# Helper function to extract the current value from the CustomFieldValue
defp extract_current_value(
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
_value_type
) do
value
end
defp extract_current_value(_data, _value_type) do
nil
end
# Helper function to format Date values for HTML input
defp format_date_value(%Date{} = date) do
Date.to_iso8601(date)
end
defp format_date_value(nil), do: ""
defp format_date_value(date) when is_binary(date) do
case Date.from_iso8601(date) do
{:ok, parsed_date} -> Date.to_iso8601(parsed_date)
_ -> ""
end
end
defp format_date_value(_), do: ""
@impl true
def mount(params, _session, socket) do
custom_field_value =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field])
end
action = if is_nil(custom_field_value), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields
actor = current_actor(socket)
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field_value: custom_field_value)
|> assign(:page_title, page_title)
|> assign(:custom_fields, custom_fields)
|> assign(:members, members)
|> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
# Find the selected CustomField
selected_custom_field =
case custom_field_value_params["custom_field_id"] do
"" -> nil
nil -> nil
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
end
# Set the Union type based on the selected CustomField
updated_params =
if selected_custom_field do
union_type = to_string(selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
end
{:noreply,
socket
|> assign(:selected_custom_field, selected_custom_field)
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
end
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
# Set the Union type based on the selected CustomField
updated_params =
if socket.assigns.selected_custom_field do
union_type = to_string(socket.assigns.selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
end
actor = current_actor(socket)
case submit_form(socket.assigns.form, updated_params, actor) do
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(
:info,
gettext("Custom field value %{action} successfully", action: action)
)
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do
form =
if custom_field_value do
# Determine the Union type based on the custom_field
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
params =
if union_type do
%{"value" => %{"_union_type" => to_string(union_type)}}
else
%{}
end
AshPhoenix.Form.for_update(custom_field_value, :update,
as: "custom_field_value",
params: params
)
else
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
as: "custom_field_value"
)
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
defp return_path("show", custom_field_value),
do: ~p"/custom_field_values/#{custom_field_value.id}"
# Helper functions for selection options
defp custom_field_options(custom_fields) do
Enum.map(custom_fields, &{&1.name, &1.id})
end
defp member_options(members) do
Enum.map(members, &{MvWeb.Helpers.MemberHelpers.display_name(&1), &1.id})
end
end

View file

@ -1,157 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Index do
@moduledoc """
LiveView for displaying and managing custom field values.
## Features
- List all custom field values with their values and types
- Show which member each custom field value belongs to
- Display custom field information
- Navigate to custom field value details and edit forms
- Delete custom field values
## Relationships
Each custom field value is linked to:
- A member (the custom field value owner)
- A custom field (defining value type and behavior)
## Events
- `delete` - Remove a custom field value from the database
## Note
Custom field values are typically managed through the member edit form.
This view provides a global overview of all custom field values.
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom field values
<:actions>
<.button variant="primary" navigate={~p"/custom_field_values/new"}>
<.icon name="hero-plus" /> New Custom field value
</.button>
</:actions>
</.header>
<.table
id="custom_field_values"
rows={@streams.custom_field_values}
row_click={
fn {_id, custom_field_value} ->
JS.navigate(~p"/custom_field_values/#{custom_field_value}")
end
}
>
<:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id}</:col>
<:action :let={{_id, custom_field_value}}>
<div class="sr-only">
<.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show</.link>
</div>
<.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit</.link>
</:action>
<:action :let={{id, custom_field_value}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
actor = current_actor(socket)
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])}
else
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
{:ok, custom_field_values} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, custom_field_values)}
{:error, %Ash.Error.Forbidden{}} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
{:error, error} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, format_error(error))}
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
{:ok, custom_field_value} ->
case Ash.destroy(custom_field_value, actor: actor) do
:ok ->
{:noreply,
socket
|> stream_delete(:custom_field_values, custom_field_value)
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -1,67 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Show do
@moduledoc """
LiveView for displaying a single custom field value's details.
## Features
- Display custom field value and type
- Show linked member
- Show custom field definition
- Navigate to edit form
- Return to custom field value list
## Displayed Information
- Custom field value (formatted based on type)
- Custom field name and description
- Member information (who owns this custom field value)
- Custom field value metadata (ID, timestamps if added)
## Navigation
- Back to custom field value list
- Edit custom field value
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Data field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_field_values"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field value
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field_value.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end
defp page_title(:show), do: "Show data field value"
defp page_title(:edit), do: "Edit data field value"
end

View file

@ -13,7 +13,7 @@ defmodule MvWeb.MemberLive.Form do
## Form Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
- Payment Data: Mockup section (not editable)
- Membership Fee: Selection of membership fee type with interval validation
## Events
- `validate` - Real-time form validation
@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.MembershipFees
@ -295,11 +293,14 @@ defmodule MvWeb.MemberLive.Form do
handle_save_success(socket, member)
{:error, form} ->
{:noreply, assign(socket, form: form)}
handle_save_error(socket, form)
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
e ->
handle_save_exception(socket, e)
end
end
@ -321,6 +322,13 @@ defmodule MvWeb.MemberLive.Form do
{:noreply, socket}
end
defp handle_save_error(socket, form) do
# Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
error_message = extract_error_message(form)
{:noreply, socket |> assign(form: form) |> put_flash(:error, error_message)}
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
@ -332,6 +340,98 @@ defmodule MvWeb.MemberLive.Form do
{:noreply, put_flash(socket, :error, error_message)}
end
defp handle_save_exception(socket, exception) do
# Handle unexpected exceptions (database errors, network issues, etc.)
require Logger
Logger.error("Unexpected error saving member: #{inspect(exception)}")
action = get_action_name(socket.assigns.form.source.type)
error_message = gettext("Failed to %{action} member.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end
# Extracts a user-friendly error message from form errors
defp extract_error_message(form) do
source_errors = get_source_errors(form)
cond do
has_invalid_error?(source_errors) ->
extract_invalid_error_message(source_errors)
has_other_error?(source_errors) ->
extract_other_error_message(source_errors)
has_form_errors?(form) ->
gettext("Please correct the errors in the form and try again.")
true ->
gettext("Failed to save member. Please try again.")
end
end
# Checks if source errors contain an Ash.Error.Invalid
defp has_invalid_error?([%Ash.Error.Invalid{errors: errors} | _]) when is_list(errors), do: true
defp has_invalid_error?(_), do: false
# Extracts message from Ash.Error.Invalid
defp extract_invalid_error_message([%Ash.Error.Invalid{errors: errors} | _]) do
case List.first(errors) do
%{message: message} when is_binary(message) ->
gettext("Validation failed: %{message}", message: message)
%{field: field, message: message} when is_binary(message) ->
gettext("Validation failed: %{field} %{message}", field: field, message: message)
_ ->
gettext("Validation failed. Please check your input.")
end
end
# Checks if source errors contain other error types
defp has_other_error?([_ | _]), do: true
defp has_other_error?(_), do: false
# Extracts message from other error types
defp extract_other_error_message([error | _]) do
cond do
Map.has_key?(error, :message) and is_binary(error.message) ->
error.message
is_struct(error) ->
extract_struct_error_message(error)
true ->
gettext("Failed to save member. Please try again.")
end
end
# Extracts message from struct error using Ash.ErrorKind protocol
defp extract_struct_error_message(error) do
try do
Ash.ErrorKind.message(error)
rescue
Protocol.UndefinedError -> gettext("Failed to save member. Please try again.")
end
end
# Checks if form has any errors
defp has_form_errors?(form) do
case Map.get(form, :errors) do
errors when is_list(errors) and errors != [] -> true
_ -> false
end
end
# Extracts source-level errors from form (Ash errors, etc.)
defp get_source_errors(form) do
case form.source do
%{errors: errors} when is_list(errors) -> errors
%Ash.Changeset{errors: errors} when is_list(errors) -> errors
_ -> []
end
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)

View file

@ -27,8 +27,6 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]

View file

@ -12,7 +12,7 @@ defmodule MvWeb.MemberLive.Show do
## Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
- Payment Data: Mockup section with placeholder data
- Membership Fees: Tab showing all membership fee cycles with status management (via MembershipFeesComponent)
## Navigation
- Back to member list
@ -22,8 +22,6 @@ defmodule MvWeb.MemberLive.Show do
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true

View file

@ -554,46 +554,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
actor = current_actor(socket)
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
# SECURITY: Only admins can manually regenerate cycles via UI
# Cycle generation itself uses system actor, but UI access should be restricted
if actor.role && actor.role.permission_set_name == "admin" do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
send(self(), {:member_updated, updated_member})
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
send(self(), {:member_updated, updated_member})
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
end
else
{:noreply,
socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
end
end

View file

@ -13,7 +13,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query

View file

@ -14,7 +14,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query

View file

@ -17,8 +17,6 @@ defmodule MvWeb.RoleLive.Form do
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def render(assigns) do
~H"""

View file

@ -24,8 +24,6 @@ defmodule MvWeb.RoleLive.Index do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
actor = socket.assigns[:current_user]

View file

@ -19,8 +19,6 @@ defmodule MvWeb.RoleLive.Show do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(%{"id" => id}, _session, socket) do
try do

View file

@ -33,7 +33,6 @@ defmodule MvWeb.UserLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true

View file

@ -23,7 +23,6 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view
import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -26,7 +26,6 @@ defmodule MvWeb.UserLive.Show do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -27,39 +27,17 @@ defmodule MvWeb.LiveHelpers do
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
user = socket.assigns[:current_user]
if user do
# Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.

View file

@ -58,12 +58,6 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
live "/users", UserLive.Index, :index
live "/users/new", UserLive.Form, :new
live "/users/:id/edit", UserLive.Form, :edit
@ -80,10 +74,6 @@ defmodule MvWeb.Router do
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
# Contribution Management (Mock-ups)
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new