Member Resource Policies closes #345 #346

Merged
moritz merged 33 commits from feature/345_member_policies_2 into main 2026-01-13 16:36:24 +01:00
49 changed files with 2593 additions and 1102 deletions

View file

@ -32,6 +32,8 @@ lint:
mix format --check-formatted mix format --check-formatted
mix compile --warnings-as-errors mix compile --warnings-as-errors
mix credo 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 mix gettext.extract --check-up-to-date
audit: audit:

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

View file

@ -294,7 +294,9 @@ Each Permission Set contains:
**:own** - Only records where id == actor.id **:own** - Only records where id == actor.id
- Example: User can read their own User record - 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 - Example: User can read Member linked to their account
**:all** - All records without restriction **:all** - All records without restriction

View file

@ -34,10 +34,12 @@ defmodule Mv.Membership.Member do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Helpers
require Logger require Logger
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
@ -118,11 +120,12 @@ defmodule Mv.Membership.Member do
# Only runs if membership_fee_type_id is set # Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action, # Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility # 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 case result do
{:ok, member} -> {:ok, member} ->
if member.membership_fee_type_id && member.join_date do 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 end
{:error, _} -> {:error, _} ->
@ -193,7 +196,9 @@ defmodule Mv.Membership.Member do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do 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} -> {:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit # Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications} {:ok, member, notifications}
@ -225,7 +230,8 @@ defmodule Mv.Membership.Member do
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date) exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do 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 end
{:error, _} -> {:error, _} ->
@ -296,6 +302,41 @@ defmodule Mv.Membership.Member do
end end
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 """ @doc """
Filters members list based on email match priority. Filters members list based on email match priority.
@ -363,8 +404,13 @@ defmodule Mv.Membership.Member do
user_id = user_arg[:id] user_id = user_arg[:id]
current_member_id = changeset.data.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 # 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 # User is free to be linked
{:ok, %{member_id: nil}} -> {:ok, %{member_id: nil}} ->
:ok :ok
@ -742,33 +788,37 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} where notifications are collected # Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits # to be sent after transaction commits
@doc false @doc false
def regenerate_cycles_on_type_change(member) do def regenerate_cycles_on_type_change(member, opts \\ []) do
today = Date.utc_today() today = Date.utc_today()
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
# Use advisory lock to prevent concurrent deletion and regeneration # Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously # This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key) regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
else else
regenerate_cycles_new_transaction(member, today, lock_key) regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
end end
end end
# Already in transaction: use advisory lock directly # Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) 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 end
# Not in transaction: start new transaction with advisory lock # Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
Mv.Repo.transaction(fn -> Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) 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} -> {:ok, notifications} ->
# Return notifications - they will be sent by the caller # Return notifications - they will be sent by the caller
notifications notifications
@ -790,6 +840,7 @@ defmodule Mv.Membership.Member do
require Ash.Query require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
# Find all unpaid cycles for this member # Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval # We need to check cycle_end for each cycle using its own interval
@ -799,10 +850,21 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid) |> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type]) |> 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} -> {:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today) 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} ->
{:error, reason} {:error, reason}
@ -831,13 +893,14 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} # Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
if Enum.empty?(cycles_to_delete) do if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate # 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 else
case delete_cycles(cycles_to_delete) do 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} {:error, reason} -> {:error, reason}
end end
end end
@ -863,11 +926,13 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id, member_id,
today: today, today: today,
skip_lock?: skip_lock? skip_lock?: skip_lock?,
actor: actor
) do ) do
{:ok, _cycles, notifications} when is_list(notifications) -> {:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications} {:ok, notifications}
@ -881,21 +946,25 @@ defmodule Mv.Membership.Member do
# based on environment (test vs production) # based on environment (test vs production)
# This function encapsulates the common logic for cycle generation # This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks # 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 if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member) handle_cycle_generation_sync(member, actor: actor)
else else
handle_cycle_generation_async(member) handle_cycle_generation_async(member, actor: actor)
end end
end end
# Runs cycle generation synchronously (for test environment) # Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member) do defp handle_cycle_generation_sync(member, opts) do
require Logger require Logger
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id, member.id,
today: Date.utc_today() today: Date.utc_today(),
actor: actor
) do ) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) send_notifications_if_any(notifications)
@ -907,9 +976,11 @@ defmodule Mv.Membership.Member do
end end
# Runs cycle generation asynchronously (for production environment) # 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 -> 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} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false) 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) custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
if is_nil(custom_field_values_arg) do if is_nil(custom_field_values_arg) do
extract_existing_values(changeset.data) extract_existing_values(changeset.data, changeset)
else else
extract_argument_values(custom_field_values_arg) extract_argument_values(custom_field_values_arg)
end end
end end
# Extracts custom field values from existing member data (update scenario) # Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data) do defp extract_existing_values(member_data, changeset) do
case Ash.load(member_data, :custom_field_values) 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}} -> {:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) 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) - **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id - **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type: - **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship) - Member: `id == actor.member_id` (User.member_id Member.id, inverse relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id Member.id User.member_id)
## Error Handling ## Error Handling
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
def strict_check(actor, authorizer, _opts) do def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource resource = authorizer.resource
action = get_action_from_authorizer(authorizer) action = get_action_from_authorizer(authorizer)
record = get_record_from_authorizer(authorizer)
cond do cond do
is_nil(actor) -> is_nil(actor) ->
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
{:ok, false} {:ok, false}
true -> true ->
strict_check_with_permissions(actor, resource, action) strict_check_with_permissions(actor, resource, action, record)
end end
end end
# Helper function to reduce nesting depth # 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, 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), {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom), permissions <- PermissionSets.get_permissions(ps_atom),
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor, actor,
resource_name resource_name
) do ) do
:authorized -> {:ok, true} :authorized ->
{:filter, _} -> {:ok, :unknown} {:ok, true}
false -> {:ok, false}
{: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 end
else else
%{role: nil} -> %{role: nil} ->
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
action = get_action_from_authorizer(authorizer) action = get_action_from_authorizer(authorizer)
cond do cond do
is_nil(actor) -> nil is_nil(actor) ->
is_nil(action) -> nil # No actor - deny access (fail-closed)
true -> auto_filter_with_permissions(actor, resource, action) # 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
end end
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor, actor,
resource_name resource_name
) do ) do
:authorized -> nil :authorized ->
{:filter, filter_expr} -> filter_expr # :all scope - allow all records (no filter)
false -> nil # 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 end
else 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 _ -> nil
end end
end end
# Helper to extract action from authorizer # Evaluate filter expression for strict_check on single records
defp get_action_from_authorizer(authorizer) do # For :linked scope with Member resource: id == actor.member_id
case authorizer.subject do defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
%{action: %{name: action}} -> action case {resource_name, record} do
%{action: action} when is_atom(action) -> action {"Member", %{id: member_id}} when not is_nil(member_id) ->
_ -> nil # 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
end end
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
end end
# Scope: linked - Filter based on user relationship (resource-specific!) # 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 defp apply_scope(:linked, actor, resource_name) do
case resource_name do case resource_name do
"Member" -> "Member" ->
# Member has_one :user → filter by user.id == actor.id # User.member_id → Member.id (inverse relationship)
{:filter, expr(user.id == ^actor.id)} # Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" -> "CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user # CustomFieldValue.member_id → Member.id → User.member_id
# Traverse: custom_field_value.member.user.id == actor.id # Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member.user.id == ^actor.id)} {:filter, expr(member_id == ^actor.member_id)}
_ -> _ ->
# Fallback for other resources: try user relationship first, then user_id # Fallback for other resources
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)} {:filter, expr(user_id == ^actor.id)}
end end
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 -> Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs) result = callback.(cs)
actor = Map.get(changeset.context, :actor)
with {:ok, member} <- Helpers.extract_record(result), 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) Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else else
_ -> result _ -> result

