Merge pull request 'Member Resource Policies closes #345' (#346) from feature/345_member_policies_2 into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #346
This commit is contained in:
commit
e9bcfe4fa6
49 changed files with 2593 additions and 1102 deletions
4
Justfile
4
Justfile
|
|
@ -32,6 +32,8 @@ lint:
|
|||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
# Check that all German translations are filled (UI must be in German)
|
||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
|
|
@ -116,4 +118,4 @@ init-prod-secrets:
|
|||
|
||||
# Start production environment with Docker Compose
|
||||
start-prod: init-prod-secrets
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -110,8 +110,8 @@ Control access to LiveView pages:
|
|||
Three scope levels for permissions:
|
||||
- **:own** - Only records where `record.id == user.id` (for User resource)
|
||||
- **:linked** - Only records linked to user via relationships
|
||||
- Member: `member.user_id == user.id`
|
||||
- CustomFieldValue: `custom_field_value.member.user_id == user.id`
|
||||
- Member: `id == user.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship)
|
||||
- **:all** - All records, no filtering
|
||||
|
||||
**6. Special Cases**
|
||||
|
|
@ -714,8 +714,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
- **:all** - Authorizes without filtering (returns all records)
|
||||
- **:own** - Filters to records where record.id == actor.id
|
||||
- **:linked** - Filters based on resource type:
|
||||
- Member: member.user_id == actor.id
|
||||
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -799,12 +799,14 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
defp apply_scope(:linked, actor, resource_name) do
|
||||
case resource_name do
|
||||
"Member" ->
|
||||
# Member.user_id == actor.id (direct relationship)
|
||||
{:filter, expr(user_id == ^actor.id)}
|
||||
# User.member_id → Member.id (inverse relationship)
|
||||
# Filter: member.id == actor.member_id
|
||||
{:filter, expr(id == ^actor.member_id)}
|
||||
|
||||
"CustomFieldValue" ->
|
||||
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
|
||||
{:filter, expr(member.user_id == ^actor.id)}
|
||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||
# Filter: custom_field_value.member_id == actor.member_id
|
||||
{:filter, expr(member_id == ^actor.member_id)}
|
||||
|
||||
_ ->
|
||||
# Fallback for other resources: try direct user_id
|
||||
|
|
@ -918,7 +920,7 @@ end
|
|||
|
||||
**Location:** `lib/mv/membership/member.ex`
|
||||
|
||||
**Special Case:** Users can always access their linked member (where `member.user_id == user.id`).
|
||||
**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Membership.Member do
|
||||
|
|
@ -978,10 +980,10 @@ defmodule Mv.Membership.CustomFieldValue do
|
|||
|
||||
policies do
|
||||
# SPECIAL CASE: Users can access custom field values of their linked member
|
||||
# Note: This traverses the member relationship!
|
||||
# Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||
policy action_type([:read, :update]) do
|
||||
description "Users can access custom field values of their linked member"
|
||||
authorize_if expr(member.user_id == ^actor(:id))
|
||||
authorize_if expr(member_id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# GENERAL: Check permissions from role
|
||||
|
|
|
|||
|
|
@ -294,7 +294,9 @@ Each Permission Set contains:
|
|||
**:own** - Only records where id == actor.id
|
||||
- Example: User can read their own User record
|
||||
|
||||
**:linked** - Only records where user_id == actor.id
|
||||
**:linked** - Only records linked to actor via relationships
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
|
||||
- Example: User can read Member linked to their account
|
||||
|
||||
**:all** - All records without restriction
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ defmodule Mv.Membership.Member do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Helpers
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
|
@ -118,11 +120,12 @@ defmodule Mv.Membership.Member do
|
|||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||
change after_transaction(fn _changeset, result, _context ->
|
||||
change after_transaction(fn changeset, result, _context ->
|
||||
case result do
|
||||
{:ok, member} ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
handle_cycle_generation(member)
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, actor: actor)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
|
|
@ -193,7 +196,9 @@ defmodule Mv.Membership.Member do
|
|||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||
|
||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||
case regenerate_cycles_on_type_change(member) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
case regenerate_cycles_on_type_change(member, actor: actor) do
|
||||
{:ok, notifications} ->
|
||||
# Return notifications to Ash - they will be sent automatically after commit
|
||||
{:ok, member, notifications}
|
||||
|
|
@ -225,7 +230,8 @@ defmodule Mv.Membership.Member do
|
|||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||||
|
||||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||||
handle_cycle_generation(member)
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, actor: actor)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
|
|
@ -296,6 +302,41 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Authorization Policies
|
||||
# Order matters: Most specific policies first, then general permission check
|
||||
policies do
|
||||
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
|
||||
# In test: All operations allowed (for test fixtures)
|
||||
# In production/dev: ALL operations denied without actor (fail-closed for security)
|
||||
# NoActor.check uses compile-time environment detection to prevent security issues
|
||||
bypass action_type([:create, :read, :update, :destroy]) do
|
||||
description "Allow system operations without actor (test environment only)"
|
||||
authorize_if Mv.Authorization.Checks.NoActor
|
||||
end
|
||||
|
||||
# SPECIAL CASE: Users can always READ their linked member
|
||||
# This allows users with ANY permission set to read their own linked member
|
||||
# Check using the inverse relationship: User.member_id → Member.id
|
||||
bypass action_type(:read) do
|
||||
description "Users can always read member linked to their account"
|
||||
authorize_if expr(id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# GENERAL: Check permissions from user's role
|
||||
# HasPermission handles update permissions correctly:
|
||||
# - :own_data → can update linked member (scope :linked)
|
||||
# - :read_only → cannot update any member (no update permission)
|
||||
# - :normal_user → can update all members (scope :all)
|
||||
# - :admin → can update all members (scope :all)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# DEFAULT: Forbid if no policy matched
|
||||
# Ash implicitly forbids if no policy authorized
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members list based on email match priority.
|
||||
|
||||
|
|
@ -363,8 +404,13 @@ defmodule Mv.Membership.Member do
|
|||
user_id = user_arg[:id]
|
||||
current_member_id = changeset.data.id
|
||||
|
||||
# Get actor from changeset context for authorization
|
||||
# If no actor is present, this will fail in production (fail-closed)
|
||||
actor = Map.get(changeset.context || %{}, :actor)
|
||||
|
||||
# Check the current state of the user in the database
|
||||
case Ash.get(Mv.Accounts.User, user_id) do
|
||||
# Pass actor to ensure proper authorization (User might have policies in future)
|
||||
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
|
||||
# User is free to be linked
|
||||
{:ok, %{member_id: nil}} ->
|
||||
:ok
|
||||
|
|
@ -742,33 +788,37 @@ defmodule Mv.Membership.Member do
|
|||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||||
# to be sent after transaction commits
|
||||
@doc false
|
||||
def regenerate_cycles_on_type_change(member) do
|
||||
def regenerate_cycles_on_type_change(member, opts \\ []) do
|
||||
today = Date.utc_today()
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||
# This ensures atomicity when multiple updates happen simultaneously
|
||||
if Mv.Repo.in_transaction?() do
|
||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
|
||||
else
|
||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
|
||||
end
|
||||
|
||||
# Not in transaction: start new transaction with advisory lock
|
||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Mv.Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
|
||||
{:ok, notifications} ->
|
||||
# Return notifications - they will be sent by the caller
|
||||
notifications
|
||||
|
|
@ -790,6 +840,7 @@ defmodule Mv.Membership.Member do
|
|||
require Ash.Query
|
||||
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
# Find all unpaid cycles for this member
|
||||
# We need to check cycle_end for each cycle using its own interval
|
||||
|
|
@ -799,10 +850,21 @@ defmodule Mv.Membership.Member do
|
|||
|> Ash.Query.filter(status == :unpaid)
|
||||
|> Ash.Query.load([:membership_fee_type])
|
||||
|
||||
case Ash.read(all_unpaid_cycles_query) do
|
||||
result =
|
||||
if actor do
|
||||
Ash.read(all_unpaid_cycles_query, actor: actor)
|
||||
else
|
||||
Ash.read(all_unpaid_cycles_query)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, all_unpaid_cycles} ->
|
||||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
||||
|
||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
|
||||
skip_lock?: skip_lock?,
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
|
@ -831,13 +893,14 @@ defmodule Mv.Membership.Member do
|
|||
# Returns {:ok, notifications} or {:error, reason}
|
||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
if Enum.empty?(cycles_to_delete) do
|
||||
# No cycles to delete, just regenerate
|
||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
|
||||
else
|
||||
case delete_cycles(cycles_to_delete) do
|
||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
|
@ -863,11 +926,13 @@ defmodule Mv.Membership.Member do
|
|||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles(member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member_id,
|
||||
today: today,
|
||||
skip_lock?: skip_lock?
|
||||
skip_lock?: skip_lock?,
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||||
{:ok, notifications}
|
||||
|
|
@ -881,21 +946,25 @@ defmodule Mv.Membership.Member do
|
|||
# based on environment (test vs production)
|
||||
# This function encapsulates the common logic for cycle generation
|
||||
# to avoid code duplication across different hooks
|
||||
defp handle_cycle_generation(member) do
|
||||
defp handle_cycle_generation(member, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
if Mv.Config.sql_sandbox?() do
|
||||
handle_cycle_generation_sync(member)
|
||||
handle_cycle_generation_sync(member, actor: actor)
|
||||
else
|
||||
handle_cycle_generation_async(member)
|
||||
handle_cycle_generation_async(member, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member) do
|
||||
defp handle_cycle_generation_sync(member, opts) do
|
||||
require Logger
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today()
|
||||
today: Date.utc_today(),
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
|
|
@ -907,9 +976,11 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member) do
|
||||
defp handle_cycle_generation_async(member, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
||||
|
|
@ -1138,15 +1209,18 @@ defmodule Mv.Membership.Member do
|
|||
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
|
||||
|
||||
if is_nil(custom_field_values_arg) do
|
||||
extract_existing_values(changeset.data)
|
||||
extract_existing_values(changeset.data, changeset)
|
||||
else
|
||||
extract_argument_values(custom_field_values_arg)
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from existing member data (update scenario)
|
||||
defp extract_existing_values(member_data) do
|
||||
case Ash.load(member_data, :custom_field_values) do
|
||||
defp extract_existing_values(member_data, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
- **:all** - Authorizes without filtering (returns all records)
|
||||
- **:own** - Filters to records where record.id == actor.id
|
||||
- **:linked** - Filters based on resource type:
|
||||
- Member: member.user.id == actor.id (via has_one :user relationship)
|
||||
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member → user relationship!)
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
def strict_check(actor, authorizer, _opts) do
|
||||
resource = authorizer.resource
|
||||
action = get_action_from_authorizer(authorizer)
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
|
||||
cond do
|
||||
is_nil(actor) ->
|
||||
|
|
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
{:ok, false}
|
||||
|
||||
true ->
|
||||
strict_check_with_permissions(actor, resource, action)
|
||||
strict_check_with_permissions(actor, resource, action, record)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to reduce nesting depth
|
||||
defp strict_check_with_permissions(actor, resource, action) do
|
||||
defp strict_check_with_permissions(actor, resource, action, record) do
|
||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||
permissions <- PermissionSets.get_permissions(ps_atom),
|
||||
|
|
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
actor,
|
||||
resource_name
|
||||
) do
|
||||
:authorized -> {:ok, true}
|
||||
{:filter, _} -> {:ok, :unknown}
|
||||
false -> {:ok, false}
|
||||
:authorized ->
|
||||
{:ok, true}
|
||||
|
||||
{:filter, filter_expr} ->
|
||||
# For strict_check on single records, evaluate the filter against the record
|
||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||
|
||||
false ->
|
||||
{:ok, false}
|
||||
end
|
||||
else
|
||||
%{role: nil} ->
|
||||
|
|
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
action = get_action_from_authorizer(authorizer)
|
||||
|
||||
cond do
|
||||
is_nil(actor) -> nil
|
||||
is_nil(action) -> nil
|
||||
true -> auto_filter_with_permissions(actor, resource, action)
|
||||
is_nil(actor) ->
|
||||
# No actor - deny access (fail-closed)
|
||||
# Return filter that never matches (expr(false) = match none)
|
||||
deny_filter()
|
||||
|
||||
is_nil(action) ->
|
||||
# Cannot determine action - deny access (fail-closed)
|
||||
deny_filter()
|
||||
|
||||
true ->
|
||||
auto_filter_with_permissions(actor, resource, action)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
actor,
|
||||
resource_name
|
||||
) do
|
||||
:authorized -> nil
|
||||
{:filter, filter_expr} -> filter_expr
|
||||
false -> nil
|
||||
:authorized ->
|
||||
# :all scope - allow all records (no filter)
|
||||
# Return empty keyword list (no filtering)
|
||||
[]
|
||||
|
||||
{:filter, filter_expr} ->
|
||||
# :linked or :own scope - apply filter
|
||||
# filter_expr is a keyword list from expr(...), return it directly
|
||||
filter_expr
|
||||
|
||||
false ->
|
||||
# No permission - deny access (fail-closed)
|
||||
deny_filter()
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
|
||||
deny_filter()
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to return a filter that never matches (deny all records)
|
||||
# Used when authorization should be denied (fail-closed)
|
||||
#
|
||||
# Using `expr(false)` avoids depending on the primary key being named `:id`.
|
||||
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
|
||||
defp deny_filter do
|
||||
expr(false)
|
||||
end
|
||||
|
||||
# Helper to extract action type from authorizer
|
||||
# CRITICAL: Must use action_type, not action.name!
|
||||
# Action types: :create, :read, :update, :destroy
|
||||
# Action names: :create_member, :update_member, etc.
|
||||
# PermissionSets uses action types, not action names
|
||||
#
|
||||
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
|
||||
defp get_action_from_authorizer(authorizer) do
|
||||
# Primary: Use authorizer.action.type (stable API)
|
||||
case Map.get(authorizer, :action) do
|
||||
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
_ ->
|
||||
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
|
||||
case Map.get(authorizer, :subject) do
|
||||
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
%{action: %{type: action_type}}
|
||||
when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to extract record from authorizer for strict_check
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to extract action from authorizer
|
||||
defp get_action_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{action: %{name: action}} -> action
|
||||
%{action: action} when is_atom(action) -> action
|
||||
_ -> nil
|
||||
# Evaluate filter expression for strict_check on single records
|
||||
# For :linked scope with Member resource: id == actor.member_id
|
||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||
case {resource_name, record} do
|
||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||
# Check if this member's ID matches the actor's member_id
|
||||
if member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
|
||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||
# Check if this CFV's member_id matches the actor's member_id
|
||||
if cfv_member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
|
||||
_ ->
|
||||
# For other cases or when record is not available, return :unknown
|
||||
# This will cause Ash to use auto_filter instead
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
end
|
||||
|
||||
# Scope: linked - Filter based on user relationship (resource-specific!)
|
||||
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
|
||||
# IMPORTANT: Understand the relationship direction!
|
||||
# - User belongs_to :member (User.member_id → Member.id)
|
||||
# - Member has_one :user (inverse, no FK on Member)
|
||||
defp apply_scope(:linked, actor, resource_name) do
|
||||
case resource_name do
|
||||
"Member" ->
|
||||
# Member has_one :user → filter by user.id == actor.id
|
||||
{:filter, expr(user.id == ^actor.id)}
|
||||
# User.member_id → Member.id (inverse relationship)
|
||||
# Filter: member.id == actor.member_id
|
||||
{:filter, expr(id == ^actor.member_id)}
|
||||
|
||||
"CustomFieldValue" ->
|
||||
# CustomFieldValue belongs_to :member → member has_one :user
|
||||
# Traverse: custom_field_value.member.user.id == actor.id
|
||||
{:filter, expr(member.user.id == ^actor.id)}
|
||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||
# Filter: custom_field_value.member_id == actor.member_id
|
||||
{:filter, expr(member_id == ^actor.member_id)}
|
||||
|
||||
_ ->
|
||||
# Fallback for other resources: try user relationship first, then user_id
|
||||
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
|
||||
# Fallback for other resources
|
||||
{:filter, expr(user_id == ^actor.id)}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
74
lib/mv/authorization/checks/no_actor.ex
Normal file
74
lib/mv/authorization/checks/no_actor.ex
Normal 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
|
||||
|
|
@ -41,8 +41,10 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
|||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
with {:ok, member} <- Helpers.extract_record(result),
|
||||
linked_user <- Loader.get_linked_user(member) do
|
||||
linked_user <- Loader.get_linked_user(member, actor) do
|
||||
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
||||
else
|
||||
_ -> result
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
if Map.get(context, :syncing_email, false) do
|
||||
changeset
|
||||
else
|
||||
sync_email(changeset)
|
||||
# Ensure actor is in changeset context - get it from context if available
|
||||
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
|
||||
|
||||
changeset_with_actor =
|
||||
if actor && !Map.has_key?(changeset.context, :actor) do
|
||||
Ash.Changeset.put_context(changeset, :actor, actor)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
sync_email(changeset_with_actor)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -42,7 +52,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
result = callback.(cs)
|
||||
|
||||
with {:ok, record} <- Helpers.extract_record(result),
|
||||
{:ok, user, member} <- get_user_and_member(record) do
|
||||
{:ok, user, member} <- get_user_and_member(record, cs) do
|
||||
# When called from Member-side, we need to update the member in the result
|
||||
# When called from User-side, we update the linked member in DB only
|
||||
case record do
|
||||
|
|
@ -61,15 +71,19 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
end
|
||||
|
||||
# Retrieves user and member - works for both resource types
|
||||
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
||||
case Loader.get_linked_member(user) do
|
||||
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
case Loader.get_linked_member(user, actor) do
|
||||
nil -> {:error, :no_member}
|
||||
member -> {:ok, user, member}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_and_member(%Mv.Membership.Member{} = member) do
|
||||
case Loader.load_linked_user!(member) do
|
||||
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
|
||||
case Loader.load_linked_user!(member, actor) do
|
||||
{:ok, user} -> {:ok, user, member}
|
||||
error -> error
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
|
|||
@moduledoc """
|
||||
Helper functions for loading linked records in email synchronization.
|
||||
Centralizes the logic for retrieving related User/Member entities.
|
||||
|
||||
## Authorization
|
||||
|
||||
This module runs systemically and accepts optional actor parameters.
|
||||
When called from hooks/changes, actor is extracted from changeset context.
|
||||
When called directly, actor should be provided for proper authorization.
|
||||
|
||||
All functions accept an optional `actor` parameter that is passed to Ash operations
|
||||
to ensure proper authorization checks are performed.
|
||||
"""
|
||||
alias Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Loads the member linked to a user, returns nil if not linked or on error.
|
||||
"""
|
||||
def get_linked_member(%{member_id: nil}), do: nil
|
||||
|
||||
def get_linked_member(%{member_id: id}) do
|
||||
case Ash.get(Mv.Membership.Member, id) do
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def get_linked_member(user, actor \\ nil)
|
||||
def get_linked_member(%{member_id: nil}, _actor), do: nil
|
||||
|
||||
def get_linked_member(%{member_id: id}, actor) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||
{:ok, member} -> member
|
||||
{:error, _} -> nil
|
||||
end
|
||||
|
|
@ -18,9 +33,13 @@ defmodule Mv.EmailSync.Loader do
|
|||
|
||||
@doc """
|
||||
Loads the user linked to a member, returns nil if not linked or on error.
|
||||
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def get_linked_user(member) do
|
||||
case Ash.load(member, :user) do
|
||||
def get_linked_user(member, actor \\ nil) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member, :user, opts) do
|
||||
{:ok, %{user: user}} -> user
|
||||
{:error, _} -> nil
|
||||
end
|
||||
|
|
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
|
|||
@doc """
|
||||
Loads the user linked to a member, returning an error tuple if not linked.
|
||||
Useful when a link is required for the operation.
|
||||
|
||||
Accepts optional actor for authorization.
|
||||
"""
|
||||
def load_linked_user!(member) do
|
||||
case Ash.load(member, :user) do
|
||||
def load_linked_user!(member, actor \\ nil) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member, :user, opts) do
|
||||
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
||||
{:ok, _} -> {:error, :no_linked_user}
|
||||
{:error, _} = error -> error
|
||||
|
|
|
|||
27
lib/mv/helpers.ex
Normal file
27
lib/mv/helpers.ex
Normal 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
|
||||
|
|
@ -8,6 +8,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
This allows creating members with the same email as unlinked users.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
alias Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked Member-User pairs.
|
||||
|
|
@ -29,7 +30,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
|
||||
linked_user_id = get_linked_user_id(changeset.data)
|
||||
actor = Map.get(changeset.context || %{}, :actor)
|
||||
linked_user_id = get_linked_user_id(changeset.data, actor)
|
||||
is_linked? = not is_nil(linked_user_id)
|
||||
|
||||
# Only validate if member is already linked AND email is changing
|
||||
|
|
@ -38,19 +40,21 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
|
||||
if should_validate? do
|
||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
check_email_uniqueness(new_email, linked_user_id)
|
||||
check_email_uniqueness(new_email, linked_user_id, actor)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_user_id) do
|
||||
defp check_email_uniqueness(email, exclude_user_id, actor) do
|
||||
query =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> maybe_exclude_id(exclude_user_id)
|
||||
|
||||
case Ash.read(query) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -65,8 +69,10 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
|
||||
defp get_linked_user_id(member_data) do
|
||||
case Ash.load(member_data, :user) do
|
||||
defp get_linked_user_id(member_data, actor) do
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :user, opts) do
|
||||
{:ok, %{user: %{id: id}}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
||||
cycles for the same member concurrently.
|
||||
|
||||
## Authorization
|
||||
|
||||
This module runs systemically and accepts optional actor parameters.
|
||||
When called from hooks/changes, actor is extracted from changeset context.
|
||||
When called directly, actor should be provided for proper authorization.
|
||||
|
||||
All functions accept an optional `actor` parameter in the `opts` keyword list
|
||||
that is passed to Ash operations to ensure proper authorization checks are performed.
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles for a single member
|
||||
|
|
@ -77,7 +86,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
||||
|
||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
||||
case load_member(member_id) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
case load_member(member_id, actor: actor) do
|
||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
|
@ -87,25 +98,27 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
do_generate_cycles_with_lock(member, today, skip_lock?)
|
||||
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
|
||||
end
|
||||
|
||||
# Generate cycles with lock handling
|
||||
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
||||
# they should be returned to the caller (e.g., via after_action hook)
|
||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
|
||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
|
||||
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
||||
# Just generate cycles without additional locking
|
||||
do_generate_cycles(member, today)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
do_generate_cycles(member, today, actor: actor)
|
||||
end
|
||||
|
||||
defp do_generate_cycles_with_lock(member, today, false) do
|
||||
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_generate_cycles(member, today) do
|
||||
case do_generate_cycles(member, today, actor: actor) do
|
||||
{:ok, cycles, notifications} ->
|
||||
# Return cycles and notifications - do NOT send notifications here
|
||||
# They will be sent by the caller (e.g., via after_action hook)
|
||||
|
|
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
|
||||
# Private functions
|
||||
|
||||
defp load_member(member_id) do
|
||||
Member
|
||||
|> Ash.Query.filter(id == ^member_id)
|
||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||
|> Ash.read_one()
|
||||
|> case do
|
||||
defp load_member(member_id, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(id == ^member_id)
|
||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||
|
||||
result =
|
||||
if actor do
|
||||
Ash.read_one(query, actor: actor)
|
||||
else
|
||||
Ash.read_one(query)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, nil} -> {:error, :member_not_found}
|
||||
{:ok, member} -> {:ok, member}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_generate_cycles(member, today) do
|
||||
defp do_generate_cycles(member, today, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
# Reload member with relationships to ensure fresh data
|
||||
case load_member(member.id) do
|
||||
case load_member(member.id, actor: actor) do
|
||||
{:ok, member} ->
|
||||
cond do
|
||||
is_nil(member.membership_fee_type_id) ->
|
||||
|
|
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
{:error, :no_join_date}
|
||||
|
||||
true ->
|
||||
generate_missing_cycles(member, today)
|
||||
generate_missing_cycles(member, today, actor: actor)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
end
|
||||
end
|
||||
|
||||
defp generate_missing_cycles(member, today) do
|
||||
defp generate_missing_cycles(member, today, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
fee_type = member.membership_fee_type
|
||||
interval = fee_type.interval
|
||||
amount = fee_type.amount
|
||||
|
|
@ -270,7 +296,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
# Only generate if start_date <= end_date
|
||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
||||
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
|
||||
else
|
||||
{:ok, [], []}
|
||||
end
|
||||
|
|
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
end
|
||||
end
|
||||
|
||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
|
||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
# Always use return_notifications?: true to collect notifications
|
||||
# Notifications will be returned to the caller, who is responsible for
|
||||
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
||||
|
|
@ -380,7 +407,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
}
|
||||
|
||||
handle_cycle_creation_result(
|
||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
|
||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
|
||||
cycle_start
|
||||
)
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
|
||||
{:ok, custom_field} ->
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
|||
page_title = action <> " " <> "Custom field value"
|
||||
|
||||
# Load all CustomFields and Members for the selection fields
|
||||
custom_fields = Ash.read!(Mv.Membership.CustomField)
|
||||
members = Ash.read!(Mv.Membership.Member)
|
||||
actor = current_actor(socket)
|
||||
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
|
||||
members = Ash.read!(Mv.Membership.Member, actor: actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
|||
custom_field_value_params
|
||||
end
|
||||
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case submit_form(socket.assigns.form, updated_params, actor) do
|
||||
{:ok, custom_field_value} ->
|
||||
notify_parent({:saved, custom_field_value})
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
|
||||
actor = current_actor(socket)
|
||||
|
||||
# Early return if no actor (prevents exceptions in unauthenticated tests)
|
||||
if is_nil(actor) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, [])}
|
||||
else
|
||||
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
|
||||
{:ok, custom_field_values} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, custom_field_values)}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, [])
|
||||
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, [])
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
|
||||
Ash.destroy!(custom_field_value)
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
|
||||
case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
|
||||
{:ok, custom_field_value} ->
|
||||
case Ash.destroy(custom_field_value, actor: actor) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_delete(:custom_field_values, custom_field_value)
|
||||
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this custom field value")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this custom field value")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
|
||||
end
|
||||
|
||||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
|
||||
{:ok, _updated_settings} ->
|
||||
# Reload settings from database to ensure all dependent data is updated
|
||||
{:ok, fresh_settings} = Membership.get_settings()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
|
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<select
|
||||
class="select select-bordered w-full"
|
||||
name={@form[:membership_fee_type_id].name}
|
||||
phx-change="validate_membership_fee_type"
|
||||
phx-change="validate"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<option value="">{gettext("None")}</option>
|
||||
|
|
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
|
||||
actor = current_actor(socket)
|
||||
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
||||
|
||||
initial_custom_field_values =
|
||||
|
|
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
|
|||
member =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
|
||||
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
|
||||
end
|
||||
|
||||
page_title =
|
||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||
|
||||
# Load available membership fee types
|
||||
available_fee_types = load_available_fee_types(member)
|
||||
available_fee_types = load_available_fee_types(member, actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -265,53 +271,80 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member" => member_params}, socket) do
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
|
||||
# Merge with existing form values to preserve unchanged fields (especially custom_field_values)
|
||||
# Extract values directly from form fields to get current state
|
||||
existing_values = get_existing_form_values(socket.assigns.form)
|
||||
|
||||
# Merge existing values with new params (new params take precedence)
|
||||
merged_params = Map.merge(existing_values, member_params)
|
||||
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
|
||||
|
||||
# Check for interval mismatch if membership_fee_type_id changed
|
||||
socket = check_interval_change(socket, member_params)
|
||||
socket = check_interval_change(socket, merged_params)
|
||||
|
||||
{:noreply, assign(socket, form: validated_form)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"validate_membership_fee_type",
|
||||
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
|
||||
socket
|
||||
) do
|
||||
# Same validation as above, but triggered by select change
|
||||
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
|
||||
end
|
||||
|
||||
def handle_event("save", %{"member" => member_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
|
||||
{:ok, member} ->
|
||||
notify_parent({:saved, member})
|
||||
try do
|
||||
actor = current_actor(socket)
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
case submit_form(socket.assigns.form, member_params, actor) do
|
||||
{:ok, member} ->
|
||||
handle_save_success(socket, member)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
rescue
|
||||
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
|
||||
handle_save_forbidden(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, member) do
|
||||
notify_parent({:saved, member})
|
||||
|
||||
flash_message =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("Member created successfully")
|
||||
:update -> gettext("Member updated successfully")
|
||||
other -> gettext("Member %{action} successfully", action: get_action_name(other))
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp handle_save_forbidden(socket) do
|
||||
# Handle policy violations that aren't properly displayed in forms
|
||||
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
|
||||
action = get_action_name(socket.assigns.form.source.type)
|
||||
|
||||
error_message =
|
||||
gettext("You do not have permission to %{action} members.", action: action)
|
||||
|
||||
{:noreply, put_flash(socket, :error, error_message)}
|
||||
end
|
||||
|
||||
defp get_action_name(:create), do: gettext("create")
|
||||
defp get_action_name(:update), do: gettext("update")
|
||||
defp get_action_name(other), do: to_string(other)
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{member: member}} = socket) do
|
||||
defp assign_form(%{assigns: assigns} = socket) do
|
||||
member = assigns.member
|
||||
actor = assigns[:current_user] || assigns.current_user
|
||||
|
||||
form =
|
||||
if member do
|
||||
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
|
||||
{:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
|
||||
|
||||
existing_custom_field_values =
|
||||
member.custom_field_values
|
||||
|
|
@ -342,7 +375,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
api: Mv.Membership,
|
||||
as: "member",
|
||||
params: params,
|
||||
forms: [auto?: true]
|
||||
forms: [auto?: true],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
missing_custom_field_values =
|
||||
|
|
@ -360,7 +394,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
api: Mv.Membership,
|
||||
as: "member",
|
||||
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
||||
forms: [auto?: true]
|
||||
forms: [auto?: true],
|
||||
actor: actor
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -375,11 +410,11 @@ defmodule MvWeb.MemberLive.Form do
|
|||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp load_available_fee_types(member) do
|
||||
defp load_available_fee_types(member, actor) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: MembershipFees)
|
||||
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||
|
||||
# If member has a fee type, filter to same interval
|
||||
if member && member.membership_fee_type do
|
||||
|
|
@ -453,4 +488,167 @@ defmodule MvWeb.MemberLive.Form do
|
|||
defp custom_field_input_type(:date), do: "date"
|
||||
defp custom_field_input_type(:email), do: "email"
|
||||
defp custom_field_input_type(_), do: "text"
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions for Form Value Preservation
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Helper to extract existing form values to preserve them when only one field changes
|
||||
# This ensures custom_field_values and other fields are preserved when only the dropdown changes
|
||||
defp get_existing_form_values(form) do
|
||||
%{}
|
||||
|> extract_form_value(form, :first_name, &to_string/1)
|
||||
|> extract_form_value(form, :last_name, &to_string/1)
|
||||
|> extract_form_value(form, :email, &to_string/1)
|
||||
|> extract_form_value(form, :street, &to_string/1)
|
||||
|> extract_form_value(form, :house_number, &to_string/1)
|
||||
|> extract_form_value(form, :postal_code, &to_string/1)
|
||||
|> extract_form_value(form, :city, &to_string/1)
|
||||
|> extract_form_value(form, :join_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :exit_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :notes, &to_string/1)
|
||||
|> extract_form_value(form, :membership_fee_type_id, &to_string/1)
|
||||
|> extract_form_value(form, :membership_fee_start_date, &format_date_value/1)
|
||||
|> extract_custom_field_values(form)
|
||||
end
|
||||
|
||||
# Helper to extract a single form field value
|
||||
defp extract_form_value(acc, form, field, formatter) do
|
||||
if form[field] && form[field].value do
|
||||
Map.put(acc, to_string(field), formatter.(form[field].value))
|
||||
else
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from the form structure
|
||||
# The form is a Phoenix.HTML.Form with source being AshPhoenix.Form
|
||||
# Custom field values are in form.source.params["custom_field_values"] as a map
|
||||
defp extract_custom_field_values(acc, form) do
|
||||
cfv_params = get_custom_field_values_params(form)
|
||||
|
||||
if map_size(cfv_params) > 0 do
|
||||
custom_field_values = convert_cfv_params_to_list(cfv_params)
|
||||
Map.put(acc, "custom_field_values", custom_field_values)
|
||||
else
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
# Gets custom_field_values from form params
|
||||
defp get_custom_field_values_params(form) do
|
||||
ash_form = form.source
|
||||
|
||||
if ash_form && Map.has_key?(ash_form, :params) && ash_form.params["custom_field_values"] do
|
||||
ash_form.params["custom_field_values"]
|
||||
else
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Converts custom field values map to sorted list
|
||||
defp convert_cfv_params_to_list(cfv_params) do
|
||||
cfv_params
|
||||
|> Map.to_list()
|
||||
|> Enum.sort_by(&parse_numeric_key/1)
|
||||
|> Enum.map(&build_custom_field_value/1)
|
||||
end
|
||||
|
||||
# Parses numeric key for sorting
|
||||
defp parse_numeric_key({key, _}) do
|
||||
case Integer.parse(key) do
|
||||
{num, _} -> num
|
||||
:error -> 999_999
|
||||
end
|
||||
end
|
||||
|
||||
# Builds a custom field value map from params
|
||||
defp build_custom_field_value({_key, cfv_map}) do
|
||||
%{
|
||||
"custom_field_id" => Map.get(cfv_map, "custom_field_id", ""),
|
||||
"value" => extract_custom_field_value_from_map(Map.get(cfv_map, "value", %{}))
|
||||
}
|
||||
end
|
||||
|
||||
# Extracts the value map structure from a custom field value
|
||||
# Handles both map format and Ash.Union struct format
|
||||
defp extract_custom_field_value_from_map(%Ash.Union{} = union) do
|
||||
union_type = Atom.to_string(union.type)
|
||||
|
||||
%{
|
||||
"_union_type" => union_type,
|
||||
"type" => union_type,
|
||||
"value" => format_custom_field_value(union.value)
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_custom_field_value_from_map(value_map) when is_map(value_map) do
|
||||
union_type = extract_union_type_from_map(value_map)
|
||||
value = Map.get(value_map, "value") || Map.get(value_map, :value)
|
||||
|
||||
%{
|
||||
"_union_type" => union_type,
|
||||
"type" => union_type,
|
||||
"value" => format_custom_field_value(value)
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_custom_field_value_from_map(_),
|
||||
do: %{"_union_type" => "", "type" => "", "value" => ""}
|
||||
|
||||
# Extracts union type from map, checking various possible locations
|
||||
defp extract_union_type_from_map(value_map) do
|
||||
cond do
|
||||
has_non_empty_string(value_map, "_union_type") ->
|
||||
Map.get(value_map, "_union_type")
|
||||
|
||||
has_non_empty_atom(value_map, :_union_type) ->
|
||||
to_string(Map.get(value_map, :_union_type))
|
||||
|
||||
has_atom_type(value_map) ->
|
||||
Atom.to_string(Map.get(value_map, :type))
|
||||
|
||||
has_string_type(value_map) ->
|
||||
Map.get(value_map, "type")
|
||||
|
||||
true ->
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to check if map has non-empty string value
|
||||
defp has_non_empty_string(map, key) do
|
||||
value = Map.get(map, key)
|
||||
value && value != ""
|
||||
end
|
||||
|
||||
# Helper to check if map has non-empty atom value
|
||||
defp has_non_empty_atom(map, key) do
|
||||
value = Map.get(map, key)
|
||||
value && value != ""
|
||||
end
|
||||
|
||||
# Helper to check if map has atom type
|
||||
defp has_atom_type(map) do
|
||||
value = Map.get(map, :type)
|
||||
value && is_atom(value)
|
||||
end
|
||||
|
||||
# Helper to check if map has string type
|
||||
defp has_string_type(map) do
|
||||
value = Map.get(map, "type")
|
||||
value && is_binary(value)
|
||||
end
|
||||
|
||||
# Formats custom field value based on its type
|
||||
defp format_custom_field_value(%Date{} = date), do: Date.to_iso8601(date)
|
||||
defp format_custom_field_value(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
|
||||
defp format_custom_field_value(value) when is_boolean(value), do: to_string(value)
|
||||
defp format_custom_field_value(value) when is_binary(value), do: value
|
||||
defp format_custom_field_value(value), do: to_string(value)
|
||||
|
||||
# Formats date value (Date or string) to string
|
||||
defp format_date_value(%Date{} = date), do: Date.to_iso8601(date)
|
||||
defp format_date_value(value) when is_binary(value), do: value
|
||||
defp format_date_value(_), do: ""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
|
@ -55,20 +58,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Load custom fields that should be shown in overview (for display)
|
||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
||||
# should be visible to the user rather than silently failing.
|
||||
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
|
||||
# This is appropriate for initialization errors that should be visible to the user.
|
||||
actor = current_actor(socket)
|
||||
|
||||
custom_fields_visible =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(show_in_overview == true))
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||
all_custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Load settings once to avoid N+1 queries
|
||||
settings =
|
||||
|
|
@ -130,13 +134,41 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"""
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
|
||||
# This ensures users see error messages if deletion fails (e.g., permission denied)
|
||||
member = Ash.get!(Mv.Membership.Member, id)
|
||||
Ash.destroy!(member)
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
||||
{:noreply, assign(socket, :members, updated_members)}
|
||||
case Ash.get(Mv.Membership.Member, id, actor: actor) do
|
||||
{:ok, member} ->
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:members, updated_members)
|
||||
|> put_flash(:info, gettext("Member deleted successfully"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -236,6 +268,24 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Helper to format errors for display
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
Enum.map(errors, fn error ->
|
||||
case error do
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
%{message: message} -> message
|
||||
_ -> inspect(error)
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.join(error_messages, ", ")
|
||||
end
|
||||
|
||||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Handle Infos from Child Components
|
||||
# -----------------------------------------------------------------
|
||||
|
|
@ -676,9 +726,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.custom_fields_visible
|
||||
)
|
||||
|
||||
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
|
||||
# This is appropriate for data loading in LiveViews
|
||||
members = Ash.read!(query)
|
||||
# Errors in handle_params are handled by Phoenix LiveView
|
||||
actor = current_actor(socket)
|
||||
members = Ash.read!(query, actor: actor)
|
||||
|
||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||
# No need for in-memory filtering anymore
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
|
|
@ -148,9 +151,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Additional Data Fields")}>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
|
|
@ -220,6 +223,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
|
|
@ -233,15 +237,15 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
# Load custom fields for display
|
||||
# Note: Each page load starts a new LiveView process, so caching with
|
||||
# assign_new is not necessary here (mount creates a fresh socket each time)
|
||||
custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
actor = current_actor(socket)
|
||||
|
||||
socket = assign(socket, :custom_fields, custom_fields)
|
||||
# Load custom fields once using assign_new to avoid repeated queries
|
||||
socket =
|
||||
assign_new(socket, :custom_fields, fn ->
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(actor: actor)
|
||||
end)
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|
|
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
member = Ash.read_one!(query)
|
||||
member = Ash.read_one!(query, actor: actor)
|
||||
|
||||
# Calculate last and current cycle status from loaded cycles
|
||||
last_cycle_status = get_last_cycle_status(member)
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
use MvWeb, :live_component
|
||||
|
||||
require Ash.Query
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
|
|
@ -63,7 +65,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete All Cycles")}
|
||||
title={gettext("Delete all cycles")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete All Cycles")}
|
||||
|
|
@ -168,7 +170,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete Cycle")}
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
|
|
@ -329,14 +331,16 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
{gettext("The cycle will be calculated based on this date and the interval.")}
|
||||
{gettext(
|
||||
"The cycle period will be calculated based on this date and the interval."
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<%= if @create_cycle_date do %>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">{gettext("Cycle")}</span>
|
||||
<span class="label-text">{gettext("Cycle Period")}</span>
|
||||
</label>
|
||||
<div class="text-sm text-base-content/70">
|
||||
{format_create_cycle_period(
|
||||
|
|
@ -388,6 +392,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
@impl true
|
||||
def update(assigns, socket) do
|
||||
member = assigns.member
|
||||
actor = assigns.current_user
|
||||
|
||||
# Load cycles if not already loaded
|
||||
cycles =
|
||||
|
|
@ -401,7 +406,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
|
||||
|
||||
# Get available fee types (filtered to same interval if member has a type)
|
||||
available_fee_types = get_available_fee_types(member)
|
||||
available_fee_types = get_available_fee_types(member, actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -422,7 +427,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
@impl true
|
||||
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
|
||||
# Remove membership fee type
|
||||
case update_member_fee_type(socket.assigns.member, nil) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case update_member_fee_type(socket.assigns.member, nil, actor) do
|
||||
{:ok, updated_member} ->
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
|
|
@ -430,7 +437,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, [])
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(
|
||||
:available_fee_types,
|
||||
get_available_fee_types(updated_member, current_actor(socket))
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||
|
||||
|
|
@ -441,7 +451,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
|
||||
member = socket.assigns.member
|
||||
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
|
||||
actor = current_actor(socket)
|
||||
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
|
||||
|
||||
# Check if interval matches
|
||||
interval_warning =
|
||||
|
|
@ -459,15 +470,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
if interval_warning do
|
||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||
else
|
||||
case update_member_fee_type(member, fee_type_id) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case update_member_fee_type(member, fee_type_id, actor) do
|
||||
{:ok, updated_member} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
updated_member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
|
|
@ -482,7 +500,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(
|
||||
:available_fee_types,
|
||||
get_available_fee_types(updated_member, current_actor(socket))
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|
||||
|
|
@ -503,7 +524,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
:suspended -> :mark_as_suspended
|
||||
end
|
||||
|
||||
case Ash.update(cycle, action: action, domain: MembershipFees) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
|
|
@ -533,16 +556,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
def handle_event("regenerate_cycles", _params, socket) do
|
||||
socket = assign(socket, :regenerating, true)
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
|
|
@ -572,7 +601,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
actor = current_actor(socket)
|
||||
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
|
||||
|
||||
{:noreply, assign(socket, :editing_cycle, cycle)}
|
||||
end
|
||||
|
|
@ -589,9 +619,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
case Decimal.parse(normalized_amount_str) do
|
||||
{amount, _} when is_struct(amount, Decimal) ->
|
||||
actor = current_actor(socket)
|
||||
|
||||
case cycle
|
||||
|> Ash.Changeset.for_update(:update, %{amount: amount})
|
||||
|> Ash.update(domain: MembershipFees) do
|
||||
|> Ash.update(domain: MembershipFees, actor: actor) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
|
|
@ -616,7 +648,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
actor = current_actor(socket)
|
||||
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
|
||||
|
||||
{:noreply, assign(socket, :deleting_cycle, cycle)}
|
||||
end
|
||||
|
|
@ -627,8 +660,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.destroy(cycle, domain: MembershipFees) do
|
||||
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||
|
||||
|
|
@ -699,12 +733,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
if deleted_count > 0 do
|
||||
# Reload member to get updated cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated_cycles =
|
||||
Enum.sort_by(
|
||||
|
|
@ -786,15 +825,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
membership_fee_type_id: member.membership_fee_type_id
|
||||
}
|
||||
|
||||
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
|
||||
{:ok, _new_cycle} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
|
|
@ -842,11 +886,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
# Helper functions
|
||||
|
||||
defp get_available_fee_types(member) do
|
||||
defp get_available_fee_types(member, actor) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||
|
||||
# If member has a fee type, filter to same interval
|
||||
if member.membership_fee_type do
|
||||
|
|
@ -858,12 +902,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
end
|
||||
end
|
||||
|
||||
defp update_member_fee_type(member, fee_type_id) do
|
||||
defp update_member_fee_type(member, fee_type_id, actor) do
|
||||
attrs = %{membership_fee_type_id: fee_type_id}
|
||||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|
||||
|> Ash.update(domain: Membership)
|
||||
|> Ash.update(domain: Membership, actor: actor)
|
||||
end
|
||||
|
||||
defp find_cycle(cycles, cycle_id) do
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
Map.put(params, "include_joining_cycle", false)
|
||||
end
|
||||
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do
|
||||
{:ok, updated_settings} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
|
@ -305,7 +308,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
if socket.assigns.show_amount_warning do
|
||||
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
|
||||
else
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case submit_form(socket.assigns.form, params, actor) do
|
||||
{:ok, membership_fee_type} ->
|
||||
notify_parent({:saved, membership_fee_type})
|
||||
|
||||
|
|
@ -380,7 +385,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
||||
|
||||
@spec get_affected_member_count(String.t()) :: non_neg_integer()
|
||||
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
|
||||
# Checks if amount changed and updates socket assigns accordingly
|
||||
defp check_amount_change(socket, params) do
|
||||
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
|
||||
|
|
@ -428,7 +433,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
socket.assigns.affected_member_count
|
||||
else
|
||||
# Warning being shown for first time, calculate count
|
||||
get_affected_member_count(socket.assigns.membership_fee_type.id)
|
||||
get_affected_member_count(socket.assigns.membership_fee_type.id, current_actor(socket))
|
||||
end
|
||||
|
||||
socket
|
||||
|
|
@ -446,8 +451,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
|> assign(:pending_amount, nil)
|
||||
end
|
||||
|
||||
defp get_affected_member_count(fee_type_id) do
|
||||
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
|
||||
defp get_affected_member_count(fee_type_id, actor) do
|
||||
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id),
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, count} -> count
|
||||
_ -> 0
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership
|
||||
|
|
@ -24,8 +27,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
fee_types = load_membership_fee_types()
|
||||
member_counts = load_member_counts(fee_types)
|
||||
actor = current_actor(socket)
|
||||
fee_types = load_membership_fee_types(actor)
|
||||
member_counts = load_member_counts(fee_types, actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -129,18 +133,43 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.destroy(fee_type, domain: MembershipFees) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||
{:ok, fee_type} ->
|
||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
|
|
@ -149,14 +178,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
# Helper functions
|
||||
|
||||
defp load_membership_fee_types do
|
||||
defp load_membership_fee_types(actor) do
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: MembershipFees)
|
||||
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||
end
|
||||
|
||||
# Loads all member counts for fee types in a single query to avoid N+1 queries
|
||||
defp load_member_counts(fee_types) do
|
||||
defp load_member_counts(fee_types, actor) do
|
||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||
|
||||
# Load all members with membership_fee_type_id in a single query
|
||||
|
|
@ -164,7 +193,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
Member
|
||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||
|> Ash.Query.select([:membership_fee_type_id])
|
||||
|> Ash.read!(domain: Membership)
|
||||
|> Ash.read!(domain: Membership, actor: actor)
|
||||
|
||||
# Group by membership_fee_type_id and count
|
||||
members
|
||||
|
|
|
|||
|
|
@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
|
|||
end
|
||||
|
||||
def handle_event("save", %{"role" => role_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
|
||||
{:ok, role} ->
|
||||
notify_parent({:saved, role})
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|
||||
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
||||
defp load_roles(actor) do
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
|
||||
|
||||
case Authorization.list_roles(opts) do
|
||||
{:ok, roles} -> Enum.sort_by(roles, & &1.name)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
user =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||
end
|
||||
|
||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||
|
|
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||
|
||||
|
|
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
socket =
|
||||
if Map.has_key?(user_params, "email") do
|
||||
user_email = user_params["email"]
|
||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
|
||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
||||
|
||||
assign(socket, form: validated_form, available_members: members)
|
||||
else
|
||||
|
|
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
actor = current_actor(socket)
|
||||
# First save the user without member changes
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
|
||||
case submit_form(socket.assigns.form, user_params, actor) do
|
||||
{:ok, user} ->
|
||||
# Then handle member linking/unlinking as a separate step
|
||||
result =
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
|
||||
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil})
|
||||
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, updated_user} ->
|
||||
notify_parent({:saved, updated_user})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Show user-friendly error from member linking/unlinking
|
||||
error_message = extract_error_message(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to link member: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
handle_member_linking(socket, user, actor)
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("show_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("hide_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
|
|
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
|
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
|
|||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
select_focused_member(socket)
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_member", %{"id" => member_id}, socket) do
|
||||
# Find the selected member to get their name
|
||||
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
|
||||
|
|
@ -448,27 +428,82 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(:selected_member_name, member_name)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:member_search_query, member_name)
|
||||
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlink_member", _params, socket) do
|
||||
# Set flag to unlink member on save
|
||||
# Clear all member selection state and keep dropdown hidden
|
||||
socket =
|
||||
socket
|
||||
|> assign(:unlink_member, true)
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:unlink_member, true)
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> load_initial_members()
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp handle_member_linking(socket, user, actor) do
|
||||
result = perform_member_link_action(socket, user, actor)
|
||||
|
||||
case result do
|
||||
{:ok, updated_user} ->
|
||||
handle_save_success(socket, updated_user)
|
||||
|
||||
{:error, error} ->
|
||||
handle_member_link_error(socket, error)
|
||||
end
|
||||
end
|
||||
|
||||
defp perform_member_link_action(socket, user, actor) do
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, updated_user) do
|
||||
notify_parent({:saved, updated_user})
|
||||
|
||||
action = get_action_name(socket.assigns.form.source.type)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp get_action_name(:create), do: gettext("created")
|
||||
defp get_action_name(:update), do: gettext("updated")
|
||||
defp get_action_name(other), do: to_string(other)
|
||||
|
||||
defp handle_member_link_error(socket, error) do
|
||||
error_message = extract_error_message(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to link member: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
|
|
@ -497,18 +532,21 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
form =
|
||||
if user do
|
||||
# For existing users, use admin password action if password fields are shown
|
||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user")
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||
else
|
||||
# For new users, use password registration if password fields are shown
|
||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||
|
||||
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
|
||||
domain: Mv.Accounts,
|
||||
as: "user"
|
||||
as: "user",
|
||||
actor: actor
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, "")
|
||||
members = load_members_for_linking(user_email, "", socket)
|
||||
|
||||
# Dropdown should ALWAYS be hidden initially
|
||||
# It will only show when user focuses the input field (show_member_dropdown event)
|
||||
|
|
@ -539,12 +577,15 @@ defmodule MvWeb.UserLive.Form do
|
|||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, query)
|
||||
members = load_members_for_linking(user_email, query, socket)
|
||||
assign(socket, available_members: members)
|
||||
end
|
||||
|
||||
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
|
||||
defp load_members_for_linking(user_email, search_query) do
|
||||
@spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
|
||||
[
|
||||
Mv.Membership.Member.t()
|
||||
]
|
||||
defp load_members_for_linking(user_email, search_query, socket) do
|
||||
user_email_str = if user_email, do: to_string(user_email), else: nil
|
||||
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||
|
||||
|
|
@ -555,20 +596,27 @@ defmodule MvWeb.UserLive.Form do
|
|||
search_query: search_query_str
|
||||
})
|
||||
|
||||
case Ash.read(query, domain: Mv.Membership) do
|
||||
{:ok, members} ->
|
||||
# Apply email match filter if user_email is provided
|
||||
if user_email_str do
|
||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
else
|
||||
members
|
||||
end
|
||||
actor = current_actor(socket)
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
# Early return if no actor (prevents exceptions in unauthenticated tests)
|
||||
if is_nil(actor) do
|
||||
[]
|
||||
else
|
||||
case Ash.read(query, domain: Mv.Membership, actor: actor) do
|
||||
{:ok, members} -> apply_email_filter(members, user_email_str)
|
||||
{:error, _} -> []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
|
||||
[Mv.Membership.Member.t()]
|
||||
defp apply_email_filter(members, nil), do: members
|
||||
|
||||
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
|
||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
end
|
||||
|
||||
# Extract user-friendly error message from Ash.Error
|
||||
@spec extract_error_message(any()) :: String.t()
|
||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||
|
|
@ -576,10 +624,10 @@ defmodule MvWeb.UserLive.Form do
|
|||
case List.first(errors) do
|
||||
%{message: message} when is_binary(message) -> message
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
_ -> "Unknown error"
|
||||
_ -> gettext("Unknown error")
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(error) when is_binary(error), do: error
|
||||
defp extract_error_message(_), do: "Unknown error"
|
||||
defp extract_error_message(_), do: gettext("Unknown error")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ defmodule MvWeb.UserLive.Index do
|
|||
use MvWeb, :live_view
|
||||
import MvWeb.TableComponents
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
|
||||
actor = current_actor(socket)
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||
sorted = Enum.sort_by(users, & &1.email)
|
||||
|
||||
{:ok,
|
||||
|
|
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
|
||||
{:noreply, assign(socket, :users, updated_users)}
|
||||
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
|
||||
{:ok, user} ->
|
||||
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
||||
:ok ->
|
||||
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:users, updated_users)
|
||||
|> put_flash(:info, gettext("User deleted successfully"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this user")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
# Selects one user in the list of users
|
||||
|
|
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
|
|||
defp toggle_order(:desc), do: :asc
|
||||
defp sort_fun(:asc), do: &<=/2
|
||||
defp sort_fun(:desc), do: &>=/2
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
|
||||
end
|
||||
|
||||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ defmodule MvWeb.UserLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
||||
actor = current_actor(socket)
|
||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
|
|||
user
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Helper function to get the current actor (user) from socket assigns.
|
||||
|
||||
Provides consistent access pattern across all LiveViews.
|
||||
Returns nil if no current_user is present.
|
||||
|
||||
## Examples
|
||||
|
||||
actor = current_actor(socket)
|
||||
members = Membership.list_members!(actor: actor)
|
||||
"""
|
||||
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
|
||||
def current_actor(socket) do
|
||||
socket.assigns[:current_user] || socket.assigns.current_user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an actor to Ash options list for authorization.
|
||||
Returns empty list if actor is nil.
|
||||
|
||||
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
|
||||
|
||||
## Examples
|
||||
|
||||
opts = ash_actor_opts(actor)
|
||||
Ash.read(query, opts)
|
||||
"""
|
||||
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||
defdelegate ash_actor_opts(actor), to: Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Submits an AshPhoenix form with consistent actor handling.
|
||||
|
||||
This wrapper ensures that actor is always passed via `action_opts`
|
||||
in a consistent manner across all LiveViews.
|
||||
|
||||
## Examples
|
||||
|
||||
case submit_form(form, params, actor) do
|
||||
{:ok, resource} -> # success
|
||||
{:error, form} -> # validation errors
|
||||
end
|
||||
"""
|
||||
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
||||
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
|
||||
def submit_form(form, params, actor) do
|
||||
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
1
mix.exs
1
mix.exs
|
|
@ -76,6 +76,7 @@ defmodule Mv.MixProject do
|
|||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"}
|
||||
]
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -60,6 +60,7 @@
|
|||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
## to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
"Language: de\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
|
|
@ -633,6 +633,7 @@ msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
|
@ -1312,14 +1313,14 @@ msgid "These fields are neccessary for MILA to handle member identification and
|
|||
msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field %{action} successfully"
|
||||
msgstr "Mitglied wurde erfolgreich %{action}"
|
||||
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A cycle for this period already exists"
|
||||
msgstr "Für dieses Intervall besteht bereits ein Zyklus."
|
||||
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1366,7 +1367,7 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to edit amount"
|
||||
msgstr "Klicke um den Betrag zu ändern"
|
||||
msgstr "Klicken Sie, um den Betrag zu bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1396,12 +1397,12 @@ msgstr "erstellt"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create Cycle"
|
||||
msgstr "Aktueller Zyklus"
|
||||
msgstr "Zyklus erstellen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create a new cycle manually"
|
||||
msgstr "Erstelle manuell einen neuen Zyklus"
|
||||
msgstr "Einen neuen Zyklus manuell erstellen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1431,7 +1432,7 @@ msgstr "Zyklusbetrag aktualisiert"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle created successfully"
|
||||
msgstr "Zyklen erfolgreich regeneriert"
|
||||
msgstr "Zyklus erfolgreich erstellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1474,9 +1475,9 @@ msgid "Edit Cycle Amount"
|
|||
msgstr "Zyklusbetrag bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Field: %{field}"
|
||||
msgstr "Mitglied bearbeiten"
|
||||
msgstr "Feld bearbeiten: %{field}"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1524,9 +1525,9 @@ msgid "Invalid amount format"
|
|||
msgstr "Ungültiges Betragsformat"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid date format"
|
||||
msgstr "Ungültiges Betragsformat"
|
||||
msgstr "Ungültiges Datumsformat"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1679,9 +1680,9 @@ msgid "Not set"
|
|||
msgstr "Nicht gesetzt"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Interval"
|
||||
msgstr "Zahlungsfilter"
|
||||
msgstr "Zahlungsintervall"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1762,7 +1763,7 @@ msgstr "Art"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr "Trage '%{confirmation}' ein um zu bestätigen"
|
||||
msgstr "Geben Sie '%{confirmation}' ein, um zu bestätigen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1825,11 +1826,6 @@ msgstr "Mitgliedsbeitragsstatus"
|
|||
msgid "Show/Hide Columns"
|
||||
msgstr "Spalten ein-/ausblenden"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "The cycle will be calculated based on this date and the interval."
|
||||
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Back to settings"
|
||||
|
|
@ -1879,78 +1875,78 @@ msgstr "Zurück zur Rollen-Liste"
|
|||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete system role"
|
||||
msgstr ""
|
||||
msgstr "System-Rolle kann nicht gelöscht werden"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
msgstr "Benutzerdefiniert"
|
||||
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Role"
|
||||
msgstr "Bearbeiten"
|
||||
msgstr "Rolle bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to delete role: %{error}"
|
||||
msgstr "Konnte Feld nicht löschen: %{error}"
|
||||
msgstr "Rolle konnte nicht gelöscht werden: %{error}"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Listing Roles"
|
||||
msgstr "Benutzer*innen auflisten"
|
||||
msgstr "Rollen auflisten"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage user roles and their permission sets."
|
||||
msgstr ""
|
||||
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
|
||||
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
||||
msgstr ""
|
||||
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weisen Sie sie zunächst einer anderen Rolle zu."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Close sidebar"
|
||||
msgstr ""
|
||||
msgstr "Sidebar schließen"
|
||||
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Role"
|
||||
msgstr "Zyklus löschen"
|
||||
msgstr "Rolle löschen"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Main navigation"
|
||||
msgstr ""
|
||||
msgstr "Hauptnavigation"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Role"
|
||||
msgstr ""
|
||||
msgstr "Neue Rolle"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No description"
|
||||
msgstr "Beschreibung"
|
||||
msgstr "Keine Beschreibung"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open navigation menu"
|
||||
msgstr ""
|
||||
msgstr "Navigationsmenü öffnen"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Permission Set"
|
||||
msgstr ""
|
||||
msgstr "Berechtigungssatz"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1961,250 +1957,234 @@ msgstr "Profil"
|
|||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role"
|
||||
msgstr ""
|
||||
msgstr "Rolle"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role deleted successfully."
|
||||
msgstr "Zyklen erfolgreich regeneriert"
|
||||
msgstr "Rolle erfolgreich gelöscht."
|
||||
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role details and permissions."
|
||||
msgstr ""
|
||||
msgstr "Rollen-Details und Berechtigungen."
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role not found."
|
||||
msgstr ""
|
||||
msgstr "Rolle nicht gefunden."
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role saved successfully."
|
||||
msgstr ""
|
||||
msgstr "Rolle erfolgreich gespeichert."
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Role"
|
||||
msgstr "Speichern"
|
||||
msgstr "Rolle speichern"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select permission set"
|
||||
msgstr ""
|
||||
msgstr "Berechtigungssatz auswählen"
|
||||
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show Role"
|
||||
msgstr "Anzeigen"
|
||||
msgstr "Rolle anzeigen"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
msgstr "System"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "System Role"
|
||||
msgstr ""
|
||||
msgstr "System-Rolle"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "System roles cannot be deleted"
|
||||
msgstr ""
|
||||
msgstr "System-Rollen können nicht gelöscht werden"
|
||||
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "System roles cannot be deleted."
|
||||
msgstr ""
|
||||
msgstr "System-Rollen können nicht gelöscht werden."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
msgstr "Sidebar umschalten"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||
msgstr "Verwenden Sie dieses Formular, um Rollen in Ihrer Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User menu"
|
||||
msgstr "Benutzer*in"
|
||||
msgstr "Benutzer*innen-Menü"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
msgstr "admin - Uneingeschränkter Zugriff"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "normal_user - Create/Read/Update access"
|
||||
msgstr ""
|
||||
msgstr "normal_user - Erstellen/Lesen/Aktualisieren Zugriff"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "own_data - Access only to own data"
|
||||
msgstr ""
|
||||
msgstr "own_data - Zugriff nur auf eigene Daten"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "read_only - Read access to all data"
|
||||
msgstr ""
|
||||
msgstr "read_only - Lesezugriff auf alle Daten"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Additional Data Fields"
|
||||
msgstr "Zusätzliche Datenfelder"
|
||||
msgid "You do not have permission to %{action} members."
|
||||
msgstr "Sie haben keine Berechtigung, Mitglieder zu %{action}."
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Auto-generated identifier (immutable)"
|
||||
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle Period"
|
||||
msgstr "Zykluszeitraum"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Configure global settings for membership contributions."
|
||||
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete all cycles"
|
||||
msgstr "Alle Zyklen löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution"
|
||||
#~ msgstr "Beitrag"
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete cycle"
|
||||
msgstr "Zyklus löschen"
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Contribution Settings"
|
||||
#~ msgstr "Beitragseinstellungen"
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy emails"
|
||||
#~ msgstr "E-Mails kopieren"
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field value deleted successfully"
|
||||
msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Default Contribution Type"
|
||||
#~ msgstr "Standard-Beitragsart"
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field value not found"
|
||||
msgstr "Benutzerdefinierter Feldwert nicht gefunden"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Immutable"
|
||||
#~ msgstr "Unveränderlich"
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type not found"
|
||||
msgstr "Mitgliedsbeitragsart nicht gefunden"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Include joining period"
|
||||
#~ msgstr "Beitrittsdatum einbeziehen"
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User %{action} successfully"
|
||||
msgstr "Benutzer*in wurde erfolgreich %{action}"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "New Custom field"
|
||||
#~ msgstr "Benutzerdefiniertes Feld speichern"
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User deleted successfully"
|
||||
msgstr "Benutzer*in erfolgreich gelöscht"
|
||||
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Not paid"
|
||||
#~ msgstr "Nicht bezahlt"
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User not found"
|
||||
msgstr "Benutzer*in nicht gefunden"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Payment Cycle"
|
||||
#~ msgstr "Zahlungszyklus"
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this custom field value"
|
||||
msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Pending"
|
||||
#~ msgstr "Ausstehend"
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #: lib/mv_web/translations/member_fields.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Phone"
|
||||
#~ msgstr "Telefon"
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this user"
|
||||
msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Phone Number"
|
||||
#~ msgstr "Telefonnummer"
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this custom field value"
|
||||
msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
||||
#~ msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
msgstr "Sie haben keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Roles"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this user"
|
||||
msgstr "Sie haben keine Berechtigung, diese*n Benutzer*in zu löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show Last/Current Cycle Payment Status"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "created"
|
||||
msgstr "erstellt"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show current cycle"
|
||||
#~ msgstr "Aktuellen Zyklus anzeigen"
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "updated"
|
||||
msgstr "aktualisiert"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show last completed cycle"
|
||||
#~ msgstr "Letzten abgeschlossenen Zyklus anzeigen"
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
msgstr "Unbekannter Fehler"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to current cycle"
|
||||
#~ msgstr "Zum aktuellen Zyklus wechseln"
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member deleted successfully"
|
||||
msgstr "Mitglied wurde erfolgreich gelöscht"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to last completed cycle"
|
||||
#~ msgstr "Zum letzten abgeschlossenen Zyklus wechseln"
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member not found"
|
||||
msgstr "Mitglied nicht gefunden"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This data is for demonstration purposes only (mockup)."
|
||||
#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this member"
|
||||
msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in current cycle"
|
||||
#~ msgstr "Unbezahlt im aktuellen Zyklus"
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this member"
|
||||
msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in last cycle"
|
||||
#~ msgstr "Unbezahlt im letzten Zyklus"
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to view custom field values"
|
||||
msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "View Example Member"
|
||||
#~ msgstr "Beispielmitglied anzeigen"
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member created successfully"
|
||||
msgstr "Mitglied wurde erfolgreich erstellt"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Included"
|
||||
#~ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "monthly"
|
||||
#~ msgstr "monatlich"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "yearly"
|
||||
#~ msgstr "jährlich"
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member updated successfully"
|
||||
msgstr "Mitglied wurde erfolgreich aktualisiert"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
## to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
"Language: de\n"
|
||||
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
|
|
|
|||
|
|
@ -634,6 +634,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
|
@ -1826,11 +1827,6 @@ msgstr ""
|
|||
msgid "Show/Hide Columns"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The cycle will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to settings"
|
||||
|
|
@ -2059,7 +2055,137 @@ msgstr ""
|
|||
msgid "read_only - Read access to all data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Additional Data Fields"
|
||||
msgid "You do not have permission to %{action} members."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle Period"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete all cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field value deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field value not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this custom field value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this custom field value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to view custom field values"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member created successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member updated successfully"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -219,14 +219,14 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr "created"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "update"
|
||||
msgstr "updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -455,12 +455,12 @@ msgstr ""
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||
msgstr "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "At least 8 characters"
|
||||
msgstr "At least 8 characters"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -470,32 +470,32 @@ msgstr ""
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Check 'Change Password' above to set a new password for this user."
|
||||
msgstr "Check 'Change Password' above to set a new password for this user."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Password"
|
||||
msgstr "Confirm Password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Consider using special characters"
|
||||
msgstr "Consider using special characters"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Include both letters and numbers"
|
||||
msgstr "Include both letters and numbers"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Password requirements"
|
||||
msgstr "Password requirements"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -510,12 +510,12 @@ msgstr ""
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Set Password"
|
||||
msgstr "Set Password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -634,6 +634,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
|
@ -1392,7 +1393,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create"
|
||||
msgstr "created"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1412,7 +1413,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle Payment Status"
|
||||
msgstr "Current Cycle Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1537,7 +1538,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle Payment Status"
|
||||
msgstr "Last Cycle Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1826,11 +1827,6 @@ msgstr ""
|
|||
msgid "Show/Hide Columns"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "The cycle will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Back to settings"
|
||||
|
|
@ -2059,163 +2055,137 @@ msgstr ""
|
|||
msgid "read_only - Read access to all data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Additional Data Fields"
|
||||
msgid "You do not have permission to %{action} members."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "All payment statuses"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle Period"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Configure global settings for membership contributions."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete all cycles"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete cycle"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution Settings"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Example: Member Contribution View"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom field value deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Failed to save settings. Please check the errors below."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom field value not found"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Failed to update member field visibility: %{error}"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee type not found"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #: lib/mv_web/live/user_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Generated periods"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "User %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Immutable"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "User deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Not paid"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "User not found"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Payment Cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this custom field value"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Pending"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #: lib/mv_web/translations/member_fields.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Phone"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this user"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Phone Number"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to delete this custom field value"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Roles"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to delete this user"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Save Custom Field"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show Last/Current Cycle Payment Status"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "updated"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show current cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show last completed cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "String"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member not found"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to current cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this member"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to last completed cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this member"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This data is for demonstration purposes only (mockup)."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to view custom field values"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in current cycle"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member created successfully"
|
||||
msgstr "Member created successfully"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in last cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "View Example Member"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Included"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "monthly"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "yearly"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member updated successfully"
|
||||
msgstr "Member updated successfully"
|
||||
|
|
|
|||
|
|
@ -162,6 +162,17 @@ if admin_role do
|
|||
|> Ash.update!()
|
||||
end
|
||||
|
||||
# Load admin user with role for use as actor in member operations
|
||||
# This ensures all member operations have proper authorization
|
||||
# If admin role creation failed, we cannot proceed with member operations
|
||||
admin_user_with_role =
|
||||
if admin_role do
|
||||
admin_user
|
||||
|> Ash.load!(:role)
|
||||
else
|
||||
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||
end
|
||||
|
||||
# Load all membership fee types for assignment
|
||||
# Sort by name to ensure deterministic order
|
||||
all_fee_types =
|
||||
|
|
@ -236,7 +247,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
member =
|
||||
Membership.create_member!(member_attrs_without_fee_type,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
upsert_identity: :unique_email,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||
|
|
@ -247,7 +259,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|> Ash.Changeset.put_context(:actor, admin_user_with_role)
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -264,7 +277,10 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||
# Generate cycles
|
||||
{:ok, new_cycles, _notifications} =
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||
skip_lock?: true,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
new_cycles
|
||||
else
|
||||
|
|
@ -299,7 +315,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
if cycle.status != status do
|
||||
cycle
|
||||
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -371,13 +387,15 @@ Enum.with_index(linked_members)
|
|||
Membership.create_member!(
|
||||
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
upsert_identity: :unique_email,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
else
|
||||
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||
Membership.create_member!(member_attrs_without_fee_type,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
upsert_identity: :unique_email,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -391,7 +409,7 @@ Enum.with_index(linked_members)
|
|||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -408,7 +426,10 @@ Enum.with_index(linked_members)
|
|||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||
# Generate cycles
|
||||
{:ok, new_cycles, _notifications} =
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||
skip_lock?: true,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
new_cycles
|
||||
else
|
||||
|
|
@ -435,7 +456,7 @@ Enum.with_index(linked_members)
|
|||
end)
|
||||
|
||||
# Create sample custom field values for some members
|
||||
all_members = Ash.read!(Membership.Member)
|
||||
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||
all_custom_fields = Ash.read!(Membership.CustomField)
|
||||
|
||||
# Helper function to find custom field by name
|
||||
|
|
@ -463,7 +484,11 @@ if hans = find_member.("hans.mueller@example.de") do
|
|||
custom_field_id: field.id,
|
||||
value: value
|
||||
})
|
||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
||||
|> Ash.create!(
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_custom_field_per_member,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -488,7 +513,11 @@ if greta = find_member.("greta.schmidt@example.de") do
|
|||
custom_field_id: field.id,
|
||||
value: value
|
||||
})
|
||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
||||
|> Ash.create!(
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_custom_field_per_member,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -514,7 +543,11 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
|
|||
custom_field_id: field.id,
|
||||
value: value
|
||||
})
|
||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
||||
|> Ash.create!(
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_custom_field_per_member,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
|||
alias Mv.Authorization.Checks.HasPermission
|
||||
|
||||
# Helper to create mock actor with role
|
||||
defp create_actor_with_role(permission_set_name) do
|
||||
%{
|
||||
defp create_actor_with_role(permission_set_name, opts \\ []) do
|
||||
actor = %{
|
||||
id: "user-#{System.unique_integer([:positive])}",
|
||||
role: %{permission_set_name: permission_set_name}
|
||||
}
|
||||
|
||||
# Add member_id if provided (needed for :linked scope tests)
|
||||
case Keyword.get(opts, :member_id) do
|
||||
nil -> actor
|
||||
member_id -> Map.put(actor, :member_id, member_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Filter Expression Structure - :linked scope" do
|
||||
test "Member filter uses user.id relationship path" do
|
||||
actor = create_actor_with_role("own_data")
|
||||
test "Member filter uses actor.member_id (inverse relationship)" do
|
||||
actor = create_actor_with_role("own_data", member_id: "member-123")
|
||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||
|
||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||
|
|
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
|||
assert is_list(filter) or is_map(filter)
|
||||
end
|
||||
|
||||
test "CustomFieldValue filter uses member.user.id relationship path" do
|
||||
actor = create_actor_with_role("own_data")
|
||||
test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
|
||||
actor = create_actor_with_role("own_data", member_id: "member-123")
|
||||
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
|
||||
|
||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||
|
|
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
|||
end
|
||||
|
||||
describe "Filter Expression Structure - :all scope" do
|
||||
test "Admin can read all members without filter" do
|
||||
test "Admin can read all members without filter (returns expr(true))" do
|
||||
actor = create_actor_with_role("admin")
|
||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||
|
||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||
|
||||
# :all scope should return nil (no filter needed)
|
||||
assert is_nil(filter)
|
||||
# :all scope should return [] (empty keyword list = no filter = allow all records)
|
||||
# After auto_filter fix: no longer returns nil, returns [] instead
|
||||
assert filter == []
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
|||
defp create_authorizer(resource, action) do
|
||||
%Ash.Policy.Authorizer{
|
||||
resource: resource,
|
||||
subject: %{action: %{name: action}}
|
||||
subject: %{
|
||||
action: %{type: action},
|
||||
data: nil
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
defp create_authorizer(resource, action) do
|
||||
%Ash.Policy.Authorizer{
|
||||
resource: resource,
|
||||
subject: %{action: %{name: action}}
|
||||
subject: %{
|
||||
action: %{type: action},
|
||||
data: nil
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Helper to create actor with role
|
||||
defp create_actor(id, permission_set_name) do
|
||||
%{
|
||||
defp create_actor(id, permission_set_name, opts \\ []) do
|
||||
actor = %{
|
||||
id: id,
|
||||
role: %{permission_set_name: permission_set_name}
|
||||
}
|
||||
|
||||
# Add member_id if provided (needed for :linked scope tests)
|
||||
case Keyword.get(opts, :member_id) do
|
||||
nil -> actor
|
||||
member_id -> Map.put(actor, :member_id, member_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "describe/1" do
|
||||
|
|
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
|
||||
describe "auto_filter/3 - Scope :linked" do
|
||||
test "scope :linked for Member returns user_id filter" do
|
||||
user = create_actor("user-123", "own_data")
|
||||
user = create_actor("user-123", "own_data", member_id: "member-456")
|
||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||
|
||||
filter = HasPermission.auto_filter(user, authorizer, [])
|
||||
|
|
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
end
|
||||
|
||||
test "scope :linked for CustomFieldValue returns member.user_id filter" do
|
||||
user = create_actor("user-123", "own_data")
|
||||
user = create_actor("user-123", "own_data", member_id: "member-456")
|
||||
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
|
||||
|
||||
filter = HasPermission.auto_filter(user, authorizer, [])
|
||||
|
|
|
|||
430
test/mv/membership/member_policies_test.exs
Normal file
430
test/mv/membership/member_policies_test.exs
Normal 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
|
||||
|
|
@ -1,21 +1,42 @@
|
|||
defmodule MvWeb.AuthControllerTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
import Phoenix.ConnTest
|
||||
|
||||
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
||||
defp build_unauthenticated_conn(authenticated_conn) do
|
||||
# Create new conn but preserve sandbox metadata for database access
|
||||
new_conn = build_conn()
|
||||
|
||||
# Copy sandbox metadata from authenticated conn
|
||||
if authenticated_conn.private[:ecto_sandbox] do
|
||||
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
|
||||
else
|
||||
new_conn
|
||||
end
|
||||
end
|
||||
|
||||
# Basic UI tests
|
||||
test "GET /sign-in shows sign in form", %{conn: conn} do
|
||||
test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
conn = get(conn, ~p"/sign-in")
|
||||
assert html_response(conn, 200) =~ "Sign in"
|
||||
end
|
||||
|
||||
test "GET /sign-out redirects to home", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
|
||||
conn = conn_with_oidc_user(authenticated_conn)
|
||||
conn = get(conn, ~p"/sign-out")
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
# Password authentication (LiveView)
|
||||
test "password user can sign in with valid credentials via LiveView", %{conn: conn} do
|
||||
test "password user can sign in with valid credentials via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "password@example.com",
|
||||
|
|
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||
end
|
||||
|
||||
test "password user with invalid credentials shows error via LiveView", %{conn: conn} do
|
||||
test "password user with invalid credentials shows error via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "test@example.com",
|
||||
|
|
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert html =~ "Email or password was incorrect"
|
||||
end
|
||||
|
||||
test "password user with non-existent email shows error via LiveView", %{conn: conn} do
|
||||
test "password user with non-existent email shows error via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/sign-in")
|
||||
|
||||
html =
|
||||
|
|
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
|
|||
end
|
||||
|
||||
# Registration (LiveView)
|
||||
test "user can register with valid credentials via LiveView", %{conn: conn} do
|
||||
test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/register")
|
||||
|
||||
{:error, {:redirect, %{to: to}}} =
|
||||
|
|
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||
end
|
||||
|
||||
test "registration with existing email shows error via LiveView", %{conn: conn} do
|
||||
test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "existing@example.com",
|
||||
|
|
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert html =~ "has already been taken"
|
||||
end
|
||||
|
||||
test "registration with weak password shows error via LiveView", %{conn: conn} do
|
||||
test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/register")
|
||||
|
||||
html =
|
||||
|
|
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
|
|||
end
|
||||
|
||||
# Access control
|
||||
test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do
|
||||
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
conn = get(conn, ~p"/members")
|
||||
assert redirected_to(conn) == ~p"/sign-in"
|
||||
end
|
||||
|
||||
test "authenticated user can access protected route", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
test "authenticated user can access protected route", %{conn: authenticated_conn} do
|
||||
conn = conn_with_oidc_user(authenticated_conn)
|
||||
conn = get(conn, ~p"/members")
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
test "password authenticated user can access protected route via LiveView", %{conn: conn} do
|
||||
test "password authenticated user can access protected route via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "auth@example.com",
|
||||
|
|
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
|
|||
end
|
||||
|
||||
# Edge cases
|
||||
test "user with nil oidc_id can still sign in with password via LiveView", %{conn: conn} do
|
||||
test "user with nil oidc_id can still sign in with password via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "nil_oidc@example.com",
|
||||
|
|
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||
end
|
||||
|
||||
test "user with empty string oidc_id is handled correctly via LiveView", %{conn: conn} do
|
||||
test "user with empty string oidc_id is handled correctly via LiveView", %{
|
||||
conn: authenticated_conn
|
||||
} do
|
||||
# Create unauthenticated conn for this test
|
||||
conn = build_unauthenticated_conn(authenticated_conn)
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "empty_oidc@example.com",
|
||||
|
|
|
|||
|
|
@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
authenticated_conn = conn_with_password_user(conn, user)
|
||||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
# Use global setup from ConnCase which provides admin user with role
|
||||
# No custom setup needed
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
|
|
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
# Uses admin actor from global setup to ensure authorization
|
||||
defp create_member(attrs, actor) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
|
|
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(opts)
|
||||
end
|
||||
|
||||
describe "list display" do
|
||||
|
|
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
assert html =~ "Yearly" || html =~ "Jährlich"
|
||||
end
|
||||
|
||||
test "member count column shows correct count", %{conn: conn} do
|
||||
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create 3 members with this fee type
|
||||
Enum.each(1..3, fn _ ->
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
|
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "delete functionality" do
|
||||
test "delete button disabled if type is in use", %{conn: conn} do
|
||||
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
||||
|
|
|
|||
|
|
@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
authenticated_conn = conn_with_password_user(conn, user)
|
||||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
|
|
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
assert html =~ fee_type.name || html =~ "selected"
|
||||
end
|
||||
end
|
||||
|
||||
describe "custom field value preservation" do
|
||||
test "custom field values preserved when membership fee type changes", %{
|
||||
conn: conn,
|
||||
current_user: admin_user
|
||||
} do
|
||||
# Create custom field
|
||||
custom_field =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Field",
|
||||
value_type: :string,
|
||||
required: false
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create two fee types with same interval
|
||||
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
|
||||
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
|
||||
|
||||
# Create member with fee type 1 and custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type1.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
# Add custom field value
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Change membership fee type dropdown
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|
||||
|> render_change()
|
||||
|
||||
# Verify custom field value is still present (check for field name or value)
|
||||
assert html =~ custom_field.name || html =~ "Test Value"
|
||||
end
|
||||
|
||||
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
|
||||
# Create date custom field
|
||||
custom_field =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Date Field",
|
||||
value_type: :date,
|
||||
required: false
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member with date custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
test_date = ~D[2024-01-15]
|
||||
|
||||
# Add date custom field value
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: %{"_union_type" => "date", "_union_value" => test_date}
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Trigger validation (simulates dropdown change)
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type.id})
|
||||
|> render_change()
|
||||
|
||||
# Verify date value is still present (check for date input or formatted date)
|
||||
assert html =~ "2024" || html =~ "date"
|
||||
end
|
||||
|
||||
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
|
||||
# Create custom field
|
||||
custom_field =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Field",
|
||||
value_type: :string,
|
||||
required: false
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member with custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
# Add custom field value
|
||||
_cfv =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Change membership fee type to trigger validation
|
||||
# This should preserve the custom field value
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", %{
|
||||
"member[membership_fee_type_id]" => fee_type.id
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
# Form should still be valid and custom field value should be preserved
|
||||
# The custom field value should still be visible in the form
|
||||
assert html =~ "Test Value" || html =~ custom_field.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
|
|
|
|||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(build_conn(), user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
|
|
|
|||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
|
|||
@doc """
|
||||
Signs in a user via OIDC and returns a connection with the user authenticated.
|
||||
By default creates a user with "user@example.com" for consistency.
|
||||
The user will have an admin role for authorization.
|
||||
"""
|
||||
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
|
||||
# Ensure unique email for OIDC users
|
||||
|
|
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
|
|||
oidc_id: "oidc_#{unique_id}"
|
||||
}
|
||||
|
||||
# Create user using Ash.Seed (supports oidc_id)
|
||||
user = create_test_user(Map.merge(default_attrs, user_attrs))
|
||||
sign_in_user_via_oidc(conn, user)
|
||||
|
||||
# Create admin role and assign it
|
||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
# Load role for authorization
|
||||
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
|
||||
|
||||
sign_in_user_via_oidc(conn, user_with_role)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|
|||
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a connection with an authenticated user that has an admin role.
|
||||
This is useful for tests that need full access to resources.
|
||||
"""
|
||||
def conn_with_admin_user(conn) do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn_with_password_user(conn, admin_user)
|
||||
end
|
||||
|
||||
setup tags do
|
||||
pid = Mv.DataCase.setup_sandbox(tags)
|
||||
|
||||
|
|
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
|
|||
# to share the test's database connection in async tests
|
||||
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
|
||||
|
||||
{:ok, conn: conn}
|
||||
# Handle role tags for future test extensions
|
||||
# Default to admin to maintain backward compatibility with existing tests
|
||||
role = Map.get(tags, :role, :admin)
|
||||
|
||||
{conn, user} =
|
||||
case role do
|
||||
:admin ->
|
||||
# Create admin user with role for all tests (unless test overrides with its own user)
|
||||
# This ensures all tests have an authenticated user with proper authorization
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
authenticated_conn = conn_with_password_user(conn, admin_user)
|
||||
{authenticated_conn, admin_user}
|
||||
|
||||
:member ->
|
||||
# Create member user for role-based testing
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("member")
|
||||
authenticated_conn = conn_with_password_user(conn, member_user)
|
||||
{authenticated_conn, member_user}
|
||||
|
||||
:unauthenticated ->
|
||||
# No authentication for unauthenticated tests
|
||||
{conn, nil}
|
||||
|
||||
_other ->
|
||||
# Fallback: treat unknown role as admin for safety
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
authenticated_conn = conn_with_password_user(conn, admin_user)
|
||||
{authenticated_conn, admin_user}
|
||||
end
|
||||
|
||||
{:ok, conn: conn, current_user: user}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
|
|||
|
||||
{user, member}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a role with a specific permission set.
|
||||
|
||||
## Parameters
|
||||
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
|
||||
|
||||
## Returns
|
||||
- Role struct
|
||||
|
||||
## Examples
|
||||
|
||||
iex> role_fixture("admin")
|
||||
%Mv.Authorization.Role{permission_set_name: "admin", ...}
|
||||
|
||||
"""
|
||||
def role_fixture(permission_set_name) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Mv.Authorization.create_role(%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
}) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a user with a specific permission set (role).
|
||||
|
||||
## Parameters
|
||||
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
|
||||
- `user_attrs` - Optional user attributes
|
||||
|
||||
## Returns
|
||||
- User struct with role preloaded
|
||||
|
||||
## Examples
|
||||
|
||||
iex> admin_user = user_with_role_fixture("admin")
|
||||
iex> admin_user.role.permission_set_name
|
||||
"admin"
|
||||
|
||||
"""
|
||||
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
|
||||
# Create role with permission set
|
||||
role = role_fixture(permission_set_name)
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
user_attrs
|
||||
|> Enum.into(%{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Mv.Accounts.create_user()
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a member with an actor (for use in tests with policies).
|
||||
|
||||
## Parameters
|
||||
- `attrs` - Map or keyword list of attributes to override defaults
|
||||
- `actor` - The actor (user) to use for authorization
|
||||
|
||||
## Returns
|
||||
- Member struct
|
||||
|
||||
## Examples
|
||||
|
||||
iex> admin = user_with_role_fixture("admin")
|
||||
iex> member_fixture_with_actor(%{first_name: "Alice"}, admin)
|
||||
%Mv.Membership.Member{first_name: "Alice", ...}
|
||||
|
||||
"""
|
||||
def member_fixture_with_actor(attrs \\ %{}, actor) do
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Mv.Membership.create_member(actor: actor)
|
||||
|> case do
|
||||
{:ok, member} -> member
|
||||
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue