Fix Credo Design (AliasUsage): add aliases in lib

Add module aliases at top and use short names instead of
fully qualified nested modules across lib/.
This commit is contained in:
Moritz 2026-03-03 19:03:47 +01:00
parent cfc8900c5c
commit 7a8b069834
Signed by: moritz
GPG key ID: 1020A035E5DD0824
25 changed files with 176 additions and 109 deletions

View file

@ -11,6 +11,11 @@ defmodule Mv.Accounts.User do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Ash.Resource.Preparation.Builtins
alias Mv.Authorization.Role, as: RoleResource
alias Mv.Helpers.SystemActor
alias Mv.OidcRoleSync
postgres do postgres do
table "users" table "users"
repo Mv.Repo repo Mv.Repo
@ -282,20 +287,20 @@ defmodule Mv.Accounts.User do
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each # get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context -> prepare Builtins.after_action(fn query, result, _context ->
user_info = Ash.Query.get_argument(query, :user_info) || %{} user_info = Ash.Query.get_argument(query, :user_info) || %{}
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{} oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
users = users =
case result do case result do
nil -> [] nil -> []
u when is_struct(u, User) -> [u] u when is_struct(u, __MODULE__) -> [u]
list when is_list(list) -> list list when is_list(list) -> list
_ -> [] _ -> []
end end
Enum.each(users, fn user -> Enum.each(users, fn user ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
end) end)
{:ok, result} {:ok, result}
@ -483,10 +488,10 @@ defmodule Mv.Accounts.User do
|> Enum.map(& &1.id) |> Enum.map(& &1.id)
# Count only non-system users with admin role (system user is for internal ops) # Count only non-system users with admin role (system user is for internal ops)
system_email = Mv.Helpers.SystemActor.system_user_email() system_email = SystemActor.system_user_email()
count = count =
Mv.Accounts.User __MODULE__
|> Ash.Query.for_read(:read) |> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids)) |> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email)) |> Ash.Query.filter(expr(email != ^system_email))
@ -512,7 +517,7 @@ defmodule Mv.Accounts.User do
# Prevent modification of the system actor user (required for internal operations). # Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context -> validate fn changeset, _context ->
if Mv.Helpers.SystemActor.system_user?(changeset.data) do if SystemActor.system_user?(changeset.data) do
{:error, {:error,
field: :email, field: :email,
message: message:
@ -641,8 +646,8 @@ defmodule Mv.Accounts.User do
case Process.get({__MODULE__, :default_role_id}) do case Process.get({__MODULE__, :default_role_id}) do
nil -> nil ->
role_id = role_id =
case Mv.Authorization.Role.get_mitglied_role() do case RoleResource.get_mitglied_role() do
{:ok, %Mv.Authorization.Role{id: id}} -> id {:ok, %RoleResource{id: id}} -> id
_ -> nil _ -> nil
end end

View file

@ -26,7 +26,9 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
use Ash.Resource.Validation use Ash.Resource.Validation
require Logger require Logger
alias Mv.Accounts.User
alias Mv.Accounts.User.Errors.PasswordVerificationRequired alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Helpers.SystemActor
@impl true @impl true
def init(opts), do: {:ok, opts} def init(opts), do: {:ok, opts}
@ -43,10 +45,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
# Check if a user with this oidc_id already exists # Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration # If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor) # Use SystemActor for authorization during OIDC registration (no logged-in actor)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
existing_oidc_user = existing_oidc_user =
case Mv.Accounts.User case User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id)) |> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one(actor: system_actor) do |> Ash.read_one(actor: system_actor) do
{:ok, user} -> user {:ok, user} -> user
@ -62,7 +64,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email # Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor) # Use SystemActor for authorization during OIDC registration (no logged-in actor)
case Mv.Accounts.User case User
|> Ash.Query.filter(email == ^to_string(email)) |> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one(actor: system_actor) do |> Ash.read_one(actor: system_actor) do
{:ok, nil} -> {:ok, nil} ->

View file

