Merge pull request 'Member Resource Policies closes #345' (#346) from feature/345_member_policies_2 into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #346
This commit is contained in:
moritz 2026-01-13 16:36:23 +01:00
commit e9bcfe4fa6
49 changed files with 2593 additions and 1102 deletions

View file

@ -32,6 +32,8 @@ lint:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo
# Check that all German translations are filled (UI must be in German)
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date
audit:
@ -116,4 +118,4 @@ init-prod-secrets:
# Start production environment with Docker Compose
start-prod: init-prod-secrets
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml up -d

View file

@ -1,318 +0,0 @@
---
name: Code Review Fixes - Membership Fee Features
overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen.
todos:
- id: fix-after-action-tasks
content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden"
status: pending
- id: reduce-code-duplication
content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden"
status: pending
dependencies:
- fix-after-action-tasks
- id: fix-join-date-validation
content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen"
status: pending
- id: fix-load-cycles-docs
content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)"
status: pending
- id: fix-get-current-cycle-sort
content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle"
status: pending
- id: fix-n1-query-member-count
content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen"
status: pending
- id: fix-assign-new-stale
content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen"
status: pending
- id: fix-regenerating-flag
content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen"
status: pending
- id: fix-create-cycle-parsing
content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error"
status: pending
- id: fix-delete-all-atomic
content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map"
status: pending
- id: improve-async-error-handling
content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung"
status: pending
- id: improve-format-currency
content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)"
status: pending
- id: add-missing-typespecs
content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen"
status: pending
- id: fix-race-condition
content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)"
status: pending
- id: extract-magic-values
content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)"
status: pending
- id: fix-domain-consistency
content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben"
status: pending
- id: fix-test-helper
content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen"
status: pending
- id: fix-date-utc-today-param
content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen"
status: pending
- id: fix-ui-locale-input
content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren"
status: pending
- id: fix-delete-confirmation
content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern"
status: pending
- id: fix-warning-state
content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen"
status: pending
- id: fix-double-toggle
content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)"
status: pending
- id: fix-format-consistency
content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren"
status: pending
dependencies:
- fix-ui-locale-input
---
# Code Review Fixes - Membership Fee Features
## Kritische Probleme (Müssen vor Merge behoben werden)
### 1. after_action mit Task.start - Transaktionsprobleme
**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279)
**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher:
- Tasks sehen möglicherweise noch nicht committed state
- Tasks werden auch bei Rollback gestartet
- Keine Supervision → Memory Leaks möglich
**Lösung:**
- `after_transaction` Hook verwenden (Ash Best Practice)
- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`)
- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden
**Betroffene Stellen:**
- Member Creation (Zeile 116-164)
- Join/Exit Date Change (Zeile 250-301)
### 2. Code-Duplikation in Cycle-Generation-Logik
**Datei:** `lib/membership/member.ex`
**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change)
**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2`
## Wichtige Probleme (Sollten behoben werden)
### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil
**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518)
**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt
**Lösung:** Dokumentation anpassen
### 4. load_cycles_for_members overpromises
**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40)
**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles
**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle)
### 5. get_current_cycle nondeterministisch
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182)
**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab
**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc)
### 6. N+1 Query durch get_member_count
**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140)
**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query
**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden
### 7. assign_new kann stale werden
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403)
**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert
**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen
### 8. @regenerating wird nie auf true gesetzt
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561)
**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled
**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen
### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812)
**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch
**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben:
```elixir
amount = case Decimal.parse(amount_str) do
{d, _} -> {:ok, d}
:error -> {:error, :invalid_amount}
end
```
### 10. Delete all cycles: nicht atomar
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714)
**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich
**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert)
### 11. Fehlerbehandlung bei async Tasks
**Datei:** `lib/membership/member.ex`
**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring.
**Lösung:**
- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren
- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung
- Strukturierte Error-Logs mit Context
- Optional: Error-Tracking (Sentry, etc.)
### 12. format_currency Robustheit
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51)
**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte).
**Lösung:**
- `Number.Currency` oder ähnliche Bibliothek verwenden
- Oder: Robusteres Pattern Matching für Edge Cases
- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen)
### 13. Fehlende Typespecs
**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex`
**Problem:** Keine `@spec` für die `change/3` Funktion.
**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support.
### 14. Potenzielle Race Condition
**Datei:** `lib/membership/member.ex` (Zeile 250-301)
**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung).
**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen).
### 15. Magic Numbers/Strings
**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet.
**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`).
## Mittlere Probleme (Nice-to-have)
### 16. Inconsistent use of domain
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821)
**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht
**Lösung:** Konsistent `domain` überall verwenden
### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52)
**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken
**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen
### 18. Tests/Design: Date.utc_today() macht Tests flaky
**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch
**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`)
### 19. UI/Locale: input type="number" + Decimal/Komma
**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner
**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren
### 20. Delete-all-Confirmation: String-Vergleich ist fragil
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298)
**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale
**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern
### 21. MembershipFeeType Form: Warning-State kann hängen bleiben
**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378)
**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben
**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen
### 22. UI/UX: Toggle ist doppelt vorhanden
**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296)
**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header
**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten)
### 23. Konsistenz: format_currency vs Inputs
**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt
**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren
## Implementierungsreihenfolge
1. **Kritisch:** after_action → after_transaction + Task.Supervisor
2. **Kritisch:** Code-Duplikation reduzieren
3. **Wichtig:** join_date Validierung/Dokumentation
4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung
5. **Wichtig:** get_current_cycle Sortierung
6. **Wichtig:** N+1 Query beheben
7. **Wichtig:** assign_new → assign
8. **Wichtig:** @regenerating auf true setzen
9. **Wichtig:** Create-cycle parsing Fix
10. **Wichtig:** Delete all cycles atomar
11. **Wichtig:** Fehlerbehandlung bei async Tasks
12. **Wichtig:** format_currency Robustheit
13. **Wichtig:** Fehlende Typespecs
14. **Wichtig:** Potenzielle Race Condition prüfen/beheben
15. **Wichtig:** Magic Numbers/Strings extrahieren
16. **Mittel:** Domain-Konsistenz
17. **Mittel:** Test-Helper Fix
18. **Mittel:** Date.utc_today() Parameter
19. **Mittel:** UI/Locale Fixes
20. **Mittel:** String-Vergleich robuster
21. **Mittel:** Warning-State Fix
22. **Mittel:** Toggle entfernen
23. **Mittel:** Format-Konsistenz

View file

@ -110,8 +110,8 @@ Control access to LiveView pages:
Three scope levels for permissions:
- **:own** - Only records where `record.id == user.id` (for User resource)
- **:linked** - Only records linked to user via relationships
- Member: `member.user_id == user.id`
- CustomFieldValue: `custom_field_value.member.user_id == user.id`
- Member: `id == user.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship)
- **:all** - All records, no filtering
**6. Special Cases**
@ -714,8 +714,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user_id == actor.id
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
## Error Handling
@ -799,12 +799,14 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member.user_id == actor.id (direct relationship)
{:filter, expr(user_id == ^actor.id)}
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" ->
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
{:filter, expr(member.user_id == ^actor.id)}
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_id)}
_ ->
# Fallback for other resources: try direct user_id
@ -918,7 +920,7 @@ end
**Location:** `lib/mv/membership/member.ex`
**Special Case:** Users can always access their linked member (where `member.user_id == user.id`).
**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
```elixir
defmodule Mv.Membership.Member do
@ -978,10 +980,10 @@ defmodule Mv.Membership.CustomFieldValue do
policies do
# SPECIAL CASE: Users can access custom field values of their linked member
# Note: This traverses the member relationship!
# Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id)
policy action_type([:read, :update]) do
description "Users can access custom field values of their linked member"
authorize_if expr(member.user_id == ^actor(:id))
authorize_if expr(member_id == ^actor(:member_id))
end
# GENERAL: Check permissions from role

View file

@ -294,7 +294,9 @@ Each Permission Set contains:
**:own** - Only records where id == actor.id
- Example: User can read their own User record
**:linked** - Only records where user_id == actor.id
**:linked** - Only records linked to actor via relationships
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
- Example: User can read Member linked to their account
**:all** - All records without restriction

View file

@ -34,10 +34,12 @@ defmodule Mv.Membership.Member do
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr
alias Mv.Helpers
require Logger
alias Mv.Membership.Helpers.VisibilityConfig
@ -118,11 +120,12 @@ defmodule Mv.Membership.Member do
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_transaction(fn _changeset, result, _context ->
change after_transaction(fn changeset, result, _context ->
case result do
{:ok, member} ->
if member.membership_fee_type_id && member.join_date do
handle_cycle_generation(member)
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end
{:error, _} ->
@ -193,7 +196,9 @@ 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
case regenerate_cycles_on_type_change(member) do
actor = Map.get(changeset.context, :actor)
case regenerate_cycles_on_type_change(member, actor: actor) do
{:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
@ -225,7 +230,8 @@ 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
handle_cycle_generation(member)
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end
{:error, _} ->
@ -296,6 +302,41 @@ defmodule Mv.Membership.Member do
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed (for test fixtures)
# In production/dev: ALL operations denied without actor (fail-closed for security)
# NoActor.check uses compile-time environment detection to prevent security issues
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# SPECIAL CASE: Users can always READ their linked member
# This allows users with ANY permission set to read their own linked member
# Check using the inverse relationship: User.member_id → Member.id
bypass action_type(:read) do
description "Users can always read member linked to their account"
authorize_if expr(id == ^actor(:member_id))
end
# GENERAL: Check permissions from user's role
# HasPermission handles update permissions correctly:
# - :own_data → can update linked member (scope :linked)
# - :read_only → cannot update any member (no update permission)
# - :normal_user → can update all members (scope :all)
# - :admin → can update all members (scope :all)
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: Forbid if no policy matched
# Ash implicitly forbids if no policy authorized
end
@doc """
Filters members list based on email match priority.
@ -363,8 +404,13 @@ defmodule Mv.Membership.Member do
user_id = user_arg[:id]
current_member_id = changeset.data.id
# Get actor from changeset context for authorization
# If no actor is present, this will fail in production (fail-closed)
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database
case Ash.get(Mv.Accounts.User, user_id) do
# Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
@ -742,33 +788,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) do
def regenerate_cycles_on_type_change(member, opts \\ []) do
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)
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
else
regenerate_cycles_new_transaction(member, today, lock_key)
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
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) do
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
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)
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
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) do
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
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) do
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
@ -790,6 +840,7 @@ defmodule Mv.Membership.Member do
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
@ -799,10 +850,21 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) do
result =
if actor do
Ash.read(all_unpaid_cycles_query, actor: actor)
else
Ash.read(all_unpaid_cycles_query)
end
case result 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?)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
skip_lock?: skip_lock?,
actor: actor
)
{:error, reason} ->
{:error, reason}
@ -831,13 +893,14 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, 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?)
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
{:error, reason} -> {:error, reason}
end
end
@ -863,11 +926,13 @@ 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?
skip_lock?: skip_lock?,
actor: actor
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
@ -881,21 +946,25 @@ 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
defp handle_cycle_generation(member) do
defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member)
handle_cycle_generation_sync(member, actor: actor)
else
handle_cycle_generation_async(member)
handle_cycle_generation_async(member, actor: actor)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member) do
defp handle_cycle_generation_sync(member, opts) do
require Logger
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today()
today: Date.utc_today(),
actor: actor
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
@ -907,9 +976,11 @@ defmodule Mv.Membership.Member do
end
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member) do
defp handle_cycle_generation_async(member, opts) do
actor = Keyword.get(opts, :actor)
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false)
@ -1138,15 +1209,18 @@ defmodule Mv.Membership.Member do
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
if is_nil(custom_field_values_arg) do
extract_existing_values(changeset.data)
extract_existing_values(changeset.data, changeset)
else
extract_argument_values(custom_field_values_arg)
end
end
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data) do
case Ash.load(member_data, :custom_field_values) do
defp extract_existing_values(member_data, changeset) do
actor = Map.get(changeset.context, :actor)
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)

View file

@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!)
- Member: `id == actor.member_id` (User.member_id Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id Member.id User.member_id)
## Error Handling
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
record = get_record_from_authorizer(authorizer)
cond do
is_nil(actor) ->
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
{:ok, false}
true ->
strict_check_with_permissions(actor, resource, action)
strict_check_with_permissions(actor, resource, action, record)
end
end
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action) do
defp strict_check_with_permissions(actor, resource, action, record) do
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),
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor,
resource_name
) do
:authorized -> {:ok, true}
{:filter, _} -> {:ok, :unknown}
false -> {:ok, false}
:authorized ->
{: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)
false ->
{:ok, false}
end
else
%{role: nil} ->
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) -> nil
is_nil(action) -> nil
true -> auto_filter_with_permissions(actor, resource, action)
is_nil(actor) ->
# No actor - deny access (fail-closed)
# Return filter that never matches (expr(false) = match none)
deny_filter()
is_nil(action) ->
# Cannot determine action - deny access (fail-closed)
deny_filter()
true ->
auto_filter_with_permissions(actor, resource, action)
end
end
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor,
resource_name
) do
:authorized -> nil
{:filter, filter_expr} -> filter_expr
false -> nil
:authorized ->
# :all scope - allow all records (no filter)
# Return empty keyword list (no filtering)
[]
{:filter, filter_expr} ->
# :linked or :own scope - apply filter
# filter_expr is a keyword list from expr(...), return it directly
filter_expr
false ->
# No permission - deny access (fail-closed)
deny_filter()
end
else
_ ->
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
deny_filter()
end
end
# Helper function to return a filter that never matches (deny all records)
# Used when authorization should be denied (fail-closed)
#
# Using `expr(false)` avoids depending on the primary key being named `:id`.
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
defp deny_filter do
expr(false)
end
# Helper to extract action type from authorizer
# CRITICAL: Must use action_type, not action.name!
# Action types: :create, :read, :update, :destroy
# Action names: :create_member, :update_member, etc.
# PermissionSets uses action types, not action names
#
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
defp get_action_from_authorizer(authorizer) do
# Primary: Use authorizer.action.type (stable API)
case Map.get(authorizer, :action) do
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
case Map.get(authorizer, :subject) do
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
%{action: %{type: action_type}}
when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
nil
end
end
end
# Helper to extract record from authorizer for strict_check
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
# Helper to extract action from authorizer
defp get_action_from_authorizer(authorizer) do
case authorizer.subject do
%{action: %{name: action}} -> action
%{action: action} when is_atom(action) -> action
_ -> nil
# Evaluate filter expression for strict_check on single records
# 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
{"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
{:ok, true}
else
{:ok, false}
end
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
# Check if this CFV's member_id matches the actor's member_id
if cfv_member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
_ ->
# For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
{:ok, :unknown}
end
end
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Scope: linked - Filter based on user relationship (resource-specific!)
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
# IMPORTANT: Understand the relationship direction!
# - User belongs_to :member (User.member_id → Member.id)
# - Member has_one :user (inverse, no FK on Member)
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member has_one :user → filter by user.id == actor.id
{:filter, expr(user.id == ^actor.id)}
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user
# Traverse: custom_field_value.member.user.id == actor.id
{:filter, expr(member.user.id == ^actor.id)}
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_id)}
_ ->
# Fallback for other resources: try user relationship first, then user_id
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
# Fallback for other resources
{:filter, expr(user_id == ^actor.id)}
end
end

View file

@ -0,0 +1,74 @@
defmodule Mv.Authorization.Checks.NoActor do
@moduledoc """
Custom Ash Policy Check that allows actions when no actor is present.
**IMPORTANT:** This check ONLY works in test environment for security reasons.
In production/dev, ALL operations without an actor are denied.
## Security Note
This check uses compile-time environment detection to prevent accidental
security issues in production. In production, ALL operations (including :create
and :read) will be denied if no actor is present.
For seeds and system operations in production, use an admin actor instead:
admin_user = get_admin_user()
Ash.create!(resource, attrs, actor: admin_user)
## Usage in Policies
policies do
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed
# In production: ALL operations denied (fail-closed)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if NoActor
end
# Check permissions when actor is present
policy action_type([:read, :create, :update, :destroy]) do
authorize_if HasPermission
end
end
## Behavior
- In test environment: Returns `true` when actor is nil (allows all operations)
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
- Returns `false` when actor is present (delegates to other policies)
"""
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)
@impl true
def describe(_opts) do
if @allow_no_actor_bypass do
"allows actions when no actor is present (test environment only)"
else
"denies all actions when no actor is present (production/dev - fail-closed)"
end
end
@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
end
def match?(_actor, _context, _opts) do
# Actor is present - don't match (let other policies decide)
false
end
end

View file

@ -41,8 +41,10 @@ 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) do
linked_user <- Loader.get_linked_user(member, actor) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result

View file

@ -33,7 +33,17 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
# 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)
end
end
@ -42,7 +52,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) do
{:ok, user, member} <- get_user_and_member(record, cs) 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
@ -61,15 +71,19 @@ 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) do
case Loader.get_linked_member(user) do
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
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
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
{:ok, user} -> {:ok, user, member}
error -> error
end

View file

@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
## 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.
All functions accept an optional `actor` parameter that is passed to Ash operations
to ensure proper authorization checks are performed.
"""
alias Mv.Helpers
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do
case Ash.get(Mv.Membership.Member, id) do
Accepts optional actor for authorization.
"""
def get_linked_member(user, actor \\ nil)
def get_linked_member(%{member_id: nil}, _actor), do: nil
def get_linked_member(%{member_id: id}, actor) do
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
{:error, _} -> nil
end
@ -18,9 +33,13 @@ 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.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
def get_linked_user(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
@doc """
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.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
def load_linked_user!(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error

27
lib/mv/helpers.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Mv.Helpers do
@moduledoc """
Shared helper functions used across the Mv application.
Provides utilities that are not specific to a single domain or layer.
"""
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
This helper ensures consistent actor handling across all Ash operations
in the application, whether called from LiveViews, changes, validations,
or other contexts.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
opts = ash_actor_opts(nil)
# => []
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
def ash_actor_opts(nil), do: []
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
end

View file

@ -8,6 +8,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
alias Mv.Helpers
@doc """
Validates email uniqueness across linked Member-User pairs.
@ -29,7 +30,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
actor = Map.get(changeset.context || %{}, :actor)
linked_user_id = get_linked_user_id(changeset.data, actor)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@ -38,19 +40,21 @@ 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)
check_email_uniqueness(new_email, linked_user_id, actor)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
defp check_email_uniqueness(email, exclude_user_id, actor) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
@ -65,8 +69,10 @@ 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) do
case Ash.load(member_data, :user) do
defp get_linked_user_id(member_data, actor) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end

View file

@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
Uses PostgreSQL advisory locks to prevent race conditions when generating
cycles for the same member concurrently.
## 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.
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.
## Examples
# Generate cycles for a single member
@ -77,7 +86,9 @@ 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
case load_member(member_id) do
actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -87,25 +98,27 @@ 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?)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
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?) do
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
do_generate_cycles(member, today)
actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
end
defp do_generate_cycles_with_lock(member, today, false) do
defp do_generate_cycles_with_lock(member, today, false, opts) 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) do
case do_generate_cycles(member, today, actor: actor) 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)
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id) do
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|> Ash.read_one()
|> case do
defp load_member(member_id, opts) do
actor = Keyword.get(opts, :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
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today) do
defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
# Reload member with relationships to ensure fresh data
case load_member(member.id) do
case load_member(member.id, actor: actor) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today)
generate_missing_cycles(member, today, actor: actor)
end
{:error, reason} ->
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today) do
defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -270,7 +296,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)
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
else
{:ok, [], []}
end
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :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})
@ -380,7 +407,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
cycle_start
)
end)

View file

@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
{:ok, custom_field} ->
action =
case socket.assigns.form.source.type do

View file

@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.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
def render(assigns) do
~H"""
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField)
members = Ash.read!(Mv.Membership.Member)
actor = current_actor(socket)
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok,
socket
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
custom_field_value_params
end
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
actor = current_actor(socket)
case submit_form(socket.assigns.form, updated_params, actor) do
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})

View file

@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
"""
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"""
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
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
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
Ash.destroy!(custom_field_value)
actor = MvWeb.LiveHelpers.current_actor(socket)
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
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

@ -90,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
{:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()

View file

@ -21,6 +21,10 @@ 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
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate_membership_fee_type"
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<option value="">{gettext("None")}</option>
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket)
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_custom_field_values =
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
member =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
end
page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
available_fee_types = load_available_fee_types(member)
available_fee_types = load_available_fee_types(member, actor)
{:ok,
socket
@ -265,53 +271,80 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def handle_event("validate", %{"member" => member_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
# Merge with existing form values to preserve unchanged fields (especially custom_field_values)
# Extract values directly from form fields to get current state
existing_values = get_existing_form_values(socket.assigns.form)
# Merge existing values with new params (new params take precedence)
merged_params = Map.merge(existing_values, member_params)
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
# Check for interval mismatch if membership_fee_type_id changed
socket = check_interval_change(socket, member_params)
socket = check_interval_change(socket, merged_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event(
"validate_membership_fee_type",
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
socket
) do
# Same validation as above, but triggered by select change
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
end
def handle_event("save", %{"member" => member_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
{:ok, member} ->
notify_parent({:saved, member})
try do
actor = current_actor(socket)
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
case submit_form(socket.assigns.form, member_params, actor) do
{:ok, member} ->
handle_save_success(socket, member)
socket =
socket
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
end
end
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
flash_message =
case socket.assigns.form.source.type do
:create -> gettext("Member created successfully")
:update -> gettext("Member updated successfully")
other -> gettext("Member %{action} successfully", action: get_action_name(other))
end
socket =
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
end
defp handle_save_forbidden(socket) do
# Handle policy violations that aren't properly displayed in forms
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
action = get_action_name(socket.assigns.form.source.type)
error_message =
gettext("You do not have permission to %{action} members.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end
defp get_action_name(:create), do: gettext("create")
defp get_action_name(:update), do: gettext("update")
defp get_action_name(other), do: to_string(other)
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{member: member}} = socket) do
defp assign_form(%{assigns: assigns} = socket) do
member = assigns.member
actor = assigns[:current_user] || assigns.current_user
form =
if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
{:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
existing_custom_field_values =
member.custom_field_values
@ -342,7 +375,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: params,
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
missing_custom_field_values =
@ -360,7 +394,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
end
@ -375,11 +410,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions
# -----------------------------------------------------------------
defp load_available_fee_types(member) do
defp load_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
|> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval
if member && member.membership_fee_type do
@ -453,4 +488,167 @@ defmodule MvWeb.MemberLive.Form do
defp custom_field_input_type(:date), do: "date"
defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text"
# -----------------------------------------------------------------
# Helper Functions for Form Value Preservation
# -----------------------------------------------------------------
# Helper to extract existing form values to preserve them when only one field changes
# This ensures custom_field_values and other fields are preserved when only the dropdown changes
defp get_existing_form_values(form) do
%{}
|> extract_form_value(form, :first_name, &to_string/1)
|> extract_form_value(form, :last_name, &to_string/1)
|> extract_form_value(form, :email, &to_string/1)
|> extract_form_value(form, :street, &to_string/1)
|> extract_form_value(form, :house_number, &to_string/1)
|> extract_form_value(form, :postal_code, &to_string/1)
|> extract_form_value(form, :city, &to_string/1)
|> extract_form_value(form, :join_date, &format_date_value/1)
|> extract_form_value(form, :exit_date, &format_date_value/1)
|> extract_form_value(form, :notes, &to_string/1)
|> extract_form_value(form, :membership_fee_type_id, &to_string/1)
|> extract_form_value(form, :membership_fee_start_date, &format_date_value/1)
|> extract_custom_field_values(form)
end
# Helper to extract a single form field value
defp extract_form_value(acc, form, field, formatter) do
if form[field] && form[field].value do
Map.put(acc, to_string(field), formatter.(form[field].value))
else
acc
end
end
# Extracts custom field values from the form structure
# The form is a Phoenix.HTML.Form with source being AshPhoenix.Form
# Custom field values are in form.source.params["custom_field_values"] as a map
defp extract_custom_field_values(acc, form) do
cfv_params = get_custom_field_values_params(form)
if map_size(cfv_params) > 0 do
custom_field_values = convert_cfv_params_to_list(cfv_params)
Map.put(acc, "custom_field_values", custom_field_values)
else
acc
end
end
# Gets custom_field_values from form params
defp get_custom_field_values_params(form) do
ash_form = form.source
if ash_form && Map.has_key?(ash_form, :params) && ash_form.params["custom_field_values"] do
ash_form.params["custom_field_values"]
else
%{}
end
end
# Converts custom field values map to sorted list
defp convert_cfv_params_to_list(cfv_params) do
cfv_params
|> Map.to_list()
|> Enum.sort_by(&parse_numeric_key/1)
|> Enum.map(&build_custom_field_value/1)
end
# Parses numeric key for sorting
defp parse_numeric_key({key, _}) do
case Integer.parse(key) do
{num, _} -> num
:error -> 999_999
end
end
# Builds a custom field value map from params
defp build_custom_field_value({_key, cfv_map}) do
%{
"custom_field_id" => Map.get(cfv_map, "custom_field_id", ""),
"value" => extract_custom_field_value_from_map(Map.get(cfv_map, "value", %{}))
}
end
# Extracts the value map structure from a custom field value
# Handles both map format and Ash.Union struct format
defp extract_custom_field_value_from_map(%Ash.Union{} = union) do
union_type = Atom.to_string(union.type)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(union.value)
}
end
defp extract_custom_field_value_from_map(value_map) when is_map(value_map) do
union_type = extract_union_type_from_map(value_map)
value = Map.get(value_map, "value") || Map.get(value_map, :value)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(value)
}
end
defp extract_custom_field_value_from_map(_),
do: %{"_union_type" => "", "type" => "", "value" => ""}
# Extracts union type from map, checking various possible locations
defp extract_union_type_from_map(value_map) do
cond do
has_non_empty_string(value_map, "_union_type") ->
Map.get(value_map, "_union_type")
has_non_empty_atom(value_map, :_union_type) ->
to_string(Map.get(value_map, :_union_type))
has_atom_type(value_map) ->
Atom.to_string(Map.get(value_map, :type))
has_string_type(value_map) ->
Map.get(value_map, "type")
true ->
""
end
end
# Helper to check if map has non-empty string value
defp has_non_empty_string(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has non-empty atom value
defp has_non_empty_atom(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has atom type
defp has_atom_type(map) do
value = Map.get(map, :type)
value && is_atom(value)
end
# Helper to check if map has string type
defp has_string_type(map) do
value = Map.get(map, "type")
value && is_binary(value)
end
# Formats custom field value based on its type
defp format_custom_field_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_custom_field_value(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
defp format_custom_field_value(value) when is_boolean(value), do: to_string(value)
defp format_custom_field_value(value) when is_binary(value), do: value
defp format_custom_field_value(value), do: to_string(value)
# Formats date value (Date or string) to string
defp format_date_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_date_value(value) when is_binary(value), do: value
defp format_date_value(_), do: ""
end

View file

@ -27,8 +27,11 @@ 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]
alias Mv.Membership
alias MvWeb.Helpers.DateFormatter
@ -55,20 +58,21 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
# This is appropriate for initialization errors that should be visible to the user.
actor = current_actor(socket)
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries
settings =
@ -130,13 +134,41 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
actor = current_actor(socket)
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)}
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:info, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
@impl true
@ -236,6 +268,24 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error) do
inspect(error)
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@ -676,9 +726,9 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.custom_fields_visible
)
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews
members = Ash.read!(query)
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore

View file

@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
"""
use MvWeb, :live_view
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@ -148,9 +151,9 @@ defmodule MvWeb.MemberLive.Show do
</div>
<%!-- Custom Fields Section --%>
<%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Additional Data Fields")}>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
@ -220,6 +223,7 @@ defmodule MvWeb.MemberLive.Show do
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
/>
<% end %>
</Layouts.app>
@ -233,15 +237,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
# Load custom fields for display
# Note: Each page load starts a new LiveView process, so caching with
# assign_new is not necessary here (mount creates a fresh socket each time)
custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
actor = current_actor(socket)
socket = assign(socket, :custom_fields, custom_fields)
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
end)
query =
Mv.Membership.Member
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
membership_fee_cycles: [:membership_fee_type]
])
member = Ash.read_one!(query)
member = Ash.read_one!(query, actor: actor)
# Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member)

View file

@ -13,12 +13,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
use MvWeb, :live_component
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -63,7 +65,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete All Cycles")}
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
@ -168,7 +170,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete Cycle")}
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
@ -329,14 +331,16 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
<label class="label">
<span class="label-text-alt">
{gettext("The cycle will be calculated based on this date and the interval.")}
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle")}</span>
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@ -388,6 +392,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def update(assigns, socket) do
member = assigns.member
actor = assigns.current_user
# Load cycles if not already loaded
cycles =
@ -401,7 +406,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member)
available_fee_types = get_available_fee_types(member, actor)
{:ok,
socket
@ -422,7 +427,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do
actor = current_actor(socket)
case update_member_fee_type(socket.assigns.member, nil, actor) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
@ -430,7 +437,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -441,7 +451,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
actor = current_actor(socket)
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
# Check if interval matches
interval_warning =
@ -459,15 +470,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
case update_member_fee_type(member, fee_type_id) do
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
updated_member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -482,7 +500,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -503,7 +524,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended
end
case Ash.update(cycle, action: action, domain: MembershipFees) do
actor = current_actor(socket)
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -533,16 +556,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
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) do
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -572,7 +601,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :editing_cycle, cycle)}
end
@ -589,9 +619,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
actor = current_actor(socket)
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do
|> Ash.update(domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -616,7 +648,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :deleting_cycle, cycle)}
end
@ -627,8 +660,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
actor = current_actor(socket)
case Ash.destroy(cycle, domain: MembershipFees) do
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -699,12 +733,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if deleted_count > 0 do
# Reload member to get updated cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles =
Enum.sort_by(
@ -786,15 +825,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
actor = current_actor(socket)
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -842,11 +886,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions
defp get_available_fee_types(member) do
defp get_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval
if member.membership_fee_type do
@ -858,12 +902,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
end
defp update_member_fee_type(member, fee_type_id) do
defp update_member_fee_type(member, fee_type_id, actor) do
attrs = %{membership_fee_type_id: fee_type_id}
member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership)
|> Ash.update(domain: Membership, actor: actor)
end
defp find_cycle(cycles, cycle_id) do

View file

@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Map.put(params, "include_joining_cycle", false)
end
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do
{:ok, updated_settings} ->
{:noreply,
socket

View file

@ -13,6 +13,9 @@ 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
alias Mv.Membership.Member
@ -305,7 +308,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
if socket.assigns.show_amount_warning do
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
else
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
actor = current_actor(socket)
case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type})
@ -380,7 +385,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
@spec get_affected_member_count(String.t()) :: non_neg_integer()
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
# Checks if amount changed and updates socket assigns accordingly
defp check_amount_change(socket, params) do
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
@ -428,7 +433,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket.assigns.affected_member_count
else
# Warning being shown for first time, calculate count
get_affected_member_count(socket.assigns.membership_fee_type.id)
get_affected_member_count(socket.assigns.membership_fee_type.id, current_actor(socket))
end
socket
@ -446,8 +451,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|> assign(:pending_amount, nil)
end
defp get_affected_member_count(fee_type_id) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
defp get_affected_member_count(fee_type_id, actor) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id),
actor: actor
) do
{:ok, count} -> count
_ -> 0
end

View file

@ -14,6 +14,9 @@ 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
alias Mv.Membership
@ -24,8 +27,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true
def mount(_params, _session, socket) do
fee_types = load_membership_fee_types()
member_counts = load_member_counts(fee_types)
actor = current_actor(socket)
fee_types = load_membership_fee_types(actor)
member_counts = load_member_counts(fee_types, actor)
{:ok,
socket
@ -129,18 +133,43 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
actor = current_actor(socket)
case Ash.destroy(fee_type, domain: MembershipFees) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@ -149,14 +178,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
# Helper functions
defp load_membership_fee_types do
defp load_membership_fee_types(actor) do
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
|> Ash.read!(domain: MembershipFees, actor: actor)
end
# Loads all member counts for fee types in a single query to avoid N+1 queries
defp load_member_counts(fee_types) do
defp load_member_counts(fee_types, actor) do
fee_type_ids = Enum.map(fee_types, & &1.id)
# Load all members with membership_fee_type_id in a single query
@ -164,7 +193,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership)
|> Ash.read!(domain: Membership, actor: actor)
# Group by membership_fee_type_id and count
members

View file

@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
end
def handle_event("save", %{"role" => role_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} ->
notify_parent({:saved, role})

View file

@ -118,7 +118,7 @@ defmodule MvWeb.RoleLive.Index do
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = if actor, do: [actor: actor], else: []
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name)

View file

@ -33,6 +33,9 @@ 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
def render(assigns) do
~H"""
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
@impl true
def mount(params, _session, socket) do
actor = current_actor(socket)
user =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
socket =
if Map.has_key?(user_params, "email") do
user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
assign(socket, form: validated_form, available_members: members)
else
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} ->
# Then handle member linking/unlinking as a separate step
result =
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil})
# No changes to member relationship
true ->
{:ok, user}
end
case result do
{:ok, updated_user} ->
notify_parent({:saved, updated_user})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
{:error, error} ->
# Show user-friendly error from member linking/unlinking
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
handle_member_linking(socket, user, actor)
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do
socket =
socket
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do
# Find the selected member to get their name
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
@ -448,27 +428,82 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, member_name)
|> assign(:unlink_member, false)
|> assign(:show_member_dropdown, false)
|> assign(:member_search_query, member_name)
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("unlink_member", _params, socket) do
# Set flag to unlink member on save
# Clear all member selection state and keep dropdown hidden
socket =
socket
|> assign(:unlink_member, true)
|> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil)
|> assign(:member_search_query, "")
|> assign(:unlink_member, true)
|> assign(:show_member_dropdown, false)
|> load_initial_members()
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)
case result do
{:ok, updated_user} ->
handle_save_success(socket, updated_user)
{:error, error} ->
handle_member_link_error(socket, error)
end
end
defp perform_member_link_action(socket, user, actor) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship
true ->
{:ok, user}
end
end
defp handle_save_success(socket, updated_user) do
notify_parent({:saved, updated_user})
action = get_action_name(socket.assigns.form.source.type)
socket =
socket
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
end
defp get_action_name(:create), do: gettext("created")
defp get_action_name(:update), do: gettext("updated")
defp get_action_name(other), do: to_string(other)
defp handle_member_link_error(socket, error) do
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@ -497,18 +532,21 @@ defmodule MvWeb.UserLive.Form do
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
actor = current_actor(socket)
form =
if user do
# For existing users, use admin password action if password fields are shown
action = if show_password_fields, do: :admin_set_password, else: :update_user
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user")
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
else
# For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
domain: Mv.Accounts,
as: "user"
as: "user",
actor: actor
)
end
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, "")
members = load_members_for_linking(user_email, "", socket)
# Dropdown should ALWAYS be hidden initially
# It will only show when user focuses the input field (show_member_dropdown event)
@ -539,12 +577,15 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, query)
members = load_members_for_linking(user_email, query, socket)
assign(socket, available_members: members)
end
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
defp load_members_for_linking(user_email, search_query) do
@spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
[
Mv.Membership.Member.t()
]
defp load_members_for_linking(user_email, search_query, socket) do
user_email_str = if user_email, do: to_string(user_email), else: nil
search_query_str = if search_query && search_query != "", do: search_query, else: nil
@ -555,20 +596,27 @@ defmodule MvWeb.UserLive.Form do
search_query: search_query_str
})
case Ash.read(query, domain: Mv.Membership) do
{:ok, members} ->
# Apply email match filter if user_email is provided
if user_email_str do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
else
members
end
actor = current_actor(socket)
{:error, _} ->
[]
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
[]
else
case Ash.read(query, domain: Mv.Membership, actor: actor) do
{:ok, members} -> apply_email_filter(members, user_email_str)
{:error, _} -> []
end
end
end
@spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
[Mv.Membership.Member.t()]
defp apply_email_filter(members, nil), do: members
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)
end
# Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
@ -576,10 +624,10 @@ defmodule MvWeb.UserLive.Form do
case List.first(errors) do
%{message: message} when is_binary(message) -> message
%{field: field, message: message} -> "#{field}: #{message}"
_ -> "Unknown error"
_ -> gettext("Unknown error")
end
end
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: "Unknown error"
defp extract_error_message(_), do: gettext("Unknown error")
end

View file

@ -23,9 +23,13 @@ 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
def mount(_params, _session, socket) do
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
actor = current_actor(socket)
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
sorted = Enum.sort_by(users, & &1.email)
{:ok,
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts)
actor = current_actor(socket)
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply, assign(socket, :users, updated_users)}
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:ok, user} ->
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply,
socket
|> assign(:users, updated_users)
|> put_flash(:info, gettext("User deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
# Selects one user in the list of users
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2
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

@ -26,6 +26,9 @@ 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
def render(assigns) do
~H"""
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
{:ok,
socket

View file

@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.
Provides consistent access pattern across all LiveViews.
Returns nil if no current_user is present.
## Examples
actor = current_actor(socket)
members = Membership.list_members!(actor: actor)
"""
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
def current_actor(socket) do
socket.assigns[:current_user] || socket.assigns.current_user
end
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
defdelegate ash_actor_opts(actor), to: Mv.Helpers
@doc """
Submits an AshPhoenix form with consistent actor handling.
This wrapper ensures that actor is always passed via `action_opts`
in a consistent manner across all LiveViews.
## Examples
case submit_form(form, params, actor) do
{:ok, resource} -> # success
{:error, form} -> # validation errors
end
"""
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
def submit_form(form, params, actor) do
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
end
end

View file

@ -76,6 +76,7 @@ defmodule Mv.MixProject do
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"}
]

View file

@ -60,6 +60,7 @@
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},

View file

@ -8,7 +8,7 @@
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Language: de\n"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.ex
@ -633,6 +633,7 @@ msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
@ -1312,14 +1313,14 @@ msgid "These fields are neccessary for MILA to handle member identification and
msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Member field %{action} successfully"
msgstr "Mitglied wurde erfolgreich %{action}"
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "A cycle for this period already exists"
msgstr "Für dieses Intervall besteht bereits ein Zyklus."
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1366,7 +1367,7 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr "Klicke um den Betrag zu ändern"
msgstr "Klicken Sie, um den Betrag zu bearbeiten"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1396,12 +1397,12 @@ msgstr "erstellt"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Cycle"
msgstr "Aktueller Zyklus"
msgstr "Zyklus erstellen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr "Erstelle manuell einen neuen Zyklus"
msgstr "Einen neuen Zyklus manuell erstellen"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1431,7 +1432,7 @@ msgstr "Zyklusbetrag aktualisiert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle created successfully"
msgstr "Zyklen erfolgreich regeneriert"
msgstr "Zyklus erfolgreich erstellt"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -1474,9 +1475,9 @@ msgid "Edit Cycle Amount"
msgstr "Zyklusbetrag bearbeiten"
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Edit Field: %{field}"
msgstr "Mitglied bearbeiten"
msgstr "Feld bearbeiten: %{field}"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1524,9 +1525,9 @@ msgid "Invalid amount format"
msgstr "Ungültiges Betragsformat"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Invalid date format"
msgstr "Ungültiges Betragsformat"
msgstr "Ungültiges Datumsformat"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1679,9 +1680,9 @@ msgid "Not set"
msgstr "Nicht gesetzt"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Payment Interval"
msgstr "Zahlungsfilter"
msgstr "Zahlungsintervall"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1762,7 +1763,7 @@ msgstr "Art"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr "Trage '%{confirmation}' ein um zu bestätigen"
msgstr "Geben Sie '%{confirmation}' ein, um zu bestätigen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1825,11 +1826,6 @@ msgstr "Mitgliedsbeitragsstatus"
msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "The cycle will be calculated based on this date and the interval."
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings"
@ -1879,78 +1875,78 @@ msgstr "Zurück zur Rollen-Liste"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Cannot delete system role"
msgstr ""
msgstr "System-Rolle kann nicht gelöscht werden"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Custom"
msgstr "Benutzerdefinierte Felder"
msgstr "Benutzerdefiniert"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Edit Role"
msgstr "Bearbeiten"
msgstr "Rolle bearbeiten"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}"
msgstr "Rolle konnte nicht gelöscht werden: %{error}"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Listing Roles"
msgstr "Benutzer*innen auflisten"
msgstr "Rollen auflisten"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Manage user roles and their permission sets."
msgstr ""
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
msgstr ""
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weisen Sie sie zunächst einer anderen Rolle zu."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
msgstr ""
msgstr "Sidebar schließen"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Delete Role"
msgstr "Zyklus löschen"
msgstr "Rolle löschen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Main navigation"
msgstr ""
msgstr "Hauptnavigation"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "New Role"
msgstr ""
msgstr "Neue Rolle"
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "No description"
msgstr "Beschreibung"
msgstr "Keine Beschreibung"
#: lib/mv_web/components/layouts.ex
#, elixir-autogen, elixir-format
msgid "Open navigation menu"
msgstr ""
msgstr "Navigationsmenü öffnen"
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Permission Set"
msgstr ""
msgstr "Berechtigungssatz"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1961,250 +1957,234 @@ msgstr "Profil"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role"
msgstr ""
msgstr "Rolle"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Role deleted successfully."
msgstr "Zyklen erfolgreich regeneriert"
msgstr "Rolle erfolgreich gelöscht."
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role details and permissions."
msgstr ""
msgstr "Rollen-Details und Berechtigungen."
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role not found."
msgstr ""
msgstr "Rolle nicht gefunden."
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Role saved successfully."
msgstr ""
msgstr "Rolle erfolgreich gespeichert."
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Save Role"
msgstr "Speichern"
msgstr "Rolle speichern"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select permission set"
msgstr ""
msgstr "Berechtigungssatz auswählen"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Show Role"
msgstr "Anzeigen"
msgstr "Rolle anzeigen"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "System"
msgstr ""
msgstr "System"
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "System Role"
msgstr ""
msgstr "System-Rolle"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "System roles cannot be deleted"
msgstr ""
msgstr "System-Rollen können nicht gelöscht werden"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "System roles cannot be deleted."
msgstr ""
msgstr "System-Rollen können nicht gelöscht werden."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Toggle sidebar"
msgstr ""
msgstr "Sidebar umschalten"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
msgstr "Verwenden Sie dieses Formular, um Rollen in Ihrer Datenbank zu verwalten."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "User menu"
msgstr "Benutzer*in"
msgstr "Benutzer*innen-Menü"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "admin - Unrestricted access"
msgstr ""
msgstr "admin - Uneingeschränkter Zugriff"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "normal_user - Create/Read/Update access"
msgstr ""
msgstr "normal_user - Erstellen/Lesen/Aktualisieren Zugriff"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "own_data - Access only to own data"
msgstr ""
msgstr "own_data - Zugriff nur auf eigene Daten"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "read_only - Read access to all data"
msgstr ""
msgstr "read_only - Lesezugriff auf alle Daten"
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Additional Data Fields"
msgstr "Zusätzliche Datenfelder"
msgid "You do not have permission to %{action} members."
msgstr "Sie haben keine Berechtigung, Mitglieder zu %{action}."
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle Period"
msgstr "Zykluszeitraum"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Configure global settings for membership contributions."
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete all cycles"
msgstr "Alle Zyklen löschen"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution"
#~ msgstr "Beitrag"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete cycle"
msgstr "Zyklus löschen"
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Contribution Settings"
#~ msgstr "Beitragseinstellungen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "The cycle period will be calculated based on this date and the interval."
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails"
#~ msgstr "E-Mails kopieren"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value deleted successfully"
msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgstr "Standard-Beitragsart"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value not found"
msgstr "Benutzerdefinierter Feldwert nicht gefunden"
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr "Unveränderlich"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
msgstr "Mitgliedsbeitragsart nicht gefunden"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Include joining period"
#~ msgstr "Beitrittsdatum einbeziehen"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User %{action} successfully"
msgstr "Benutzer*in wurde erfolgreich %{action}"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User deleted successfully"
msgstr "Benutzer*in erfolgreich gelöscht"
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr "Nicht bezahlt"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Payment Cycle"
#~ msgstr "Zahlungszyklus"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this custom field value"
msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen"
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Pending"
#~ msgstr "Ausstehend"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone"
#~ msgstr "Telefon"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this user"
msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone Number"
#~ msgstr "Telefonnummer"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this custom field value"
msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr "Sie haben keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Roles"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user"
msgstr "Sie haben keine Berechtigung, diese*n Benutzer*in zu löschen"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "created"
msgstr "erstellt"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show current cycle"
#~ msgstr "Aktuellen Zyklus anzeigen"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "updated"
msgstr "aktualisiert"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show last completed cycle"
#~ msgstr "Letzten abgeschlossenen Zyklus anzeigen"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
msgstr "Unbekannter Fehler"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to current cycle"
#~ msgstr "Zum aktuellen Zyklus wechseln"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr "Mitglied wurde erfolgreich gelöscht"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to last completed cycle"
#~ msgstr "Zum letzten abgeschlossenen Zyklus wechseln"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr "Mitglied nicht gefunden"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this member"
msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr "Unbezahlt im aktuellen Zyklus"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr "Unbezahlt im letzten Zyklus"
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to view custom field values"
msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member"
#~ msgstr "Beispielmitglied anzeigen"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
msgstr "Mitglied wurde erfolgreich erstellt"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included"
#~ msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "monthly"
#~ msgstr "monatlich"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "yearly"
#~ msgstr "jährlich"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr "Mitglied wurde erfolgreich aktualisiert"

View file

@ -8,7 +8,7 @@
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Language: de\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"

View file

@ -634,6 +634,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr ""
@ -1826,11 +1827,6 @@ msgstr ""
msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "The cycle will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to settings"
@ -2059,7 +2055,137 @@ msgstr ""
msgid "read_only - Read access to all data"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Additional Data Fields"
msgid "You do not have permission to %{action} members."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle Period"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete all cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "Custom field value not found"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User %{action} successfully"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User deleted successfully"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User not found"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this user"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this custom field value"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "created"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "updated"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this member"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to view custom field values"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr ""

View file

@ -219,14 +219,14 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "create"
msgstr "created"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "update"
msgstr "updated"
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
@ -455,12 +455,12 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "At least 8 characters"
msgstr "At least 8 characters"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
@ -470,32 +470,32 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Check 'Change Password' above to set a new password for this user."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Password"
msgstr "Confirm Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Consider using special characters"
msgstr "Consider using special characters"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
msgstr "Include both letters and numbers"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Password"
msgstr "Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Password requirements"
msgstr "Password requirements"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
@ -510,12 +510,12 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Set Password"
msgstr "Set Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -634,6 +634,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom Fields"
msgstr ""
@ -1392,7 +1393,7 @@ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create"
msgstr "created"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1412,7 +1413,7 @@ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr "Current Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1537,7 +1538,7 @@ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr "Last Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1826,11 +1827,6 @@ msgstr ""
msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "The cycle will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings"
@ -2059,163 +2055,137 @@ msgstr ""
msgid "read_only - Read access to all data"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Additional Data Fields"
msgid "You do not have permission to %{action} members."
msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle Period"
msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Configure global settings for membership contributions."
#~ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete all cycles"
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution"
#~ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete cycle"
msgstr ""
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contribution Settings"
#~ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#~ #, elixir-autogen, elixir-format
#~ msgid "Example: Member Contribution View"
#~ msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field value deleted successfully"
msgstr ""
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field value not found"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to update member field visibility: %{error}"
#~ msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found"
msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User %{action} successfully"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User deleted successfully"
msgstr ""
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User not found"
msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Payment Cycle"
#~ msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this custom field value"
msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Pending"
#~ msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type"
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Phone"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this user"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone Number"
#~ msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this custom field value"
msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
msgstr ""
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Roles"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this user"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Save Custom Field"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "created"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "updated"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show current cycle"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show last completed cycle"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr ""
#~ #: lib/mv_web/live/member_field_live/form_component.ex
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "String"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to current cycle"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this member"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to last completed cycle"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr ""
#: lib/mv_web/live/custom_field_value_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to view custom field values"
msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member created successfully"
msgstr "Member created successfully"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "monthly"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "yearly"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr "Member updated successfully"

View file

@ -162,6 +162,17 @@ if admin_role do
|> Ash.update!()
end
# Load admin user with role for use as actor in member operations
# This ensures all member operations have proper authorization
# If admin role creation failed, we cannot proceed with member operations
admin_user_with_role =
if admin_role do
admin_user
|> Ash.load!(:role)
else
raise "Failed to create or find admin role. Cannot proceed with member seeding."
end
# Load all membership fee types for assignment
# Sort by name to ensure deterministic order
all_fee_types =
@ -236,7 +247,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
member =
Membership.create_member!(member_attrs_without_fee_type,
upsert?: true,
upsert_identity: :unique_email
upsert_identity: :unique_email,
actor: admin_user_with_role
)
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
@ -247,7 +259,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
})
|> Ash.update!()
|> Ash.Changeset.put_context(:actor, admin_user_with_role)
|> Ash.update!(actor: admin_user_with_role)
else
member
end
@ -264,7 +277,10 @@ Enum.each(member_attrs_list, fn member_attrs ->
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
CycleGenerator.generate_cycles_for_member(final_member.id,
skip_lock?: true,
actor: admin_user_with_role
)
new_cycles
else
@ -299,7 +315,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
|> Ash.update!(actor: admin_user_with_role)
end
end)
end
@ -371,13 +387,15 @@ Enum.with_index(linked_members)
Membership.create_member!(
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert?: true,
upsert_identity: :unique_email
upsert_identity: :unique_email,
actor: admin_user_with_role
)
else
# User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_fee_type,
upsert?: true,
upsert_identity: :unique_email
upsert_identity: :unique_email,
actor: admin_user_with_role
)
end
@ -391,7 +409,7 @@ Enum.with_index(linked_members)
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: admin_user_with_role)
else
member
end
@ -408,7 +426,10 @@ Enum.with_index(linked_members)
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
CycleGenerator.generate_cycles_for_member(final_member.id,
skip_lock?: true,
actor: admin_user_with_role
)
new_cycles
else
@ -435,7 +456,7 @@ Enum.with_index(linked_members)
end)
# Create sample custom field values for some members
all_members = Ash.read!(Membership.Member)
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField)
# Helper function to find custom field by name
@ -463,7 +484,11 @@ if hans = find_member.("hans.mueller@example.de") do
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end
end)
end
@ -488,7 +513,11 @@ if greta = find_member.("greta.schmidt@example.de") do
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end
end)
end
@ -514,7 +543,11 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end
end)
end

View file

@ -0,0 +1,44 @@
defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
@moduledoc """
Regression tests to ensure deny-filter behavior is fail-closed (matches no records).
These tests verify that when HasPermission.auto_filter returns a deny-filter
(e.g., when actor is nil or no permission is found), the filter actually
matches zero records in the database.
This prevents regressions like the previous bug where [id: {:not, {:in, []}}]
was used, which logically evaluates to "NOT (id IN [])" = true for all IDs,
effectively allowing all records instead of denying them.
"""
use Mv.DataCase, async: true
alias Mv.Authorization.Checks.HasPermission
import Mv.Fixtures
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
# Arrange: create some members in DB
_m1 = member_fixture()
_m2 = member_fixture()
# Build a minimal authorizer with a stable action type (:read)
authorizer = %Ash.Policy.Authorizer{
resource: Mv.Membership.Member,
action: %{type: :read}
}
# Act: missing actor must yield a deny-all filter (fail-closed)
deny_filter = HasPermission.auto_filter(nil, authorizer, [])
# Apply the returned filter to a real DB query (no authorization involved)
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.filter_input(deny_filter)
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
# Assert: deny-filter must match nothing
assert results == []
end
end

View file

@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
alias Mv.Authorization.Checks.HasPermission
# Helper to create mock actor with role
defp create_actor_with_role(permission_set_name) do
%{
defp create_actor_with_role(permission_set_name, opts \\ []) do
actor = %{
id: "user-#{System.unique_integer([:positive])}",
role: %{permission_set_name: permission_set_name}
}
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end
describe "Filter Expression Structure - :linked scope" do
test "Member filter uses user.id relationship path" do
actor = create_actor_with_role("own_data")
test "Member filter uses actor.member_id (inverse relationship)" do
actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
assert is_list(filter) or is_map(filter)
end
test "CustomFieldValue filter uses member.user.id relationship path" do
actor = create_actor_with_role("own_data")
test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
end
describe "Filter Expression Structure - :all scope" do
test "Admin can read all members without filter" do
test "Admin can read all members without filter (returns expr(true))" do
actor = create_actor_with_role("admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# :all scope should return nil (no filter needed)
assert is_nil(filter)
# :all scope should return [] (empty keyword list = no filter = allow all records)
# After auto_filter fix: no longer returns nil, returns [] instead
assert filter == []
end
end
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
subject: %{
action: %{type: action},
data: nil
}
}
end
end

View file

@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
subject: %{
action: %{type: action},
data: nil
}
}
end
# Helper to create actor with role
defp create_actor(id, permission_set_name) do
%{
defp create_actor(id, permission_set_name, opts \\ []) do
actor = %{
id: id,
role: %{permission_set_name: permission_set_name}
}
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end
describe "describe/1" do
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
describe "auto_filter/3 - Scope :linked" do
test "scope :linked for Member returns user_id filter" do
user = create_actor("user-123", "own_data")
user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(user, authorizer, [])
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
end
test "scope :linked for CustomFieldValue returns member.user_id filter" do
user = create_actor("user-123", "own_data")
user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
filter = HasPermission.auto_filter(user, authorizer, [])

View file

@ -0,0 +1,430 @@
defmodule Mv.Membership.MemberPoliciesTest do
@moduledoc """
Tests for Member resource authorization policies.
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
and verifies that policies correctly enforce access control based on
user roles and permission sets.
"""
# async: false because we need database commits to be visible across queries
# in the same test (especially for unlinked members)
use Mv.DataCase, async: false
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
# Helper to create a user with a specific permission set
# Returns user with role preloaded (required for authorization)
defp create_user_with_permission_set(permission_set_name) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name)
# Create user
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user do
create_user_with_permission_set("admin")
end
# Helper to create a member linked to a user
defp create_linked_member_for_user(user) do
admin = create_admin_user()
# Create member
# NOTE: We need to ensure the member is actually persisted to the database
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin, return_notifications?: false)
# Link member to user (User.member_id = member.id)
# We use force_change_attribute because the member already exists and we just
# need to set the foreign key. This avoids the issue where manage_relationship
# tries to query the member without the actor context.
result =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
{:ok, _user} = result
# Return the member struct directly - no need to reload since we just created it
# and we're in the same transaction/sandbox
member
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member do
admin = create_admin_user()
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin)
member
end
describe "own_data permission set (Mitglied)" do
setup do
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read linked member", %{user: user, linked_member: linked_member} do
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "can update linked member", %{user: user, linked_member: linked_member} do
# Update is allowed via HasPermission check with :linked scope (not via special case)
# The special case policy only applies to :read actions
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot read unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
# Note: With auto_filter policies, when a user tries to read a member that doesn't
# match the filter (id == actor.member_id), Ash returns NotFound, not Forbidden.
# This is the expected behavior - the filter makes the record "invisible" to the user.
assert_raise Ash.Error.Invalid, fn ->
Ash.get!(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
end
end
test "cannot update unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should only return the linked member (scope :linked filters)
assert length(members) == 1
assert hd(members).id == linked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(linked_member, actor: user)
end
end
end
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup do
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
{:ok, member} =
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
assert member.id == unlinked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot update any member (returns forbidden)", %{
user: user,
linked_member: linked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "cannot destroy any member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "normal_user permission set (Kassenwart)" do
setup do
user = create_user_with_permission_set("normal_user")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot destroy member (safety - not in permission set)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "admin permission set" do
setup do
user = create_user_with_permission_set("admin")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
:ok = Ash.destroy(unlinked_member, actor: user)
# Verify member is deleted
assert {:error, _} = Ash.get(Membership.Member, unlinked_member.id, domain: Mv.Membership)
end
end
describe "special case: user can always READ linked member" do
# Note: The special case policy only applies to :read actions.
# Updates are handled by HasPermission with :linked scope (if permission exists).
test "read_only user can read linked member (via special case bypass)" do
# read_only has Member.read scope :all, but the special case ensures
# users can ALWAYS read their linked member, even if they had no read permission.
# This test verifies the special case works independently of permission sets.
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can read linked member (via special case bypass)" do
# own_data has Member.read scope :linked, but the special case ensures
# users can ALWAYS read their linked member regardless of permission set.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can update linked member (via HasPermission :linked scope)" do
# Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed via HasPermission check (not special case)
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
end
end

View file

@ -1,21 +1,42 @@
defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Phoenix.ConnTest
# Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access
new_conn = build_conn()
# Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
else
new_conn
end
end
# Basic UI tests
test "GET /sign-in shows sign in form", %{conn: conn} do
test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in"
end
test "GET /sign-out redirects to home", %{conn: conn} do
conn = conn_with_oidc_user(conn)
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/"
end
# Password authentication (LiveView)
test "password user can sign in with valid credentials via LiveView", %{conn: conn} do
test "password user can sign in with valid credentials via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "password@example.com",
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "password user with invalid credentials shows error via LiveView", %{conn: conn} do
test "password user with invalid credentials shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "test@example.com",
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "Email or password was incorrect"
end
test "password user with non-existent email shows error via LiveView", %{conn: conn} do
test "password user with non-existent email shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/sign-in")
html =
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
end
# Registration (LiveView)
test "user can register with valid credentials via LiveView", %{conn: conn} do
test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} =
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "registration with existing email shows error via LiveView", %{conn: conn} do
test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "existing@example.com",
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "has already been taken"
end
test "registration with weak password shows error via LiveView", %{conn: conn} do
test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
html =
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
end
# Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in"
end
test "authenticated user can access protected route", %{conn: conn} do
conn = conn_with_oidc_user(conn)
test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members")
assert conn.status == 200
end
test "password authenticated user can access protected route via LiveView", %{conn: conn} do
test "password authenticated user can access protected route via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "auth@example.com",
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
end
# Edge cases
test "user with nil oidc_id can still sign in with password via LiveView", %{conn: conn} do
test "user with nil oidc_id can still sign in with password via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "nil_oidc@example.com",
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "user with empty string oidc_id is handled correctly via LiveView", %{conn: conn} do
test "user with empty string oidc_id is handled correctly via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "empty_oidc@example.com",

View file

@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Use global setup from ConnCase which provides admin user with role
# No custom setup needed
# Helper to create a membership fee type
defp create_fee_type(attrs) do
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
# Helper to create a member
defp create_member(attrs) do
# Uses admin actor from global setup to ensure authorization
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
attrs = Map.merge(default_attrs, attrs)
opts = if actor, do: [actor: actor], else: []
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(opts)
end
describe "list display" do
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert html =~ "Yearly" || html =~ "Jährlich"
end
test "member count column shows correct count", %{conn: conn} do
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type
Enum.each(1..3, fn _ ->
create_member(%{membership_fee_type_id: fee_type.id})
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
end)
{:ok, _view, html} = live(conn, "/membership_fee_types")
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
describe "delete functionality" do
test "delete button disabled if type is in use", %{conn: conn} do
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly})
create_member(%{membership_fee_type_id: fee_type.id})
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/membership_fee_types")

View file

@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ fee_type.name || html =~ "selected"
end
end
describe "custom field value preservation" do
test "custom field values preserved when membership fee type changes", %{
conn: conn,
current_user: admin_user
} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
# Create two fee types with same interval
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
# Create member with fee type 1 and custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type1.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type dropdown
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|> render_change()
# Verify custom field value is still present (check for field name or value)
assert html =~ custom_field.name || html =~ "Test Value"
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
# Create date custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Date Field",
value_type: :date,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with date custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
test_date = ~D[2024-01-15]
# Add date custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "date", "_union_value" => test_date}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Trigger validation (simulates dropdown change)
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type.id})
|> render_change()
# Verify date value is still present (check for date input or formatted date)
assert html =~ "2024" || html =~ "date"
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
_cfv =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type to trigger validation
# This should preserve the custom field value
html =
view
|> form("#member-form", %{
"member[membership_fee_type_id]" => fee_type.id
})
|> render_change()
# Form should still be valid and custom field value should be preserved
# The custom field value should still be visible in the form
assert html =~ "Test Value" || html =~ custom_field.name
end
end
end

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
@doc """
Signs in a user via OIDC and returns a connection with the user authenticated.
By default creates a user with "user@example.com" for consistency.
The user will have an admin role for authorization.
"""
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
oidc_id: "oidc_#{unique_id}"
}
# Create user using Ash.Seed (supports oidc_id)
user = create_test_user(Map.merge(default_attrs, user_attrs))
sign_in_user_via_oidc(conn, user)
# Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
# Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
sign_in_user_via_oidc(conn, user_with_role)
end
@doc """
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
@doc """
Creates a connection with an authenticated user that has an admin role.
This is useful for tests that need full access to resources.
"""
def conn_with_admin_user(conn) do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn_with_password_user(conn, admin_user)
end
setup tags do
pid = Mv.DataCase.setup_sandbox(tags)
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
# to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn}
# Handle role tags for future test extensions
# Default to admin to maintain backward compatibility with existing tests
role = Map.get(tags, :role, :admin)
{conn, user} =
case role do
:admin ->
# Create admin user with role for all tests (unless test overrides with its own user)
# This ensures all tests have an authenticated user with proper authorization
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
:member ->
# Create member user for role-based testing
member_user = Mv.Fixtures.user_with_role_fixture("member")
authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user}
:unauthenticated ->
# No authentication for unauthenticated tests
{conn, nil}
_other ->
# Fallback: treat unknown role as admin for safety
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
end
{:ok, conn: conn, current_user: user}
end
end

View file

@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
{user, member}
end
@doc """
Creates a role with a specific permission set.
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
## Returns
- Role struct
## Examples
iex> role_fixture("admin")
%Mv.Authorization.Role{permission_set_name: "admin", ...}
"""
def role_fixture(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
@doc """
Creates a user with a specific permission set (role).
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
- `user_attrs` - Optional user attributes
## Returns
- User struct with role preloaded
## Examples
iex> admin_user = user_with_role_fixture("admin")
iex> admin_user.role.permission_set_name
"admin"
"""
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
# Create role with permission set
role = role_fixture(permission_set_name)
# Create user
{:ok, user} =
user_attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
@doc """
Creates a member with an actor (for use in tests with policies).
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
- `actor` - The actor (user) to use for authorization
## Returns
- Member struct
## Examples
iex> admin = user_with_role_fixture("admin")
iex> member_fixture_with_actor(%{first_name: "Alice"}, admin)
%Mv.Membership.Member{first_name: "Alice", ...}
"""
def member_fixture_with_actor(attrs \\ %{}, actor) do
attrs
|> Enum.into(%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Membership.create_member(actor: actor)
|> case do
{:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
end
end
end