View file

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

View file

@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
@moduledoc """ @moduledoc """
Helper functions for loading linked records in email synchronization. Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities. 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 """ @doc """
Loads the member linked to a user, returns nil if not linked or on error. 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 Accepts optional actor for authorization.
case Ash.get(Mv.Membership.Member, id) do """
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 {:ok, member} -> member
{:error, _} -> nil {:error, _} -> nil
end end
@ -18,9 +33,13 @@ defmodule Mv.EmailSync.Loader do
@doc """ @doc """
Loads the user linked to a member, returns nil if not linked or on error. 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 def get_linked_user(member, actor \\ nil) do
case Ash.load(member, :user) do opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user {:ok, %{user: user}} -> user
{:error, _} -> nil {:error, _} -> nil
end end
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
@doc """ @doc """
Loads the user linked to a member, returning an error tuple if not linked. Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation. Useful when a link is required for the operation.
Accepts optional actor for authorization.
""" """
def load_linked_user!(member) do def load_linked_user!(member, actor \\ nil) do
case Ash.load(member, :user) 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, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user} {:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error {: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. This allows creating members with the same email as unlinked users.
""" """
use Ash.Resource.Validation use Ash.Resource.Validation
alias Mv.Helpers
@doc """ @doc """
Validates email uniqueness across linked Member-User pairs. 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 def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) 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) is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing # 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 if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email) 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 else
:ok :ok
end end
end end
defp check_email_uniqueness(email, exclude_user_id) do defp check_email_uniqueness(email, exclude_user_id, actor) do
query = query =
Mv.Accounts.User Mv.Accounts.User
|> Ash.Query.filter(email == ^email) |> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id) |> 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, []} ->
: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, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do defp get_linked_user_id(member_data, actor) do
case Ash.load(member_data, :user) do opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id {:ok, %{user: %{id: id}}} -> id
_ -> nil _ -> nil
end end

View file

@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
Uses PostgreSQL advisory locks to prevent race conditions when generating Uses PostgreSQL advisory locks to prevent race conditions when generating
cycles for the same member concurrently. 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 ## Examples
# Generate cycles for a single member # 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_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do 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) {:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
@ -87,25 +98,27 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false) 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 end
# Generate cycles with lock handling # Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here, # Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook) # 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) # Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking # Just generate cycles without additional locking
do_generate_cycles(member, today) actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
end 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) lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn -> Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) 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} -> {:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here # Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook) # They will be sent by the caller (e.g., via after_action hook)
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions # Private functions
defp load_member(member_id) do defp load_member(member_id, opts) do
Member actor = Keyword.get(opts, :actor)
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) query =
|> Ash.read_one() Member
|> case do |> 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, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member} {:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
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 # Reload member with relationships to ensure fresh data
case load_member(member.id) do case load_member(member.id, actor: actor) do
{:ok, member} -> {:ok, member} ->
cond do cond do
is_nil(member.membership_fee_type_id) -> is_nil(member.membership_fee_type_id) ->
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date} {:error, :no_join_date}
true -> true ->
generate_missing_cycles(member, today) generate_missing_cycles(member, today, actor: actor)
end end
{:error, reason} -> {:error, reason} ->
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
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 fee_type = member.membership_fee_type
interval = fee_type.interval interval = fee_type.interval
amount = fee_type.amount amount = fee_type.amount
@ -270,7 +296,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date # Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval) 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 else
{:ok, [], []} {:ok, [], []}
end end
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
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 # Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for # Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications}) # 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( handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true), Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
cycle_start cycle_start
) )
end) end)

View file

@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do 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} -> {:ok, custom_field} ->
action = action =
case socket.assigns.form.source.type do case socket.assigns.form.source.type do

View file

@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
page_title = action <> " " <> "Custom field value" page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields # Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField) actor = current_actor(socket)
members = Ash.read!(Mv.Membership.Member) custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok, {:ok,
socket socket
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
custom_field_value_params custom_field_value_params
end 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} -> {:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value}) notify_parent({:saved, custom_field_value})

View file

@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, actor = current_actor(socket)
socket
|> assign(:page_title, "Listing Custom field values") # Early return if no actor (prevents exceptions in unauthenticated tests)
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))} 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 end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id) actor = MvWeb.LiveHelpers.current_actor(socket)
Ash.destroy!(custom_field_value)
{: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
end end

View file

@ -90,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do 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} -> {:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated # Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings() {:ok, fresh_settings} = Membership.get_settings()

View file

@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view 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
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
<select <select
class="select select-bordered w-full" class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name} name={@form[:membership_fee_type_id].name}
phx-change="validate_membership_fee_type" phx-change="validate"
value={@form[:membership_fee_type_id].value || ""} value={@form[:membership_fee_type_id].value || ""}
> >
<option value="">{gettext("None")}</option> <option value="">{gettext("None")}</option>
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def mount(params, _session, socket) do 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() {:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_custom_field_values = initial_custom_field_values =
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
member = member =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type]) id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
end end
page_title = page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types # Load available membership fee types
available_fee_types = load_available_fee_types(member) available_fee_types = load_available_fee_types(member, actor)
{:ok, {:ok,
socket socket
@ -265,53 +271,80 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def handle_event("validate", %{"member" => member_params}, socket) do 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 # 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)} {:noreply, assign(socket, form: validated_form)}
end 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 def handle_event("save", %{"member" => member_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do try do
{:ok, member} -> actor = current_actor(socket)
notify_parent({:saved, member})
action = case submit_form(socket.assigns.form, member_params, actor) do
case socket.assigns.form.source.type do {:ok, member} ->
:create -> gettext("create") handle_save_success(socket, member)
:update -> gettext("update")
other -> to_string(other)
end
socket = {:error, form} ->
socket {:noreply, assign(socket, form: form)}
|> put_flash(:info, gettext("Member %{action} successfully", action: action)) end
|> push_navigate(to: return_path(socket.assigns.return_to, member)) rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
{:noreply, socket} handle_save_forbidden(socket)
{:error, form} ->
{:noreply, assign(socket, form: form)}
end end
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 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 = form =
if member do 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 = existing_custom_field_values =
member.custom_field_values member.custom_field_values
@ -342,7 +375,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: params, params: params,
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
missing_custom_field_values = missing_custom_field_values =
@ -360,7 +394,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]}, params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
end end
@ -375,11 +410,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp load_available_fee_types(member) do defp load_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> 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 has a fee type, filter to same interval
if member && member.membership_fee_type do 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(:date), do: "date"
defp custom_field_input_type(:email), do: "email" defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text" 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 end

View file

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

View file

@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Query import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -148,9 +151,9 @@ defmodule MvWeb.MemberLive.Show do
</div> </div>
<%!-- Custom Fields Section --%> <%!-- Custom Fields Section --%>
<%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %> <%= if Enum.any?(@custom_fields) do %>
<div> <div>
<.section_box title={gettext("Additional Data Fields")}> <.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %> <%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> <% 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} module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -233,15 +237,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true @impl true
def handle_params(%{"id" => id}, _, socket) do def handle_params(%{"id" => id}, _, socket) do
# Load custom fields for display actor = current_actor(socket)
# 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!()
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 = query =
Mv.Membership.Member Mv.Membership.Member
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
membership_fee_cycles: [:membership_fee_type] 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 # Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member) last_cycle_status = get_last_cycle_status(member)

View file

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

View file

@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Map.put(params, "include_joining_cycle", false) Map.put(params, "include_joining_cycle", false)
end 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} -> {:ok, updated_settings} ->
{:noreply, {:noreply,
socket socket

View file

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

View file

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

View file

@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
end end
def handle_event("save", %{"role" => role_params}, socket) do 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} -> {:ok, role} ->
notify_parent({:saved, 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()] @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do 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 case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name) {:ok, roles} -> Enum.sort_by(roles, & &1.name)

View file

@ -33,6 +33,9 @@ defmodule MvWeb.UserLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
actor = current_actor(socket)
user = user =
case params["id"] do case params["id"] do
nil -> nil 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 end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit") action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
socket = socket =
if Map.has_key?(user_params, "email") do if Map.has_key?(user_params, "email") do
user_email = user_params["email"] 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) assign(socket, form: validated_form, available_members: members)
else else
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes # 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} -> {:ok, user} ->
# Then handle member linking/unlinking as a separate step handle_member_linking(socket, user, actor)
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
{:error, form} -> {:error, form} ->
{:noreply, assign(socket, form: form)} {:noreply, assign(socket, form: form)}
end end
end end
@impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)} {:noreply, assign(socket, show_member_dropdown: true)}
end end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1 max_index = length(socket.assigns.available_members) - 1
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index current = socket.assigns.focused_member_index
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
select_focused_member(socket) select_focused_member(socket)
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys # Ignore other keys
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
socket = socket =
socket socket
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do def handle_event("select_member", %{"id" => member_id}, socket) do
# Find the selected member to get their name # Find the selected member to get their name
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) 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(:selected_member_name, member_name)
|> assign(:unlink_member, false) |> assign(:unlink_member, false)
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
|> assign(:member_search_query, member_name) |> assign(:focused_member_index, nil)
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("unlink_member", _params, socket) do 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 =
socket socket
|> assign(:unlink_member, true)
|> assign(:selected_member_id, nil) |> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil) |> assign(:selected_member_name, nil)
|> assign(:member_search_query, "") |> assign(:unlink_member, true)
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
|> load_initial_members() |> assign(:focused_member_index, nil)
{:noreply, socket} {:noreply, socket}
end 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() @spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 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() @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 defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
actor = current_actor(socket)
form = form =
if user do if user do
# For existing users, use admin password action if password fields are shown # For existing users, use admin password action if password fields are shown
action = if show_password_fields, do: :admin_set_password, else: :update_user 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 else
# For new users, use password registration if password fields are shown # For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action, AshPhoenix.Form.for_create(Mv.Accounts.User, action,
domain: Mv.Accounts, domain: Mv.Accounts,
as: "user" as: "user",
actor: actor
) )
end end
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user user = socket.assigns.user
user_email = if user, do: user.email, else: nil 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 # Dropdown should ALWAYS be hidden initially
# It will only show when user focuses the input field (show_member_dropdown event) # 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 = socket.assigns.user
user_email = if user, do: user.email, else: nil 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) assign(socket, available_members: members)
end end
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] @spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
defp load_members_for_linking(user_email, search_query) do [
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 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 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 search_query: search_query_str
}) })
case Ash.read(query, domain: Mv.Membership) do actor = current_actor(socket)
{: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
{: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
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 # Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t() @spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do 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 case List.first(errors) do
%{message: message} when is_binary(message) -> message %{message: message} when is_binary(message) -> message
%{field: field, message: message} -> "#{field}: #{message}" %{field: field, message: message} -> "#{field}: #{message}"
_ -> "Unknown error" _ -> gettext("Unknown error")
end end
end end
defp extract_error_message(error) when is_binary(error), do: error 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 end

View file

@ -23,9 +23,13 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.TableComponents import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def mount(_params, _session, socket) do 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) sorted = Enum.sort_by(users, & &1.email)
{:ok, {:ok,
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) actor = current_actor(socket)
Ash.destroy!(user, domain: Mv.Accounts)
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id)) case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:noreply, assign(socket, :users, updated_users)} {: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 end
# Selects one user in the list of users # Selects one user in the list of users
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2 defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), 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 end

View file

@ -26,6 +26,9 @@ defmodule MvWeb.UserLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do 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, {:ok,
socket socket

View file

@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
user user
end end
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 end

View file

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

View file

@ -60,6 +60,7 @@
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "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_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"}, "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": {: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"}, "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"}, "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. ## to merge POT files into PO files.
msgid "" msgid ""
msgstr "" msgstr ""
"Language: en\n" "Language: de\n"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/contribution_period_live/show.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/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Fields" msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder" 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." 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Member field %{action} successfully" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "A cycle for this period already exists" 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 #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Click to edit amount" 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 #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1396,12 +1397,12 @@ msgstr "erstellt"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Create Cycle" msgid "Create Cycle"
msgstr "Aktueller Zyklus" msgstr "Zyklus erstellen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Create a new cycle manually" 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 #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1431,7 +1432,7 @@ msgstr "Zyklusbetrag aktualisiert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Cycle created successfully" msgid "Cycle created successfully"
msgstr "Zyklen erfolgreich regeneriert" msgstr "Zyklus erfolgreich erstellt"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1474,9 +1475,9 @@ msgid "Edit Cycle Amount"
msgstr "Zyklusbetrag bearbeiten" msgstr "Zyklusbetrag bearbeiten"
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Edit Field: %{field}" msgid "Edit Field: %{field}"
msgstr "Mitglied bearbeiten" msgstr "Feld bearbeiten: %{field}"
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1524,9 +1525,9 @@ msgid "Invalid amount format"
msgstr "Ungültiges Betragsformat" msgstr "Ungültiges Betragsformat"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Invalid date format" msgid "Invalid date format"
msgstr "Ungültiges Betragsformat" msgstr "Ungültiges Datumsformat"
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1679,9 +1680,9 @@ msgid "Not set"
msgstr "Nicht gesetzt" msgstr "Nicht gesetzt"
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Payment Interval" msgid "Payment Interval"
msgstr "Zahlungsfilter" msgstr "Zahlungsintervall"
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1762,7 +1763,7 @@ msgstr "Art"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm" 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 #: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1825,11 +1826,6 @@ msgstr "Mitgliedsbeitragsstatus"
msgid "Show/Hide Columns" msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden" 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 #: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings" msgid "Back to settings"
@ -1879,78 +1875,78 @@ msgstr "Zurück zur Rollen-Liste"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete system role" msgid "Cannot delete system role"
msgstr "" msgstr "System-Rolle kann nicht gelöscht werden"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Custom" msgid "Custom"
msgstr "Benutzerdefinierte Felder" msgstr "Benutzerdefiniert"
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Edit Role" msgid "Edit Role"
msgstr "Bearbeiten" msgstr "Rolle bearbeiten"
#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}" 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.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Listing Roles" msgid "Listing Roles"
msgstr "Benutzer*innen auflisten" msgstr "Rollen auflisten"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Manage user roles and their permission sets." 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/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close sidebar" msgid "Close sidebar"
msgstr "" msgstr "Sidebar schließen"
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Delete Role" msgid "Delete Role"
msgstr "Zyklus löschen" msgstr "Rolle löschen"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main navigation" msgid "Main navigation"
msgstr "" msgstr "Hauptnavigation"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Role" msgid "New Role"
msgstr "" msgstr "Neue Rolle"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "No description" msgid "No description"
msgstr "Beschreibung" msgstr "Keine Beschreibung"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open navigation menu" msgid "Open navigation menu"
msgstr "" msgstr "Navigationsmenü öffnen"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Permission Set" msgid "Permission Set"
msgstr "" msgstr "Berechtigungssatz"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1961,250 +1957,234 @@ msgstr "Profil"
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "" msgstr "Rolle"
#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Role deleted successfully." msgid "Role deleted successfully."
msgstr "Zyklen erfolgreich regeneriert" msgstr "Rolle erfolgreich gelöscht."
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role details and permissions." 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/form.ex
#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role not found." msgid "Role not found."
msgstr "" msgstr "Rolle nicht gefunden."
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role saved successfully." msgid "Role saved successfully."
msgstr "" msgstr "Rolle erfolgreich gespeichert."
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Save Role" msgid "Save Role"
msgstr "Speichern" msgstr "Rolle speichern"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select permission set" msgid "Select permission set"
msgstr "" msgstr "Berechtigungssatz auswählen"
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "Show Role" msgid "Show Role"
msgstr "Anzeigen" msgstr "Rolle anzeigen"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System" msgid "System"
msgstr "" msgstr "System"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System Role" msgid "System Role"
msgstr "" msgstr "System-Rolle"
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System roles cannot be deleted" 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/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
msgstr "" msgstr "System-Rollen können nicht gelöscht werden."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "" msgstr "Sidebar umschalten"
#: lib/mv_web/live/role_live/form.ex #: 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." 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
msgid "User menu" msgid "User menu"
msgstr "Benutzer*in" msgstr "Benutzer*innen-Menü"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "admin - Unrestricted access" msgid "admin - Unrestricted access"
msgstr "" msgstr "admin - Uneingeschränkter Zugriff"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "normal_user - Create/Read/Update access" msgid "normal_user - Create/Read/Update access"
msgstr "" msgstr "normal_user - Erstellen/Lesen/Aktualisieren Zugriff"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "own_data - Access only to own data" 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 #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "read_only - Read access to all data" 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 #, elixir-autogen, elixir-format
msgid "Additional Data Fields" msgid "You do not have permission to %{action} members."
msgstr "Zusätzliche Datenfelder" msgstr "Sie haben keine Berechtigung, Mitglieder zu %{action}."
#~ #: lib/mv_web/live/custom_field_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" msgid "Cycle Period"
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)" msgstr "Zykluszeitraum"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Configure global settings for membership contributions." msgid "Delete all cycles"
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." msgstr "Alle Zyklen löschen"
#~ #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy
#~ #, elixir-autogen, elixir-format msgid "Delete cycle"
#~ msgid "Contribution" msgstr "Zyklus löschen"
#~ msgstr "Beitrag"
#~ #: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format, fuzzy
#~ #, elixir-autogen, elixir-format, fuzzy msgid "The cycle period will be calculated based on this date and the interval."
#~ msgid "Contribution Settings" msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#~ msgstr "Beitragseinstellungen"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Copy emails" msgid "Custom field value deleted successfully"
#~ msgstr "E-Mails kopieren" msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type" msgid "Custom field value not found"
#~ msgstr "Standard-Beitragsart" msgstr "Benutzerdefinierter Feldwert nicht gefunden"
#~ #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Immutable" msgid "Membership fee type not found"
#~ msgstr "Unveränderlich" msgstr "Mitgliedsbeitragsart nicht gefunden"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/user_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Include joining period" msgid "User %{action} successfully"
#~ msgstr "Beitrittsdatum einbeziehen" msgstr "Benutzer*in wurde erfolgreich %{action}"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
#~ msgid "New Custom field" msgid "User deleted successfully"
#~ msgstr "Benutzerdefiniertes Feld speichern" msgstr "Benutzer*in erfolgreich gelöscht"
#~ #: lib/mv_web/live/components/payment_filter_component.ex #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Not paid" msgid "User not found"
#~ msgstr "Nicht bezahlt" msgstr "Benutzer*in nicht gefunden"
#~ #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Payment Cycle" msgid "You do not have permission to access this custom field value"
#~ msgstr "Zahlungszyklus" msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen"
#~ #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Pending" msgid "You do not have permission to access this membership fee type"
#~ msgstr "Ausstehend" msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
#~ #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/user_live/index.ex
#~ #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format
#~ #: lib/mv_web/translations/member_fields.ex msgid "You do not have permission to access this user"
#~ #, elixir-autogen, elixir-format msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#~ msgid "Phone"
#~ msgstr "Telefon"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Phone Number" msgid "You do not have permission to delete this custom field value"
#~ msgstr "Telefonnummer" msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded" msgid "You do not have permission to delete this membership fee type"
#~ msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen" msgstr "Sie haben keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
#~ #: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Roles" msgid "You do not have permission to delete this user"
#~ msgstr "" msgstr "Sie haben keine Berechtigung, diese*n Benutzer*in zu löschen"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/user_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Show Last/Current Cycle Payment Status" msgid "created"
#~ msgstr "" msgstr "erstellt"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/user_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Show current cycle" msgid "updated"
#~ msgstr "Aktuellen Zyklus anzeigen" msgstr "aktualisiert"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/user_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Show last completed cycle" msgid "Unknown error"
#~ msgstr "Letzten abgeschlossenen Zyklus anzeigen" msgstr "Unbekannter Fehler"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Switch to current cycle" msgid "Member deleted successfully"
#~ msgstr "Zum aktuellen Zyklus wechseln" msgstr "Mitglied wurde erfolgreich gelöscht"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Switch to last completed cycle" msgid "Member not found"
#~ msgstr "Zum letzten abgeschlossenen Zyklus wechseln" msgstr "Mitglied nicht gefunden"
#~ #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.ex
#~ #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format
#~ #, elixir-autogen, elixir-format msgid "You do not have permission to access this member"
#~ msgid "This data is for demonstration purposes only (mockup)." msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle" msgid "You do not have permission to delete this member"
#~ msgstr "Unbezahlt im aktuellen Zyklus" msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/custom_field_value_live/index.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle" msgid "You do not have permission to view custom field values"
#~ msgstr "Unbezahlt im letzten Zyklus" msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "View Example Member" msgid "Member created successfully"
#~ msgstr "Beispielmitglied anzeigen" msgstr "Mitglied wurde erfolgreich erstellt"
#~ #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included" msgid "Member updated successfully"
#~ msgstr "Jährliches Intervall Beitrittszeitraum einbezogen" msgstr "Mitglied wurde erfolgreich aktualisiert"
#~ #: 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"

View file

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

View file

@ -634,6 +634,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Fields" msgid "Custom Fields"
msgstr "" msgstr ""
@ -1826,11 +1827,6 @@ msgstr ""
msgid "Show/Hide Columns" msgid "Show/Hide Columns"
msgstr "" 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 #: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to settings" msgid "Back to settings"
@ -2059,7 +2055,137 @@ msgstr ""
msgid "read_only - Read access to all data" msgid "read_only - Read access to all data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, 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 "" msgstr ""

View file

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

View file

@ -162,6 +162,17 @@ if admin_role do
|> Ash.update!() |> Ash.update!()
end 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 # Load all membership fee types for assignment
# Sort by name to ensure deterministic order # Sort by name to ensure deterministic order
all_fee_types = all_fee_types =
@ -236,7 +247,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
member = member =
Membership.create_member!(member_attrs_without_fee_type, Membership.create_member!(member_attrs_without_fee_type,
upsert?: true, 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) # 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, %{ |> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id 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 else
member member
end end
@ -264,7 +277,10 @@ Enum.each(member_attrs_list, fn member_attrs ->
if Enum.empty?(member_with_cycles.membership_fee_cycles) do if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles # Generate cycles
{:ok, new_cycles, _notifications} = {: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 new_cycles
else else
@ -299,7 +315,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
if cycle.status != status do if cycle.status != status do
cycle cycle
|> Ash.Changeset.for_update(:update, %{status: status}) |> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!() |> Ash.update!(actor: admin_user_with_role)
end end
end) end)
end end
@ -371,13 +387,15 @@ Enum.with_index(linked_members)
Membership.create_member!( Membership.create_member!(
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}), Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email,
actor: admin_user_with_role
) )
else else
# User already has a member, just create the member without linking - use upsert to prevent duplicates # User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_fee_type, Membership.create_member!(member_attrs_without_fee_type,
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email,
actor: admin_user_with_role
) )
end end
@ -391,7 +409,7 @@ Enum.with_index(linked_members)
member member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!() |> Ash.update!(actor: admin_user_with_role)
else else
member member
end end
@ -408,7 +426,10 @@ Enum.with_index(linked_members)
if Enum.empty?(member_with_cycles.membership_fee_cycles) do if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles # Generate cycles
{:ok, new_cycles, _notifications} = {: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 new_cycles
else else
@ -435,7 +456,7 @@ Enum.with_index(linked_members)
end) end)
# Create sample custom field values for some members # 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) all_custom_fields = Ash.read!(Membership.CustomField)
# Helper function to find custom field by name # 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, custom_field_id: field.id,
value: value 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) end)
end end
@ -488,7 +513,11 @@ if greta = find_member.("greta.schmidt@example.de") do
custom_field_id: field.id, custom_field_id: field.id,
value: value 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) end)
end end
@ -514,7 +543,11 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
custom_field_id: field.id, custom_field_id: field.id,
value: value 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) 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 alias Mv.Authorization.Checks.HasPermission
# Helper to create mock actor with role # 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])}", id: "user-#{System.unique_integer([:positive])}",
role: %{permission_set_name: permission_set_name} 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 end
describe "Filter Expression Structure - :linked scope" do describe "Filter Expression Structure - :linked scope" do
test "Member filter uses user.id relationship path" do test "Member filter uses actor.member_id (inverse relationship)" do
actor = create_actor_with_role("own_data") actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.Member, :read) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
assert is_list(filter) or is_map(filter) assert is_list(filter) or is_map(filter)
end end
test "CustomFieldValue filter uses member.user.id relationship path" do test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
actor = create_actor_with_role("own_data") actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read) authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
end end
describe "Filter Expression Structure - :all scope" do 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") actor = create_actor_with_role("admin")
authorizer = create_authorizer(Mv.Membership.Member, :read) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
# :all scope should return nil (no filter needed) # :all scope should return [] (empty keyword list = no filter = allow all records)
assert is_nil(filter) # After auto_filter fix: no longer returns nil, returns [] instead
assert filter == []
end end
end end
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
defp create_authorizer(resource, action) do defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{ %Ash.Policy.Authorizer{
resource: resource, resource: resource,
subject: %{action: %{name: action}} subject: %{
action: %{type: action},
data: nil
}
} }
end end
end end

View file

@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
defp create_authorizer(resource, action) do defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{ %Ash.Policy.Authorizer{
resource: resource, resource: resource,
subject: %{action: %{name: action}} subject: %{
action: %{type: action},
data: nil
}
} }
end end
# Helper to create actor with role # 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, id: id,
role: %{permission_set_name: permission_set_name} 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 end
describe "describe/1" do describe "describe/1" do
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
describe "auto_filter/3 - Scope :linked" do describe "auto_filter/3 - Scope :linked" do
test "scope :linked for Member returns user_id filter" 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) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(user, authorizer, []) filter = HasPermission.auto_filter(user, authorizer, [])
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
end end
test "scope :linked for CustomFieldValue returns member.user_id filter" do 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) authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
filter = HasPermission.auto_filter(user, authorizer, []) 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 defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest 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 # 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") conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in" assert html_response(conn, 200) =~ "Sign in"
end end
test "GET /sign-out redirects to home", %{conn: conn} do test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out") conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
# Password authentication (LiveView) # 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 = _user =
create_test_user(%{ create_test_user(%{
email: "password@example.com", email: "password@example.com",
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "test@example.com", email: "test@example.com",
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "Email or password was incorrect" assert html =~ "Email or password was incorrect"
end 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") {:ok, view, _html} = live(conn, "/sign-in")
html = html =
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
end end
# Registration (LiveView) # 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") {:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} = {:error, {:redirect, %{to: to}}} =
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "existing@example.com", email: "existing@example.com",
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "has already been taken" assert html =~ "has already been taken"
end 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") {:ok, view, _html} = live(conn, "/register")
html = html =
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
end end
# Access control # 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") conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == ~p"/sign-in"
end end
test "authenticated user can access protected route", %{conn: conn} do test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members") conn = get(conn, ~p"/members")
assert conn.status == 200 assert conn.status == 200
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "auth@example.com", email: "auth@example.com",
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
end end
# Edge cases # 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 = _user =
create_test_user(%{ create_test_user(%{
email: "nil_oidc@example.com", email: "nil_oidc@example.com",
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "empty_oidc@example.com", email: "empty_oidc@example.com",

View file

@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
require Ash.Query require Ash.Query
setup %{conn: conn} do # Use global setup from ConnCase which provides admin user with role
# Create admin user # No custom setup needed
{: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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end end
# Helper to create a member # 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 = %{ default_attrs = %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
attrs = Map.merge(default_attrs, attrs) attrs = Map.merge(default_attrs, attrs)
opts = if actor, do: [actor: actor], else: []
Member Member
|> Ash.Changeset.for_create(:create_member, attrs) |> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!() |> Ash.create!(opts)
end end
describe "list display" do describe "list display" do
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert html =~ "Yearly" || html =~ "Jährlich" assert html =~ "Yearly" || html =~ "Jährlich"
end 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}) fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type # Create 3 members with this fee type
Enum.each(1..3, fn _ -> 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) end)
{:ok, _view, html} = live(conn, "/membership_fee_types") {:ok, _view, html} = live(conn, "/membership_fee_types")
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end end
describe "delete functionality" do 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}) 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") {:ok, _view, html} = live(conn, "/membership_fee_types")

View file

@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ fee_type.name || html =~ "selected" assert html =~ fee_type.name || html =~ "selected"
end end
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 end

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
@doc """ @doc """
Signs in a user via OIDC and returns a connection with the user authenticated. 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. 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 def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users # Ensure unique email for OIDC users
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
oidc_id: "oidc_#{unique_id}" oidc_id: "oidc_#{unique_id}"
} }
# Create user using Ash.Seed (supports oidc_id)
user = create_test_user(Map.merge(default_attrs, user_attrs)) 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 end
@doc """ @doc """
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|> AshAuthentication.Plug.Helpers.store_in_session(user) |> AshAuthentication.Plug.Helpers.store_in_session(user)
end 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 setup tags do
pid = Mv.DataCase.setup_sandbox(tags) pid = Mv.DataCase.setup_sandbox(tags)
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
# to share the test's database connection in async tests # to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid) 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
end end

View file

@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
{user, member} {user, member}
end 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 end