@ -39,10 +39,16 @@ defmodule Mv.Membership.Member do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Ecto.Adapters.SQL, as: EctoSQL
alias Mv.Helpers alias Mv.Helpers
require Logger alias Mv.Helpers.SystemActor
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo
require Logger
# Module constants # Module constants
@member_search_limit 10 @member_search_limit 10
@ -813,7 +819,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do case Map.get(cycle, :membership_fee_type) do
%{interval: interval} -> %{interval: interval} ->
cycle_end = cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq] Date.compare(today, cycle_end) in [:lt, :eq]
@ -847,7 +853,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do case Map.get(cycle, :membership_fee_type) do
%{interval: interval} -> %{interval: interval} ->
cycle_end = cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt Date.compare(today, cycle_end) == :gt
@ -863,7 +869,7 @@ defmodule Mv.Membership.Member do
cycles, cycles,
fn cycle -> fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval interval = Map.get(cycle, :membership_fee_type).interval
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end, end,
{:desc, Date} {:desc, Date}
) )
@ -890,7 +896,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do case Map.get(cycle, :membership_fee_type) do
%{interval: interval} -> %{interval: interval} ->
cycle_end = cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
@ -908,15 +914,12 @@ defmodule Mv.Membership.Member do
@doc false @doc false
# Uses system actor for cycle regeneration (mandatory side effect) # Uses system actor for cycle regeneration (mandatory side effect)
def regenerate_cycles_on_type_change(member, _opts \\ []) do def regenerate_cycles_on_type_change(member, _opts \\ []) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
today = Date.utc_today() today = Date.utc_today()
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
# Use advisory lock to prevent concurrent deletion and regeneration # Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously # This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do if Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key) regenerate_cycles_in_transaction(member, today, lock_key)
else else
regenerate_cycles_new_transaction(member, today, lock_key) regenerate_cycles_new_transaction(member, today, lock_key)
@ -926,15 +929,15 @@ defmodule Mv.Membership.Member do
# Already in transaction: use advisory lock directly # Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end end
# Not in transaction: start new transaction with advisory lock # Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn -> Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} -> {:ok, notifications} ->
@ -942,7 +945,7 @@ defmodule Mv.Membership.Member do
notifications notifications
{:error, reason} -> {:error, reason} ->
Mv.Repo.rollback(reason) Repo.rollback(reason)
end end
end) end)
|> case do |> case do
@ -956,9 +959,6 @@ defmodule Mv.Membership.Member do
# notifications are collected to be sent after transaction commits # notifications are collected to be sent after transaction commits
# Uses system actor for all operations # Uses system actor for all operations
defp do_regenerate_cycles_on_type_change(member, today, opts) do defp do_regenerate_cycles_on_type_change(member, today, opts) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
require Ash.Query require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
@ -968,7 +968,7 @@ defmodule Mv.Membership.Member do
# Find all unpaid cycles for this member # Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval # We need to check cycle_end for each cycle using its own interval
all_unpaid_cycles_query = all_unpaid_cycles_query =
Mv.MembershipFees.MembershipFeeCycle MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id) |> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid) |> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type]) |> Ash.Query.load([:membership_fee_type])
@ -997,7 +997,7 @@ defmodule Mv.Membership.Member do
case cycle.membership_fee_type do case cycle.membership_fee_type do
%{interval: interval} -> %{interval: interval} ->
cycle_end = cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq] Date.compare(today, cycle_end) in [:lt, :eq]
@ -1047,7 +1047,7 @@ defmodule Mv.Membership.Member do
defp regenerate_cycles(member_id, today, opts) do defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case CycleGenerator.generate_cycles_for_member(
member_id, member_id,
today: today, today: today,
skip_lock?: skip_lock? skip_lock?: skip_lock?
@ -1078,7 +1078,7 @@ defmodule Mv.Membership.Member do
# Runs cycle generation synchronously (for test environment) # Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, initiator) do defp handle_cycle_generation_sync(member, initiator) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case CycleGenerator.generate_cycles_for_member(
member.id, member.id,
today: Date.utc_today(), today: Date.utc_today(),
initiator: initiator initiator: initiator
@ -1099,7 +1099,7 @@ defmodule Mv.Membership.Member do
# Runs cycle generation asynchronously (for production environment) # Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member, initiator) do defp handle_cycle_generation_async(member, initiator) do
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, case CycleGenerator.generate_cycles_for_member(member.id,
initiator: initiator initiator: initiator
) do ) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->

View file

@ -5,22 +5,28 @@ defmodule Mv.Application do
use Application use Application
alias Mv.Helpers.SystemActor
alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint
alias MvWeb.Telemetry
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
Mv.Vereinfacht.SyncFlash.create_table!() SyncFlash.create_table!()
children = [ children = [
MvWeb.Telemetry, Telemetry,
Mv.Repo, Repo,
{Task.Supervisor, name: Mv.TaskSupervisor}, {Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub}, {Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my}, {AshAuthentication.Supervisor, otp_app: :my},
Mv.Helpers.SystemActor, SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg) # Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg}, # {Mv.Worker, arg},
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
MvWeb.Endpoint Endpoint
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View file

@ -7,9 +7,11 @@ defmodule Mv.Authorization.Checks.ActorIsSystemUser do
""" """
use Ash.Policy.SimpleCheck use Ash.Policy.SimpleCheck
alias Mv.Helpers.SystemActor
@impl true @impl true
def describe(_opts), do: "actor is the system user" def describe(_opts), do: "actor is the system user"
@impl true @impl true
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor) def match?(actor, _context, _opts), do: SystemActor.system_user?(actor)
end end

View file

@ -22,6 +22,7 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end end
""" """
use Ash.Policy.Check use Ash.Policy.Check
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets alias Mv.Authorization.PermissionSets
@impl true @impl true
@ -67,5 +68,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end end
end end
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor) defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
end end

View file

@ -81,6 +81,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
use Ash.Policy.Check use Ash.Policy.Check
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets alias Mv.Authorization.PermissionSets
require Logger require Logger
@ -397,6 +398,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Fallback: Load role if not loaded (in case on_mount didn't run) # Fallback: Load role if not loaded (in case on_mount didn't run)
# Delegates to centralized Actor helper # Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do defp ensure_role_loaded(actor) do
Mv.Authorization.Actor.ensure_loaded(actor) Actor.ensure_loaded(actor)
end end
end end

View file

@ -94,14 +94,16 @@ defmodule Mv.Authorization.Role do
end end
end end
alias Mv.Authorization.PermissionSets
validations do validations do
validate one_of( validate one_of(
:permission_set_name, :permission_set_name,
Mv.Authorization.PermissionSets.all_permission_sets() PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1) |> Enum.map(&Atom.to_string/1)
), ),
message: message:
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}" "must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context -> validate fn changeset, _context ->
if changeset.data.is_system_role do if changeset.data.is_system_role do

View file

@ -13,6 +13,7 @@ defmodule Mv.Membership.MemberExport do
alias Mv.Membership.CustomField alias Mv.Membership.CustomField
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.Membership.MemberExportSort alias Mv.Membership.MemberExportSort
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport do
if parsed.selected_ids == [] do if parsed.selected_ids == [] do
members members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( |> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{}, parsed.boolean_filters || %{},
Map.values(custom_fields_by_id) Map.values(custom_fields_by_id)
) )

View file

@ -21,6 +21,7 @@ defmodule Mv.Membership.MemberExport.Build do
import Ash.Expr import Ash.Expr
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort} alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport.Build do
if parsed.selected_ids == [] do if parsed.selected_ids == [] do
members members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( |> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{}, parsed.boolean_filters || %{},
Map.values(custom_fields_by_id) Map.values(custom_fields_by_id)
) )

View file

@ -54,6 +54,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo alias Mv.Repo
alias Ecto.Adapters.SQL, as: EctoSQL
require Ash.Query require Ash.Query
require Logger require Logger
@ -113,7 +115,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
Repo.transaction(fn -> Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, opts) do case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->

View file

@ -14,6 +14,9 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias Mv.Vereinfacht
alias Mv.Vereinfacht.SyncFlash
require Logger require Logger
@synced_attributes [ @synced_attributes [
@ -60,13 +63,13 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
# Ash calls after_transaction with (changeset, result) only - 2 args. # Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do defp sync_after_transaction(_changeset, {:ok, member}) do
case Mv.Vereinfacht.sync_member(member) do case Vereinfacht.sync_member(member) do
:ok -> :ok ->
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.") SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
{:ok, member} {:ok, member}
{:ok, member_updated} -> {:ok, member_updated} ->
Mv.Vereinfacht.SyncFlash.store( SyncFlash.store(
to_string(member_updated.id), to_string(member_updated.id),
:ok, :ok,
"Synced to Vereinfacht." "Synced to Vereinfacht."
@ -77,10 +80,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
{:error, reason} -> {:error, reason} ->
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}") Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
Mv.Vereinfacht.SyncFlash.store( SyncFlash.store(
to_string(member.id), to_string(member.id),
:warning, :warning,
Mv.Vereinfacht.format_error(reason) Vereinfacht.format_error(reason)
) )
{:ok, member} {:ok, member}

View file

@ -29,6 +29,7 @@ defmodule MvWeb.CoreComponents do
use Phoenix.Component use Phoenix.Component
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Phoenix.HTML.Form, as: HTMLForm
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items) # WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
@ -669,7 +670,7 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "checkbox"} = assigns) do def input(%{type: "checkbox"} = assigns) do
assigns = assigns =
assign_new(assigns, :checked, fn -> assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) HTMLForm.normalize_value("checkbox", assigns[:value])
end) end)
# For checkboxes, we don't use HTML required attribute (means "must be checked") # For checkboxes, we don't use HTML required attribute (means "must be checked")
@ -736,7 +737,7 @@ defmodule MvWeb.CoreComponents do
{@rest} {@rest}
> >
<option :if={@prompt} value="">{@prompt}</option> <option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)} {HTMLForm.options_for_select(@options, @value)}
</select> </select>
</label> </label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
@ -765,7 +766,7 @@ defmodule MvWeb.CoreComponents do
@errors != [] && (@error_class || "textarea-error") @errors != [] && (@error_class || "textarea-error")
]} ]}
{@rest} {@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea> >{HTMLForm.normalize_value("textarea", @value)}</textarea>
</label> </label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
</fieldset> </fieldset>
@ -790,7 +791,7 @@ defmodule MvWeb.CoreComponents do
type={@type} type={@type}
name={@name} name={@name}
id={@id} id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)} value={HTMLForm.normalize_value(@type, @value)}
class={[ class={[
@class || "w-full input", @class || "w-full input",
@errors != [] && (@error_class || "input-error") @errors != [] && (@error_class || "input-error")

View file

@ -18,15 +18,19 @@ defmodule MvWeb.LinkOidcAccountLive do
require Ash.Query require Ash.Query
require Logger require Logger
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in) # Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"), 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) <- oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"), Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do {:ok, user} <- Ash.get(UserResource, user_id, actor: system_actor) do
# Check if user is passwordless # Check if user is passwordless
if passwordless?(user) do if passwordless?(user) do
# Auto-link passwordless user immediately # Auto-link passwordless user immediately
@ -50,9 +54,9 @@ defmodule MvWeb.LinkOidcAccountLive do
defp reload_user!(user_id) do defp reload_user!(user_id) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in) # Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
Mv.Accounts.User UserResource
|> Ash.Query.filter(id == ^user_id) |> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!(actor: system_actor) |> Ash.read_one!(actor: system_actor)
end end
@ -65,7 +69,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (passwordless user auto-linking) # Use SystemActor for authorization (passwordless user auto-linking)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
case user.id case user.id
|> reload_user!() |> reload_user!()
@ -176,11 +180,11 @@ defmodule MvWeb.LinkOidcAccountLive do
defp verify_password(email, password) do defp verify_password(email, password) do
# Use AshAuthentication password strategy to verify # Use AshAuthentication password strategy to verify
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) strategies = AshAuthentication.Info.authentication_strategies(UserResource)
password_strategy = Enum.find(strategies, fn s -> s.name == :password end) password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
if password_strategy do if password_strategy do
AshAuthentication.Strategy.Password.Actions.sign_in( PasswordActions.sign_in(
password_strategy, password_strategy,
%{ %{
"email" => email, "email" => email,
@ -197,7 +201,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") 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) # Use SystemActor for authorization (user just verified password but is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Update the user with the OIDC ID # Update the user with the OIDC ID
case user.id case user.id

View file

@ -12,11 +12,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias MvWeb.Translations.FieldTypes
require Logger require Logger
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) assigns = assign(assigns, :field_type_label, &FieldTypes.label/1)
~H""" ~H"""
<div id={@id}> <div id={@id}>

View file

@ -26,7 +26,11 @@ defmodule MvWeb.GlobalSettingsLive do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Helpers
alias Mv.Helpers.SystemActor
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -551,13 +555,13 @@ defmodule MvWeb.GlobalSettingsLive do
end end
defp fetch_member_names_by_ids(ids) do defp fetch_member_names_by_ids(ids) do
actor = Mv.Helpers.SystemActor.get_system_actor() actor = SystemActor.get_system_actor()
opts = Mv.Helpers.ash_actor_opts(actor) opts = Helpers.ash_actor_opts(actor)
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids)) query = Ash.Query.filter(MemberResource, expr(id in ^ids))
case Ash.read(query, opts) do case Ash.read(query, opts) do
{:ok, members} -> {:ok, members} ->
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end) Map.new(members, fn m -> {m.id, MemberHelpers.display_name(m)} end)
_ -> _ ->
%{} %{}

View file

@ -25,8 +25,11 @@ defmodule MvWeb.MemberLive.Form do
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
alias Mv.Membership.Member, as: MemberResource
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -51,7 +54,7 @@ defmodule MvWeb.MemberLive.Form do
</.button> </.button>
</:leading> </:leading>
<%= if @member do %> <%= if @member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} {MemberHelpers.display_name(@member)}
<% else %> <% else %>
{gettext("New Member")} {gettext("New Member")}
<% end %> <% end %>
@ -289,7 +292,7 @@ defmodule MvWeb.MemberLive.Form do
data-testid="member-delete" data-testid="member-delete"
aria-label={ aria-label={
gettext("Delete member %{name}", gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member) name: MemberHelpers.display_name(@member)
) )
} }
> >
@ -316,7 +319,7 @@ defmodule MvWeb.MemberLive.Form do
<p class="py-4"> <p class="py-4">
{gettext( {gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.", "Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member) name: MemberHelpers.display_name(@member)
)} )}
</p> </p>
<div class="modal-action"> <div class="modal-action">
@ -371,7 +374,7 @@ defmodule MvWeb.MemberLive.Form do
member = member =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor) id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
end end
page_title = page_title =
@ -562,7 +565,7 @@ defmodule MvWeb.MemberLive.Form do
end end
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do case SyncFlash.take(to_string(member_id)) do
{:warning, message} -> {:warning, message} ->
put_flash(socket, :warning, translate_vereinfacht_flash(message)) put_flash(socket, :warning, translate_vereinfacht_flash(message))
@ -767,7 +770,7 @@ defmodule MvWeb.MemberLive.Form do
) )
else else
AshPhoenix.Form.for_create( AshPhoenix.Form.for_create(
Mv.Membership.Member, MemberResource,
:create_member, :create_member,
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",

View file

@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
@ -1012,7 +1013,7 @@ defmodule MvWeb.MemberLive.Index do
defp apply_search_filter(query, search_query) do defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do if search_query && String.trim(search_query) != "" do
query query
|> Mv.Membership.Member.fuzzy_search(%{query: search_query}) |> MemberResource.fuzzy_search(%{query: search_query})
else else
query query
end end

View file

@ -24,7 +24,13 @@ defmodule MvWeb.MemberLive.Show do
import Ash.Query import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member, as: MemberResource
alias Mv.Vereinfacht.Client, as: VereinfachtClient
alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
alias Phoenix.HTML.Engine, as: HTMLEngine
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -41,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} {MemberHelpers.display_name(@member)}
<:actions> <:actions>
<%= if can?(@current_user, :update, @member) do %> <%= if can?(@current_user, :update, @member) do %>
<.button <.button
@ -329,7 +335,7 @@ defmodule MvWeb.MemberLive.Show do
data-testid="member-delete" data-testid="member-delete"
aria-label={ aria-label={
gettext("Delete member %{name}", gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member) name: MemberHelpers.display_name(@member)
) )
} }
> >
@ -355,7 +361,7 @@ defmodule MvWeb.MemberLive.Show do
</h3> </h3>
<p class="py-4"> <p class="py-4">
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.", {gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member) name: MemberHelpers.display_name(@member)
)} )}
</p> </p>
<div class="modal-action"> <div class="modal-action">
@ -402,13 +408,13 @@ defmodule MvWeb.MemberLive.Show do
# Load custom fields once using assign_new to avoid repeated queries # Load custom fields once using assign_new to avoid repeated queries
socket = socket =
assign_new(socket, :custom_fields, fn -> assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor) |> Ash.read!(actor: actor)
end) end)
query = query =
Mv.Membership.Member MemberResource
|> filter(id == ^id) |> filter(id == ^id)
|> load([ |> load([
:user, :user,
@ -527,7 +533,7 @@ defmodule MvWeb.MemberLive.Show do
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response = response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do case VereinfachtClient.get_contact_with_receipts(contact_id) do
{:ok, receipts} -> {:ok, receipts} {:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
@ -717,7 +723,7 @@ defmodule MvWeb.MemberLive.Show do
# Handles both CustomFieldValue structs and direct values # Handles both CustomFieldValue structs and direct values
defp format_custom_field_value(nil, _type), do: render_empty_value() defp format_custom_field_value(nil, _type), do: render_empty_value()
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do defp format_custom_field_value(%CustomFieldValue{} = cfv, value_type) do
format_custom_field_value(cfv.value, value_type) format_custom_field_value(cfv.value, value_type)
end end
@ -759,6 +765,6 @@ defmodule MvWeb.MemberLive.Show do
# Returns safe HTML so it can be used from helpers without LiveView assigns. # Returns safe HTML so it can be used from helpers without LiveView assigns.
defp render_empty_value do defp render_empty_value do
text = gettext("Not set") text = gettext("Not set")
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]} {:safe, ["<span class=\"sr-only\">", HTMLEngine.html_escape(text), "</span>"]}
end end
end end

View file

@ -35,7 +35,14 @@ defmodule MvWeb.UserLive.Form do
require Jason require Jason
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Authorization alias Mv.Authorization
alias Mv.Authorization.Role, as: RoleResource
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3] import MvWeb.Authorization, only: [can?: 3]
@ -303,7 +310,7 @@ defmodule MvWeb.UserLive.Form do
<% end %> <% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> <%= if @user && can?(@current_user, :destroy, @user) && !SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
@ -402,9 +409,9 @@ defmodule MvWeb.UserLive.Form do
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil} defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
defp load_user_or_redirect(id, actor, socket) do defp load_user_or_redirect(id, actor, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) user = Ash.get!(UserResource, id, domain: Accounts, load: [:member], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do if SystemActor.system_user?(user) do
{:redirect, {:redirect,
socket socket
|> put_flash(:error, gettext("This user cannot be edited.")) |> put_flash(:error, gettext("This user cannot be edited."))
@ -420,9 +427,9 @@ defmodule MvWeb.UserLive.Form do
page_title = action <> " " <> gettext("User") page_title = action <> " " <> gettext("User")
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation). # Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User) can_manage_member_linking = can?(actor, :destroy, UserResource)
# Only admins can assign user roles (Role update permission). # Only admins can assign user roles (Role update permission).
can_assign_role = can?(actor, :update, Mv.Authorization.Role) can_assign_role = can?(actor, :update, RoleResource)
roles = if can_assign_role, do: load_roles(actor), else: [] roles = if can_assign_role, do: load_roles(actor), else: []
{:ok, {:ok,
@ -541,7 +548,7 @@ defmodule MvWeb.UserLive.Form do
|> put_flash(:error, gettext("User not found")) |> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)} |> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) -> SystemActor.system_user?(user) ->
{:noreply, {:noreply,
socket socket
|> put_flash(:error, gettext("System user cannot be deleted.")) |> put_flash(:error, gettext("System user cannot be deleted."))
@ -634,7 +641,7 @@ defmodule MvWeb.UserLive.Form do
member_name = member_name =
if selected_member, if selected_member,
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member), do: MemberHelpers.display_name(selected_member),
else: "" else: ""
# Store the selected member ID and name in socket state and clear unlink flag # Store the selected member ID and name in socket state and clear unlink flag
@ -704,17 +711,17 @@ defmodule MvWeb.UserLive.Form do
defp perform_member_link_action(socket, user, actor) do defp perform_member_link_action(socket, user, actor) do
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it). # Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
if can?(actor, :destroy, Mv.Accounts.User) do if can?(actor, :destroy, UserResource) do
cond do cond do
# Selected member ID takes precedence (new link) # Selected member ID takes precedence (new link)
socket.assigns.selected_member_id -> socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}}, Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor actor: actor
) )
# Unlink flag is set # Unlink flag is set
socket.assigns[:unlink_member] -> socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor) Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship # No changes to member relationship
true -> true ->
@ -831,8 +838,8 @@ defmodule MvWeb.UserLive.Form do
# For new users, use password registration if password fields are shown # For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action, AshPhoenix.Form.for_create(UserResource, action,
domain: Mv.Accounts, domain: Accounts,
as: "user", as: "user",
actor: actor actor: actor
) )
@ -878,7 +885,7 @@ defmodule MvWeb.UserLive.Form do
search_query_str = if search_query && search_query != "", do: search_query, else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil
query = query =
Mv.Membership.Member MemberResource
|> Ash.Query.for_read(:available_for_linking, %{ |> Ash.Query.for_read(:available_for_linking, %{
user_email: user_email_str, user_email: user_email_str,
search_query: search_query_str search_query: search_query_str
@ -890,7 +897,7 @@ defmodule MvWeb.UserLive.Form do
if is_nil(actor) do if is_nil(actor) do
[] []
else else
case Ash.read(query, domain: Mv.Membership, actor: actor) do case Ash.read(query, domain: Membership, actor: actor) do
{:ok, members} -> apply_email_filter(members, user_email_str) {:ok, members} -> apply_email_filter(members, user_email_str)
{:error, _} -> [] {:error, _} -> []
end end
@ -902,7 +909,7 @@ defmodule MvWeb.UserLive.Form do
defp apply_email_filter(members, nil), do: members defp apply_email_filter(members, nil), do: members
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
Mv.Membership.Member.filter_by_email_match(members, user_email_str) MemberResource.filter_by_email_match(members, user_email_str)
end end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()] @spec load_roles(any()) :: [Mv.Authorization.Role.t()]

View file

@ -19,6 +19,10 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor
require Ash.Query require Ash.Query
@impl true @impl true
@ -26,9 +30,9 @@ defmodule MvWeb.UserLive.Index do
actor = current_actor(socket) actor = current_actor(socket)
users = users =
Mv.Accounts.User UserResource
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email()) |> Ash.Query.filter(email != ^SystemActor.system_user_email())
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor) |> Ash.read!(domain: Accounts, load: [:member, :role], actor: actor)
sorted = Enum.sort_by(users, & &1.email) sorted = Enum.sort_by(users, & &1.email)

View file

@ -29,6 +29,10 @@ defmodule MvWeb.UserLive.Show do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1] import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -167,9 +171,9 @@ defmodule MvWeb.UserLive.Show do
actor = current_actor(socket) actor = current_actor(socket)
user = user =
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor) Ash.get!(UserResource, id, domain: Accounts, load: [:member, :role], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do if SystemActor.system_user?(user) do
{:ok, {:ok,
socket socket
|> put_flash(:error, gettext("This user cannot be viewed.")) |> put_flash(:error, gettext("This user cannot be viewed."))
@ -221,7 +225,7 @@ defmodule MvWeb.UserLive.Show do
|> put_flash(:error, gettext("User not found")) |> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)} |> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) -> SystemActor.system_user?(user) ->
{:noreply, {:noreply,
socket socket
|> put_flash(:error, gettext("System user cannot be deleted.")) |> put_flash(:error, gettext("System user cannot be deleted."))

View file

@ -16,6 +16,7 @@ defmodule MvWeb.LiveHelpers do
``` ```
""" """
import Phoenix.Component import Phoenix.Component
alias Mv.Authorization.Actor
alias MvWeb.Plugs.CheckPagePermission alias MvWeb.Plugs.CheckPagePermission
def on_mount(:default, _params, session, socket) do def on_mount(:default, _params, session, socket) do
@ -68,7 +69,7 @@ defmodule MvWeb.LiveHelpers do
if user do if user do
# Use centralized Actor helper to ensure role is loaded # Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user) user_with_role = Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role) assign(socket, :current_user, user_with_role)
else else
socket socket

View file

@ -6,6 +6,9 @@ defmodule MvWeb.LiveUserAuth do
import Phoenix.Component import Phoenix.Component
use MvWeb, :verified_routes use MvWeb, :verified_routes
alias AshAuthentication.Phoenix.LiveSession
alias Phoenix.LiveView
# This is used for nested liveviews to fetch the current user. # This is used for nested liveviews to fetch the current user.
# To use, place the following at the top of that liveview: # To use, place the following at the top of that liveview:
# on_mount {MvWeb.LiveUserAuth, :current_user} # on_mount {MvWeb.LiveUserAuth, :current_user}
@ -15,7 +18,7 @@ defmodule MvWeb.LiveUserAuth do
socket = socket =
socket socket
|> assign(:return_to, return_to) |> assign(:return_to, return_to)
|> AshAuthentication.Phoenix.LiveSession.assign_new_resources(session) |> LiveSession.assign_new_resources(session)
{:cont, session, socket} {:cont, session, socket}
end end
@ -29,14 +32,14 @@ defmodule MvWeb.LiveUserAuth do
end end
def on_mount(:live_user_required, _params, session, socket) do def on_mount(:live_user_required, _params, session, socket) do
socket = AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session) socket = LiveSession.assign_new_resources(socket, session)
case socket.assigns do case socket.assigns do
%{current_user: %{} = user} -> %{current_user: %{} = user} ->
{:cont, assign(socket, :current_user, user)} {:cont, assign(socket, :current_user, user)}
_ -> _ ->
socket = Phoenix.LiveView.redirect(socket, to: ~p"/sign-in") socket = LiveView.redirect(socket, to: ~p"/sign-in")
{:halt, socket} {:halt, socket}
end end
end end

View file

@ -25,6 +25,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets alias Mv.Authorization.PermissionSets
require Logger require Logger
@ -37,7 +38,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
# Ensure role is loaded (load_from_session does not load it; required for permission check) # Ensure role is loaded (load_from_session does not load it; required for permission check)
user = user =
conn.assigns[:current_user] conn.assigns[:current_user]
|> Mv.Authorization.Actor.ensure_loaded() |> Actor.ensure_loaded()
conn = Plug.Conn.assign(conn, :current_user, user) conn = Plug.Conn.assign(conn, :current_user, user)
page_path = get_page_path(conn) page_path = get_page_path(conn)