Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
74f398e58e Update Mix dependencies
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 00:11:52 +00:00
61 changed files with 987 additions and 2863 deletions

View file

@ -81,8 +81,8 @@ lib/
├── membership/ # Membership domain ├── membership/ # Membership domain
│ ├── membership.ex # Domain definition │ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource │ ├── member.ex # Member resource
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource │ ├── custom_field_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue type resource
│ ├── setting.ex # Global settings (singleton resource) │ ├── setting.ex # Global settings (singleton resource)
│ └── email.ex # Email custom type │ └── email.ex # Email custom type
├── membership_fees/ # MembershipFees domain ├── membership_fees/ # MembershipFees domain
@ -194,8 +194,7 @@ test/
├── seeds_test.exs # Database seed tests ├── seeds_test.exs # Database seed tests
└── support/ # Test helpers └── support/ # Test helpers
├── conn_case.ex # Controller test helpers ├── conn_case.ex # Controller test helpers
├── data_case.ex # Data layer test helpers └── data_case.ex # Data layer test helpers
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
``` ```
### 1.2 Module Organization ### 1.2 Module Organization
@ -1248,8 +1247,7 @@ test/
│ └── components/ │ └── components/
└── support/ # Test helpers └── support/ # Test helpers
├── conn_case.ex # Controller test setup ├── conn_case.ex # Controller test setup
├── data_case.ex # Database test setup └── data_case.ex # Database test setup
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
``` ```
**Test File Naming:** **Test File Naming:**

View file

@ -4,7 +4,7 @@
**Feature:** Groups Management **Feature:** Groups Management
**Version:** 1.0 **Version:** 1.0
**Last Updated:** 2025-01-XX **Last Updated:** 2025-01-XX
**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) **Status:** Architecture Design - Ready for Implementation
--- ---
@ -412,17 +412,15 @@ lib/
## Authorization ## Authorization
**Status:** ✅ Implemented. Group and MemberGroup resource policies and PermissionSets are in place. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
### Permission Model (MVP) ### Permission Model (MVP)
**Resource:** `Group` (and `MemberGroup`) **Resource:** `groups`
**Actions:** **Actions:**
- `read` - View groups (all permission sets) - `read` - View groups (all users with member read permission)
- `create` - Create groups (normal_user and admin) - `create` - Create groups (admin only)
- `update` - Edit groups (normal_user and admin) - `update` - Edit groups (admin only)
- `destroy` - Delete groups (normal_user and admin) - `destroy` - Delete groups (admin only)
**Scopes:** **Scopes:**
- `:all` - All groups (for all permission sets that have read access) - `:all` - All groups (for all permission sets that have read access)
@ -444,7 +442,7 @@ lib/
**Own Data Permission Set:** **Own Data Permission Set:**
- `read` action on `Group` resource with `:all` scope - granted - `read` action on `Group` resource with `:all` scope - granted
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. normal_user and admin can manage (create/update/destroy) groups. **Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
### Member-Group Association Permissions ### Member-Group Association Permissions

View file

@ -334,18 +334,20 @@ lib/
### Permission System Integration ### Permission System Integration
**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns. **See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
**PermissionSets (lib/mv/authorization/permission_sets.ex):** **Required Permissions:**
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all). - `MembershipFeeType.create/update/destroy` - Admin only
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). - `MembershipFeeType.read` - Admin, Treasurer, Board
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor. - `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
**Resource Policies:** **Policy Patterns:**
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. - Use existing HasPermission check
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. - Leverage existing roles (Admin, Kassenwart)
- Member can read own cycles (linked via member_id)
### LiveView Integration ### LiveView Integration
@ -355,7 +357,7 @@ lib/
2. MembershipFeeCycle table component (member detail view) 2. MembershipFeeCycle table component (member detail view)
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
- Displays all cycles in a table with status management - Displays all cycles in a table with status management
- Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin) - Allows changing cycle status, editing amounts, and regenerating cycles
3. Settings form section (admin) 3. Settings form section (admin)
4. Member list column (membership fee status) 4. Member list column (membership fee status)

View file

@ -97,10 +97,6 @@ Control CRUD operations on:
- CustomFieldValue (custom field values) - CustomFieldValue (custom field values)
- CustomField (custom field definitions) - CustomField (custom field definitions)
- Role (role management) - Role (role management)
- Group (group definitions; read all, create/update/destroy normal_user and admin)
- MemberGroup (membergroup associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
- MembershipFeeCycle (fee cycles; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
**4. Page-Level Permissions** **4. Page-Level Permissions**
@ -109,7 +105,6 @@ Control access to LiveView pages:
- Show pages (detail views) - Show pages (detail views)
- Form pages (create/edit) - Form pages (create/edit)
- Admin pages - Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
**5. Granular Scopes** **5. Granular Scopes**
@ -126,8 +121,6 @@ Three scope levels for permissions:
- **Linked Member Email:** Only admins can edit email of member linked to user - **Linked Member Email:** Only admins can edit email of member linked to user
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) - **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
- **User-Member Linking:** Only admins can link/unlink users and members - **User-Member Linking:** Only admins can link/unlink users and members
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
**7. UI Consistency** **7. UI Consistency**
@ -691,12 +684,6 @@ Quick reference table showing what each permission set allows:
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D | | **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D | | **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D | | **Role** (all) | - | - | - | R, C, U, D |
| **Group** (all) | R | R | R, C, U, D | R, C, U, D |
| **MemberGroup** (linked) | R | - | - | - |
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
| **MembershipFeeCycle** (linked) | R | - | - | - |
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
**Legend:** R=Read, C=Create, U=Update, D=Destroy **Legend:** R=Read, C=Create, U=Update, D=Destroy
@ -1208,36 +1195,6 @@ end
*Cannot destroy if `is_system_role=true` *Cannot destroy if `is_system_role=true`
### User Role Assignment (Admin-Only)
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
### Group Resource Policies
**Location:** `lib/membership/group.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
### MemberGroup Resource Policies
**Location:** `lib/membership/member_group.ex`
Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).
### MembershipFeeType Resource Policies
**Location:** `lib/membership_fees/membership_fee_type.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
### MembershipFeeCycle Resource Policies
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.
--- ---
## Page Permission System ## Page Permission System

View file

@ -78,11 +78,10 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
- ✅ Hardcoded PermissionSets module with 4 permission sets - ✅ Hardcoded PermissionSets module with 4 permission sets
- ✅ Role database table and CRUD interface - ✅ Role database table and CRUD interface
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets - ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle) - ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`) - ✅ Page-level permissions via Phoenix Plug
- ✅ UI authorization helpers for conditional rendering - ✅ UI authorization helpers for conditional rendering
- ✅ Special case: Member email validation for linked users - ✅ Special case: Member email validation for linked users
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
- ✅ Seed data for 5 roles - ✅ Seed data for 5 roles
**Benefits of Hardcoded Approach:** **Benefits of Hardcoded Approach:**

View file

@ -8,9 +8,6 @@ defmodule Mv.Accounts.User do
extensions: [AshAuthentication], extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer] authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr
postgres do postgres do
table "users" table "users"
repo Mv.Repo repo Mv.Repo
@ -149,10 +146,9 @@ defmodule Mv.Accounts.User do
update :update_user do update :update_user do
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one." description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
# Only accept email directly - member_id is NOT in accept list
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins). # This prevents direct foreign key manipulation, forcing use of manage_relationship
# member_id is NOT in accept list - use argument :member for relationship management. accept [:email]
accept [:email, :role_id]
# Allow member to be passed as argument for relationship management # Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true argument :member, :map, allow_nil?: true
@ -391,63 +387,6 @@ defmodule Mv.Accounts.User do
end end
end end
# Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
# Only block when the user is leaving admin (target role is not admin). Switching between
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
if is_nil(new_role_id) do
:ok
else
current_role_id = changeset.data.role_id
current_role =
Mv.Authorization.Role
|> Ash.get!(current_role_id, authorize?: false)
new_role =
Mv.Authorization.Role
|> Ash.get!(new_role_id, authorize?: false)
# Only block when current user is admin and target role is not admin (leaving admin)
if current_role.permission_set_name == "admin" and
new_role.permission_set_name != "admin" do
admin_role_ids =
Mv.Authorization.Role
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|> Ash.read!(authorize?: false)
|> Enum.map(& &1.id)
# Count only non-system users with admin role (system user is for internal ops)
system_email = Mv.Helpers.SystemActor.system_user_email()
count =
Mv.Accounts.User
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email))
|> Ash.count!(authorize?: false)
if count <= 1 do
{:error,
field: :role_id, message: "At least one user must keep the Admin role."}
else
:ok
end
else
:ok
end
end
else
:ok
end
end,
on: [:update],
where: [action_is(:update_user)]
# Prevent modification of the system actor user (required for internal operations). # Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context -> validate fn changeset, _context ->

View file

@ -36,8 +36,7 @@ defmodule Mv.Membership.Group do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer
authorizers: [Ash.Policy.Authorizer]
require Ash.Query require Ash.Query
alias Mv.Helpers alias Mv.Helpers
@ -64,13 +63,6 @@ defmodule Mv.Membership.Group do
end end
end end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do validations do
validate present(:name) validate present(:name)
@ -144,7 +136,7 @@ defmodule Mv.Membership.Group do
query = query =
Mv.Membership.Group Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name)) |> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|> Helpers.query_exclude_id(exclude_id) |> maybe_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor) opts = Helpers.ash_actor_opts(actor)
@ -163,4 +155,7 @@ defmodule Mv.Membership.Group do
:ok :ok
end end
end end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end end

View file

@ -39,8 +39,7 @@ defmodule Mv.Membership.MemberGroup do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer
authorizers: [Ash.Policy.Authorizer]
require Ash.Query require Ash.Query
@ -57,26 +56,6 @@ defmodule Mv.Membership.MemberGroup do
end end
end end
# Authorization: read uses bypass for :linked (own_data only) then HasPermission for :all;
# create/destroy use HasPermission (normal_user + admin only).
# Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission.
policies do
bypass action_type(:read) do
description "own_data: read only member_groups where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
end
policy action_type(:read) do
description "Check read permission from role (read_only/normal_user/admin :all)"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action_type([:create, :destroy]) do
description "Check create/destroy from role (normal_user + admin only)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do validations do
validate present(:member_id) validate present(:member_id)
validate present(:group_id) validate present(:group_id)
@ -139,7 +118,7 @@ defmodule Mv.Membership.MemberGroup do
query = query =
Mv.Membership.MemberGroup Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id) |> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|> Helpers.query_exclude_id(exclude_id) |> maybe_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor) opts = Helpers.ash_actor_opts(actor)
@ -156,4 +135,7 @@ defmodule Mv.Membership.MemberGroup do
:ok :ok
end end
end end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end end

View file

@ -155,15 +155,12 @@ defmodule Mv.Membership.Setting do
on: [:create, :update] on: [:create, :update]
# Validate default_membership_fee_type_id exists if set # Validate default_membership_fee_type_id exists if set
validate fn changeset, context -> validate fn changeset, _context ->
fee_type_id = fee_type_id =
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id) Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
if fee_type_id do if fee_type_id do
# Check existence only; action is already restricted by policy (e.g. admin). case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
opts = [domain: Mv.MembershipFees, authorize?: false]
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
{:ok, _} -> {:ok, _} ->
:ok :ok

View file

@ -31,12 +31,12 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
@impl true @impl true
def change(changeset, _opts, context) do def change(changeset, _opts, _context) do
# Only calculate if membership_fee_start_date is not already set # Only calculate if membership_fee_start_date is not already set
if has_start_date?(changeset) do if has_start_date?(changeset) do
changeset changeset
else else
calculate_and_set_start_date(changeset, context) calculate_and_set_start_date(changeset)
end end
end end
@ -56,13 +56,10 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end end
end end
defp calculate_and_set_start_date(changeset, context) do defp calculate_and_set_start_date(changeset) do
actor = Map.get(context || %{}, :actor)
opts = if actor, do: [actor: actor], else: []
with {:ok, join_date} <- get_join_date(changeset), with {:ok, join_date} <- get_join_date(changeset),
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset), {:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
{:ok, interval} <- get_interval(membership_fee_type_id, opts), {:ok, interval} <- get_interval(membership_fee_type_id),
{:ok, include_joining_cycle} <- get_include_joining_cycle() do {:ok, include_joining_cycle} <- get_include_joining_cycle() do
start_date = calculate_start_date(join_date, interval, include_joining_cycle) start_date = calculate_start_date(join_date, interval, include_joining_cycle)
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date) Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
@ -121,8 +118,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end end
end end
defp get_interval(membership_fee_type_id, opts) do defp get_interval(membership_fee_type_id) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
{:ok, %{interval: interval}} -> {:ok, interval} {:ok, %{interval: interval}} -> {:ok, interval}
{:error, _} -> {:error, :membership_fee_type_not_found} {:error, _} -> {:error, :membership_fee_type_not_found}
end end

View file

@ -19,9 +19,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
use Ash.Resource.Change use Ash.Resource.Change
@impl true @impl true
def change(changeset, _opts, context) do def change(changeset, _opts, _context) do
if changing_membership_fee_type?(changeset) do if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset, context) validate_interval_match(changeset)
else else
changeset changeset
end end
@ -33,10 +33,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end end
# Validate that the new type has the same interval as the current type # Validate that the new type has the same interval as the current type
defp validate_interval_match(changeset, context) do defp validate_interval_match(changeset) do
current_type_id = get_current_type_id(changeset) current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset) new_type_id = get_new_type_id(changeset)
actor = Map.get(context || %{}, :actor)
cond do cond do
# If no current type, allow any change (first assignment) # If no current type, allow any change (first assignment)
@ -49,13 +48,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
# Both types exist - validate intervals match # Both types exist - validate intervals match
true -> true ->
validate_intervals_match(changeset, current_type_id, new_type_id, actor) validate_intervals_match(changeset, current_type_id, new_type_id)
end end
end end
# Validates that intervals match when both types exist # Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do defp validate_intervals_match(changeset, current_type_id, new_type_id) do
case get_intervals(current_type_id, new_type_id, actor) do case get_intervals(current_type_id, new_type_id) do
{:ok, current_interval, new_interval} -> {:ok, current_interval, new_interval} ->
if current_interval == new_interval do if current_interval == new_interval do
changeset changeset
@ -86,16 +85,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end end
end end
# Get intervals for both types (actor required for authorization when resource has policies) # Get intervals for both types
defp get_intervals(current_type_id, new_type_id, actor) do defp get_intervals(current_type_id, new_type_id) do
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
opts = if actor, do: [actor: actor], else: [] case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
case {
Ash.get(MembershipFeeType, current_type_id, opts),
Ash.get(MembershipFeeType, new_type_id, opts)
} do
{{:ok, current_type}, {:ok, new_type}} -> {{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval} {:ok, current_type.interval, new_type.interval}

View file

@ -28,8 +28,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.MembershipFees, domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer
authorizers: [Ash.Policy.Authorizer]
postgres do postgres do
table "membership_fee_cycles" table "membership_fee_cycles"
@ -84,19 +83,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
end end
end end
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
policies do
bypass action_type(:read) do
description "own_data: read only cycles where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
end
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
attributes do attributes do
uuid_v7_primary_key :id uuid_v7_primary_key :id

View file

@ -24,8 +24,7 @@ defmodule Mv.MembershipFees.MembershipFeeType do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.MembershipFees, domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer
authorizers: [Ash.Policy.Authorizer]
postgres do postgres do
table "membership_fee_types" table "membership_fee_types"
@ -62,13 +61,6 @@ defmodule Mv.MembershipFees.MembershipFeeType do
end end
end end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from role (all can read, only admin can create/update/destroy)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do validations do
# Prevent interval changes after creation # Prevent interval changes after creation
validate fn changeset, _context -> validate fn changeset, _context ->

View file

@ -81,7 +81,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
query = query =
Mv.Membership.Member Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email)) |> Ash.Query.filter(email == ^to_string(email))
|> Mv.Helpers.query_exclude_id(exclude_member_id) |> maybe_exclude_id(exclude_member_id)
system_actor = SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor) opts = Helpers.ash_actor_opts(system_actor)
@ -101,4 +101,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
:ok :ok
end end
end end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end end

View file

@ -1,44 +0,0 @@
defmodule Mv.Authorization.Checks.ActorPermissionSetIs do
@moduledoc """
Policy check: true when the actor's role has the given permission_set_name.
Used to restrict bypass policies (e.g. MemberGroup read by member_id) to actors
with a specific permission set (e.g. "own_data") so that admin with member_id
still gets :all scope from HasPermission, not the bypass filter.
## Usage
# In a resource policy (both conditions must hold for the bypass)
bypass action_type(:read) do
authorize_if expr(member_id == ^actor(:member_id))
authorize_if {Mv.Authorization.Checks.ActorPermissionSetIs, permission_set_name: "own_data"}
end
## Options
- `:permission_set_name` (required) - String or atom, e.g. `"own_data"` or `:own_data`
"""
use Ash.Policy.SimpleCheck
alias Mv.Authorization.Actor
@impl true
def describe(opts) do
name = opts[:permission_set_name] || "?"
"actor has permission set #{name}"
end
@impl true
def match?(actor, _context, opts) do
case opts[:permission_set_name] do
nil ->
false
expected ->
case Actor.permission_set_name(actor) do
nil -> false
actual -> to_string(expected) == to_string(actual)
end
end
end
end

View file

@ -50,7 +50,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:linked** - Filters based on resource type: - **:linked** - Filters based on resource type:
- Member: `id == actor.member_id` (User.member_id Member.id, inverse 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) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id Member.id User.member_id)
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id Member.id User.member_id)
## Error Handling ## Error Handling
@ -279,28 +278,36 @@ defmodule Mv.Authorization.Checks.HasPermission do
# For :own scope with User resource: id == actor.id # For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id # For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
result = case {resource_name, record} do
case {resource_name, record} do {"User", %{id: user_id}} when not is_nil(user_id) ->
# Scope :own # Check if this user's ID matches the actor's ID (scope :own)
{"User", %{id: user_id}} when not is_nil(user_id) -> if user_id == actor.id do
user_id == actor.id {:ok, true}
else
{:ok, false}
end
# Scope :linked {"Member", %{id: member_id}} when not is_nil(member_id) ->
{"Member", %{id: member_id}} when not is_nil(member_id) -> # Check if this member's ID matches the actor's member_id
member_id == actor.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) -> {"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
cfv_member_id == actor.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
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) -> _ ->
mg_member_id == actor.member_id # For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
_ -> {:ok, :unknown}
:unknown end
end
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
out
end end
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member") # Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
@ -340,20 +347,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp apply_scope(:linked, actor, resource_name) do defp apply_scope(:linked, actor, resource_name) do
case resource_name do case resource_name do
"Member" -> "Member" ->
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id # User.member_id → Member.id (inverse relationship)
linked_filter_by_member_id(actor, :id) # Filter: member.id == actor.member_id
# If actor has no member_id, return no results (use false or impossible condition)
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(id == ^actor.member_id)}
end
"CustomFieldValue" -> "CustomFieldValue" ->
# CustomFieldValue.member_id → Member.id → User.member_id # CustomFieldValue.member_id → Member.id → User.member_id
linked_filter_by_member_id(actor, :member_id) # Filter: custom_field_value.member_id == actor.member_id
# If actor has no member_id, return no results
"MemberGroup" -> if is_nil(actor.member_id) do
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations) {:filter, expr(false)}
linked_filter_by_member_id(actor, :member_id) else
{:filter, expr(member_id == ^actor.member_id)}
"MembershipFeeCycle" -> end
# MembershipFeeCycle.member_id → Member.id → User.member_id (own linked member's cycles)
linked_filter_by_member_id(actor, :member_id)
_ -> _ ->
# Fallback for other resources # Fallback for other resources
@ -361,17 +372,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
end end
end end
# Returns {:filter, expr(false)} if actor has no member_id; otherwise {:filter, expr(field == ^actor.member_id)}.
# Used for :linked scope on Member (field :id), CustomFieldValue and MemberGroup (field :member_id).
defp linked_filter_by_member_id(actor, _field) when is_nil(actor.member_id) do
{:filter, expr(false)}
end
defp linked_filter_by_member_id(actor, :id), do: {:filter, expr(id == ^actor.member_id)}
defp linked_filter_by_member_id(actor, :member_id),
do: {:filter, expr(member_id == ^actor.member_id)}
# Log authorization failures for debugging (lazy evaluation) # Log authorization failures for debugging (lazy evaluation)
defp log_auth_failure(actor, resource, action, reason) do defp log_auth_failure(actor, resource, action, reason) do
Logger.debug(fn -> Logger.debug(fn ->

View file

@ -1,63 +0,0 @@
defmodule Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData do
@moduledoc """
Policy check for MemberGroup read: true only when actor has permission set "own_data"
AND record.member_id == actor.member_id.
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
while admin with member_id does not match and gets :all from HasPermission.
- With a record (e.g. get by id): returns true only when own_data and member_id match.
- Without a record (list query): strict_check returns false; auto_filter adds filter when own_data.
"""
use Ash.Policy.Check
alias Mv.Authorization.Checks.ActorPermissionSetIs
@impl true
def type, do: :filter
@impl true
def describe(_opts),
do: "own_data can read only member_groups where member_id == actor.member_id"
@impl true
def strict_check(actor, authorizer, _opts) do
record = get_record_from_authorizer(authorizer)
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
cond do
# List query + own_data: return :unknown so authorizer applies auto_filter (keyword list)
is_nil(record) and is_own_data ->
{:ok, :unknown}
is_nil(record) ->
{:ok, false}
not is_own_data ->
{:ok, false}
record.member_id == actor.member_id ->
{:ok, true}
true ->
{:ok, false}
end
end
@impl true
def auto_filter(actor, _authorizer, _opts) do
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
Map.get(actor, :member_id) do
[member_id: actor.member_id]
else
[]
end
end
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
end

View file

@ -1,62 +0,0 @@
defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do
@moduledoc """
Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data"
AND record.member_id == actor.member_id.
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
while admin with member_id does not match and gets :all from HasPermission.
- With a record (e.g. get by id): returns true only when own_data and member_id match.
- Without a record (list query): return :unknown so authorizer applies auto_filter.
"""
use Ash.Policy.Check
alias Mv.Authorization.Checks.ActorPermissionSetIs
@impl true
def type, do: :filter
@impl true
def describe(_opts),
do: "own_data can read only membership_fee_cycles where member_id == actor.member_id"
@impl true
def strict_check(actor, authorizer, _opts) do
record = get_record_from_authorizer(authorizer)
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
cond do
is_nil(record) and is_own_data ->
{:ok, :unknown}
is_nil(record) ->
{:ok, false}
not is_own_data ->
{:ok, false}
record.member_id == actor.member_id ->
{:ok, true}
true ->
{:ok, false}
end
end
@impl true
def auto_filter(actor, _authorizer, _opts) do
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
Map.get(actor, :member_id) do
[member_id: actor.member_id]
else
[]
end
end
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
end

View file

@ -58,27 +58,6 @@ defmodule Mv.Authorization.PermissionSets do
pages: [String.t()] pages: [String.t()]
} }
# DRY helpers for shared resource permission lists (used in own_data, read_only, normal_user, admin)
defp perm(resource, action, scope),
do: %{resource: resource, action: action, scope: scope, granted: true}
# All four CRUD actions for a resource with scope :all (used for admin)
defp perm_all(resource),
do: [
perm(resource, :read, :all),
perm(resource, :create, :all),
perm(resource, :update, :all),
perm(resource, :destroy, :all)
]
# User: read/update own credentials only (all non-admin sets allow password changes)
defp user_own_credentials, do: [perm("User", :read, :own), perm("User", :update, :own)]
defp group_read_all, do: [perm("Group", :read, :all)]
defp custom_field_read_all, do: [perm("CustomField", :read, :all)]
defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)]
defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)]
@doc """ @doc """
Returns the list of all valid permission set names. Returns the list of all valid permission set names.
@ -115,21 +94,29 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do def get_permissions(:own_data) do
%{ %{
resources: resources: [
user_own_credentials() ++ # User: Can read/update own credentials only
[ # IMPORTANT: "read_only" refers to member data, NOT user credentials.
perm("Member", :read, :linked), # All permission sets grant User.update :own to allow password changes.
perm("Member", :update, :linked), %{resource: "User", action: :read, scope: :own, granted: true},
perm("CustomFieldValue", :read, :linked), %{resource: "User", action: :update, scope: :own, granted: true},
perm("CustomFieldValue", :update, :linked),
perm("CustomFieldValue", :create, :linked), # Member: Can read/update linked member
perm("CustomFieldValue", :destroy, :linked) %{resource: "Member", action: :read, scope: :linked, granted: true},
] ++ %{resource: "Member", action: :update, scope: :linked, granted: true},
custom_field_read_all() ++
group_read_all() ++ # CustomFieldValue: Can read/update/create/destroy custom field values of linked member
[perm("MemberGroup", :read, :linked)] ++ %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
membership_fee_type_read_all() ++ %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
[perm("MembershipFeeCycle", :read, :linked)], %{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
# CustomField: Can read all (needed for forms)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
# Group: Can read all (needed for viewing groups)
%{resource: "Group", action: :read, scope: :all, granted: true}
],
pages: [ pages: [
# No "/" - Mitglied must not see member index at root (same content as /members). # No "/" - Mitglied must not see member index at root (same content as /members).
# Own profile (sidebar links to /users/:id) and own user edit # Own profile (sidebar links to /users/:id) and own user edit
@ -146,17 +133,25 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:read_only) do def get_permissions(:read_only) do
%{ %{
resources: resources: [
user_own_credentials() ++ # User: Can read/update own credentials only
[ # IMPORTANT: "read_only" refers to member data, NOT user credentials.
perm("Member", :read, :all), # All permission sets grant User.update :own to allow password changes.
perm("CustomFieldValue", :read, :all) %{resource: "User", action: :read, scope: :own, granted: true},
] ++ %{resource: "User", action: :update, scope: :own, granted: true},
custom_field_read_all() ++
group_read_all() ++ # Member: Can read all members, no modifications
[perm("MemberGroup", :read, :all)] ++ %{resource: "Member", action: :read, scope: :all, granted: true},
membership_fee_type_read_all() ++
membership_fee_cycle_read_all(), # CustomFieldValue: Can read all custom field values
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# CustomField: Can read all
%{resource: "CustomField", action: :read, scope: :all, granted: true},
# Group: Can read all
%{resource: "Group", action: :read, scope: :all, granted: true}
],
pages: [ pages: [
"/", "/",
# Own profile (sidebar links to /users/:id; redirect target must be allowed) # Own profile (sidebar links to /users/:id; redirect target must be allowed)
@ -181,37 +176,31 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:normal_user) do def get_permissions(:normal_user) do
%{ %{
resources: resources: [
user_own_credentials() ++ # User: Can read/update own credentials only
[ # IMPORTANT: "read_only" refers to member data, NOT user credentials.
perm("Member", :read, :all), # All permission sets grant User.update :own to allow password changes.
perm("Member", :create, :all), %{resource: "User", action: :read, scope: :own, granted: true},
perm("Member", :update, :all), %{resource: "User", action: :update, scope: :own, granted: true},
# destroy intentionally omitted for safety
perm("CustomFieldValue", :read, :all), # Member: Full CRUD except destroy (safety)
perm("CustomFieldValue", :create, :all), %{resource: "Member", action: :read, scope: :all, granted: true},
perm("CustomFieldValue", :update, :all), %{resource: "Member", action: :create, scope: :all, granted: true},
perm("CustomFieldValue", :destroy, :all) %{resource: "Member", action: :update, scope: :all, granted: true},
] ++ # Note: destroy intentionally omitted for safety
custom_field_read_all() ++
[ # CustomFieldValue: Full CRUD
perm("Group", :read, :all), %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
perm("Group", :create, :all), %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
perm("Group", :update, :all), %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
perm("Group", :destroy, :all) %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
] ++
[ # CustomField: Read only (admin manages definitions)
perm("MemberGroup", :read, :all), %{resource: "CustomField", action: :read, scope: :all, granted: true},
perm("MemberGroup", :create, :all),
perm("MemberGroup", :destroy, :all) # Group: Can read all
] ++ %{resource: "Group", action: :read, scope: :all, granted: true}
membership_fee_type_read_all() ++ ],
[
perm("MembershipFeeCycle", :read, :all),
perm("MembershipFeeCycle", :create, :all),
perm("MembershipFeeCycle", :update, :all),
perm("MembershipFeeCycle", :destroy, :all)
],
pages: [ pages: [
"/", "/",
# Own profile (sidebar links to /users/:id; redirect target must be allowed) # Own profile (sidebar links to /users/:id; redirect target must be allowed)
@ -232,39 +221,52 @@ defmodule Mv.Authorization.PermissionSets do
"/custom_field_values/:id/edit", "/custom_field_values/:id/edit",
# Groups overview # Groups overview
"/groups", "/groups",
# Create group
"/groups/new",
# Group detail # Group detail
"/groups/:slug", "/groups/:slug"
# Edit group
"/groups/:slug/edit"
] ]
} }
end end
def get_permissions(:admin) do def get_permissions(:admin) do
# MemberGroup has no :update action in the domain; use read/create/destroy only
member_group_perms = [
perm("MemberGroup", :read, :all),
perm("MemberGroup", :create, :all),
perm("MemberGroup", :destroy, :all)
]
%{ %{
resources: resources: [
perm_all("User") ++ # User: Full management including other users
perm_all("Member") ++ %{resource: "User", action: :read, scope: :all, granted: true},
perm_all("CustomFieldValue") ++ %{resource: "User", action: :create, scope: :all, granted: true},
perm_all("CustomField") ++ %{resource: "User", action: :update, scope: :all, granted: true},
perm_all("Role") ++ %{resource: "User", action: :destroy, scope: :all, granted: true},
perm_all("Group") ++
member_group_perms ++ # Member: Full CRUD
perm_all("MembershipFeeType") ++ %{resource: "Member", action: :read, scope: :all, granted: true},
perm_all("MembershipFeeCycle"), %{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
%{resource: "Member", action: :destroy, scope: :all, granted: true},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
# Role: Full CRUD (admin manages roles)
%{resource: "Role", action: :read, scope: :all, granted: true},
%{resource: "Role", action: :create, scope: :all, granted: true},
%{resource: "Role", action: :update, scope: :all, granted: true},
%{resource: "Role", action: :destroy, scope: :all, granted: true},
# Group: Full CRUD (admin manages groups)
%{resource: "Group", action: :read, scope: :all, granted: true},
%{resource: "Group", action: :create, scope: :all, granted: true},
%{resource: "Group", action: :update, scope: :all, granted: true},
%{resource: "Group", action: :destroy, scope: :all, granted: true}
],
pages: [ pages: [
# Explicit admin-only pages (for clarity and future restrictions)
"/settings",
"/membership_fee_settings",
# Wildcard: Admin can access all pages # Wildcard: Admin can access all pages
"*" "*"
] ]

View file

@ -27,10 +27,6 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
Modified changeset with email synchronization applied, or original changeset Modified changeset with email synchronization applied, or original changeset
if recursion detected. if recursion detected.
""" """
# Ash 3.12+ calls this to decide whether to run the change in certain contexts.
@impl true
def has_change?, do: true
@impl true @impl true
def change(changeset, _opts, context) do def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses # Only recursion protection needed - trigger logic is in `where` clauses

View file

@ -5,8 +5,6 @@ defmodule Mv.Helpers do
Provides utilities that are not specific to a single domain or layer. Provides utilities that are not specific to a single domain or layer.
""" """
require Ash.Query
@doc """ @doc """
Converts an actor to Ash options list for authorization. Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil. Returns empty list if actor is nil.
@ -26,22 +24,4 @@ defmodule Mv.Helpers do
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword() @spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
def ash_actor_opts(nil), do: [] def ash_actor_opts(nil), do: []
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor] def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
@doc """
Returns the query unchanged if `exclude_id` is nil; otherwise adds a filter `id != ^exclude_id`.
Used in uniqueness validations that must exclude the current record (e.g. name uniqueness
on update, duplicate association checks). Call with the record's primary key to exclude it
from the result set.
## Examples
query
|> Ash.Query.filter(name == ^name)
|> Mv.Helpers.query_exclude_id(current_id)
"""
@spec query_exclude_id(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
def query_exclude_id(query, nil), do: query
def query_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end end

View file

@ -56,7 +56,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
query = query =
Mv.Accounts.User Mv.Accounts.User
|> Ash.Query.filter(email == ^email) |> Ash.Query.filter(email == ^email)
|> Mv.Helpers.query_exclude_id(exclude_user_id) |> maybe_exclude_id(exclude_user_id)
system_actor = SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor) opts = Helpers.ash_actor_opts(system_actor)
@ -76,4 +76,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
:ok :ok
end end
end end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end end

View file

@ -545,9 +545,6 @@ defmodule MvWeb.CoreComponents do
attr :label, :string attr :label, :string
attr :class, :string attr :class, :string
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click" attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
attr :sort_field, :any,
doc: "optional; when equal to table sort_field, aria-sort is set on this th"
end end
slot :action, doc: "the slot for showing user actions in the last table column" slot :action, doc: "the slot for showing user actions in the last table column"
@ -563,13 +560,7 @@ defmodule MvWeb.CoreComponents do
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th <th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
:for={col <- @col}
class={Map.get(col, :class)}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
</th>
<th :for={dyn_col <- @dynamic_cols}> <th :for={dyn_col <- @dynamic_cols}>
<.live_component <.live_component
module={MvWeb.Components.SortHeaderComponent} module={MvWeb.Components.SortHeaderComponent}
@ -655,16 +646,6 @@ defmodule MvWeb.CoreComponents do
""" """
end end
defp table_th_aria_sort(col, sort_field, sort_order) do
col_sort = Map.get(col, :sort_field)
if not is_nil(col_sort) and col_sort == sort_field and sort_order in [:asc, :desc] do
if sort_order == :asc, do: "ascending", else: "descending"
else
nil
end
end
@doc """ @doc """
Renders a data list. Renders a data list.

View file

@ -20,6 +20,7 @@ defmodule MvWeb.TableComponents do
type="button" type="button"
phx-click="sort" phx-click="sort"
phx-value-field={@field} phx-value-field={@field}
aria-sort={aria_sort(@sort_field, @sort_order, @field)}
class="flex items-center gap-1 hover:underline focus:outline-none" class="flex items-center gap-1 hover:underline focus:outline-none"
> >
<span>{@label}</span> <span>{@label}</span>
@ -32,4 +33,12 @@ defmodule MvWeb.TableComponents do
</button> </button>
""" """
end end
defp aria_sort(current_field, current_order, this_field) do
cond do
current_field != this_field -> "none"
current_order == :asc -> "ascending"
true -> "descending"
end
end
end end

View file

@ -1,58 +0,0 @@
defmodule MvWeb.Helpers.UserHelpers do
@moduledoc """
Helper functions for user-related display in the web layer.
Provides utilities for showing authentication status without exposing
sensitive attributes (e.g. hashed_password).
"""
@doc """
Returns whether the user has password authentication set.
Only returns true when `hashed_password` is a non-empty string. This avoids
treating `nil`, empty string, or forbidden/redacted values (e.g. when the
attribute is not visible to the actor) as "has password".
## Examples
iex> user = %{hashed_password: nil}
iex> MvWeb.Helpers.UserHelpers.has_password?(user)
false
iex> user = %{hashed_password: "$2b$12$..."}
iex> MvWeb.Helpers.UserHelpers.has_password?(user)
true
iex> user = %{hashed_password: ""}
iex> MvWeb.Helpers.UserHelpers.has_password?(user)
false
"""
@spec has_password?(map() | struct()) :: boolean()
def has_password?(user) when is_map(user) do
case Map.get(user, :hashed_password) do
hash when is_binary(hash) and byte_size(hash) > 0 -> true
_ -> false
end
end
@doc """
Returns whether the user is linked via OIDC/SSO (has a non-empty oidc_id).
## Examples
iex> user = %{oidc_id: nil}
iex> MvWeb.Helpers.UserHelpers.has_oidc?(user)
false
iex> user = %{oidc_id: "sub-from-rauthy"}
iex> MvWeb.Helpers.UserHelpers.has_oidc?(user)
true
"""
@spec has_oidc?(map() | struct()) :: boolean()
def has_oidc?(user) when is_map(user) do
case Map.get(user, :oidc_id) do
id when is_binary(id) and byte_size(id) > 0 -> true
_ -> false
end
end
end

View file

@ -177,8 +177,7 @@ defmodule MvWeb.MemberLive.Form do
phx-change="validate" phx-change="validate"
value={@form[:membership_fee_type_id].value || ""} value={@form[:membership_fee_type_id].value || ""}
> >
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%> <option value="">{gettext("None")}</option>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %> <%= for fee_type <- @available_fee_types do %>
<option <option
value={fee_type.id} value={fee_type.id}
@ -190,8 +189,7 @@ defmodule MvWeb.MemberLive.Form do
</option> </option>
<% end %> <% end %>
</select> </select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> <%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p> <p class="text-error text-sm mt-1">{msg}</p>
<% end %> <% end %>
<%= if @interval_warning do %> <%= if @interval_warning do %>

View file

@ -125,26 +125,22 @@ defmodule MvWeb.MemberLive.Show do
/> />
</div> </div>
<%!-- Linked User: only show when current user can see other users (e.g. admin). <%!-- Linked User --%>
read_only cannot see linked user, so hide the section to avoid "No user linked" when <div>
a user is linked but not visible. --%> <.data_field label={gettext("Linked User")}>
<%= if can_access_page?(@current_user, "/users") do %> <%= if @member.user do %>
<div> <.link
<.data_field label={gettext("Linked User")}> navigate={~p"/users/#{@member.user}"}
<%= if @member.user do %> class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
<.link >
navigate={~p"/users/#{@member.user}"} <.icon name="hero-user" class="size-4" />
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" {@member.user.email}
> </.link>
<.icon name="hero-user" class="size-4" /> <% else %>
{@member.user.email} <span class="text-base-content/70 italic">{gettext("No user linked")}</span>
</.link> <% end %>
<% else %> </.data_field>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> </div>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Notes --%> <%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %> <%= if @member.notes && String.trim(@member.notes) != "" do %>
@ -291,23 +287,6 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true
def handle_info({:put_flash, type, message}, socket) do
{:noreply, put_flash(socket, type, message)}
end
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
@impl true
def handle_info({:member_updated, updated_member}, socket) do
member =
updated_member
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
{:noreply, assign(socket, :member, member)}
end
defp page_title(:show), do: gettext("Show Member") defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member") defp page_title(:edit), do: gettext("Edit Member")

View file

@ -14,7 +14,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization, only: [can?: 3]
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees alias Mv.MembershipFees
@ -50,10 +49,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <% end %>
</div> </div>
<%!-- Action Buttons (only when user has permission) --%> <%!-- Action Buttons --%>
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<.button <.button
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="regenerate_cycles" phx-click="regenerate_cycles"
phx-target={@myself} phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]} class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
@ -63,7 +61,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button> </.button>
<.button <.button
:if={Enum.any?(@cycles) and @can_destroy_cycle} :if={Enum.any?(@cycles)}
phx-click="delete_all_cycles" phx-click="delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline" class="btn btn-sm btn-error btn-outline"
@ -73,7 +71,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{gettext("Delete All Cycles")} {gettext("Delete All Cycles")}
</.button> </.button>
<.button <.button
:if={@member.membership_fee_type != nil and @can_create_cycle} :if={@member.membership_fee_type}
phx-click="open_create_cycle_modal" phx-click="open_create_cycle_modal"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
@ -105,19 +103,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
<%= if @can_update_cycle do %> <span
<span class="font-mono cursor-pointer hover:text-primary"
class="font-mono cursor-pointer hover:text-primary" phx-click="edit_cycle_amount"
phx-click="edit_cycle_amount" phx-value-cycle_id={cycle.id}
phx-value-cycle_id={cycle.id} phx-target={@myself}
phx-target={@myself} title={gettext("Click to edit amount")}
title={gettext("Click to edit amount")} >
> {MembershipFeeHelpers.format_currency(cycle.amount)}
{MembershipFeeHelpers.format_currency(cycle.amount)} </span>
</span>
<% else %>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
<% end %>
</:col> </:col>
<:col :let={cycle} label={gettext("Status")}> <:col :let={cycle} label={gettext("Status")}>
@ -131,60 +125,56 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<:action :let={cycle}> <:action :let={cycle}>
<div class="flex gap-1"> <div class="flex gap-1">
<%= if @can_update_cycle do %> <button
<button :if={cycle.status != :paid}
:if={cycle.status != :paid} type="button"
type="button" phx-click="mark_cycle_status"
phx-click="mark_cycle_status" phx-value-cycle_id={cycle.id}
phx-value-cycle_id={cycle.id} phx-value-status="paid"
phx-value-status="paid" phx-target={@myself}
phx-target={@myself} class="btn btn-sm btn-success"
class="btn btn-sm btn-success" title={gettext("Mark as paid")}
title={gettext("Mark as paid")} >
> <.icon name="hero-check-circle" class="size-4" />
<.icon name="hero-check-circle" class="size-4" /> {gettext("Paid")}
{gettext("Paid")} </button>
</button> <button
<button :if={cycle.status != :suspended}
:if={cycle.status != :suspended} type="button"
type="button" phx-click="mark_cycle_status"
phx-click="mark_cycle_status" phx-value-cycle_id={cycle.id}
phx-value-cycle_id={cycle.id} phx-value-status="suspended"
phx-value-status="suspended" phx-target={@myself}
phx-target={@myself} class="btn btn-sm btn-outline btn-warning"
class="btn btn-sm btn-outline btn-warning" title={gettext("Mark as suspended")}
title={gettext("Mark as suspended")} >
> <.icon name="hero-pause-circle" class="size-4" />
<.icon name="hero-pause-circle" class="size-4" /> {gettext("Suspended")}
{gettext("Suspended")} </button>
</button> <button
<button :if={cycle.status != :unpaid}
:if={cycle.status != :unpaid} type="button"
type="button" phx-click="mark_cycle_status"
phx-click="mark_cycle_status" phx-value-cycle_id={cycle.id}
phx-value-cycle_id={cycle.id} phx-value-status="unpaid"
phx-value-status="unpaid" phx-target={@myself}
phx-target={@myself} class="btn btn-sm btn-error"
class="btn btn-sm btn-error" title={gettext("Mark as unpaid")}
title={gettext("Mark as unpaid")} >
> <.icon name="hero-x-circle" class="size-4" />
<.icon name="hero-x-circle" class="size-4" /> {gettext("Unpaid")}
{gettext("Unpaid")} </button>
</button> <button
<% end %> type="button"
<%= if @can_destroy_cycle do %> phx-click="delete_cycle"
<button phx-value-cycle_id={cycle.id}
type="button" phx-target={@myself}
phx-click="delete_cycle" class="btn btn-sm btn-error btn-outline"
phx-value-cycle_id={cycle.id} title={gettext("Delete cycle")}
phx-target={@myself} >
class="btn btn-sm btn-error btn-outline" <.icon name="hero-trash" class="size-4" />
title={gettext("Delete cycle")} {gettext("Delete")}
> </button>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<% end %>
</div> </div>
</:action> </:action>
</.table> </.table>
@ -418,19 +408,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Get available fee types (filtered to same interval if member has a type) # Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member, actor) available_fee_types = get_available_fee_types(member, actor)
# Permission flags for cycle actions (so read_only does not see create/update/destroy UI)
can_create_cycle = can?(actor, :create, MembershipFeeCycle)
can_destroy_cycle = can?(actor, :destroy, MembershipFeeCycle)
can_update_cycle = can?(actor, :update, MembershipFeeCycle)
{:ok, {:ok,
socket socket
|> assign(assigns) |> assign(assigns)
|> assign(:cycles, cycles) |> assign(:cycles, cycles)
|> assign(:available_fee_types, available_fee_types) |> assign(:available_fee_types, available_fee_types)
|> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_cycle)
|> assign_new(:interval_warning, fn -> nil end) |> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end)
@ -457,7 +439,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, []) |> assign(:cycles, [])
|> assign( |> assign(
:available_fee_types, :available_fee_types,
get_available_fee_types(updated_member, actor) get_available_fee_types(updated_member, current_actor(socket))
) )
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))} |> put_flash(:info, gettext("Membership fee type removed"))}
@ -488,9 +470,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)} {:noreply, assign(socket, :interval_warning, interval_warning)}
else else
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} -> {:ok, updated_member} ->
# Reload member with cycles # Reload member with cycles
actor = current_actor(socket)
updated_member = updated_member =
updated_member updated_member
|> Ash.load!( |> Ash.load!(
@ -516,7 +502,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, cycles) |> assign(:cycles, cycles)
|> assign( |> assign(
:available_fee_types, :available_fee_types,
get_available_fee_types(updated_member, actor) get_available_fee_types(updated_member, current_actor(socket))
) )
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -568,15 +554,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end end
def handle_event("regenerate_cycles", _params, socket) do def handle_event("regenerate_cycles", _params, socket) do
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
actor = current_actor(socket) actor = current_actor(socket)
if can?(actor, :create, MembershipFeeCycle) do # SECURITY: Only admins can manually regenerate cycles via UI
# Cycle generation itself uses system actor, but UI access should be restricted
if actor.role && actor.role.permission_set_name == "admin" do
socket = assign(socket, :regenerating, true) socket = assign(socket, :regenerating, true)
member = socket.assigns.member member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} -> {:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket) actor = current_actor(socket)
updated_member = updated_member =
@ -614,8 +602,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
else else
{:noreply, {:noreply,
socket socket
|> assign(:regenerating, false) |> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end end
end end
@ -735,31 +722,61 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation)) confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
expected = String.downcase(gettext("Yes")) expected = String.downcase(gettext("Yes"))
if confirmation == expected do if confirmation != expected do
member = socket.assigns.member
actor = current_actor(socket)
cycles = socket.assigns.cycles
reset_modal = fn s ->
s
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
end
if can?(actor, :destroy, MembershipFeeCycle) do
do_delete_all_cycles(socket, member, actor, cycles, reset_modal)
else
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
else
{:noreply, {:noreply,
socket socket
|> assign(:deleting_all_cycles, false) |> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "") |> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Confirmation text does not match"))} |> put_flash(:error, gettext("Confirmation text does not match"))}
else
member = socket.assigns.member
# Delete all cycles atomically using Ecto query
import Ecto.Query
deleted_count =
Mv.Repo.delete_all(
from c in Mv.MembershipFees.MembershipFeeCycle,
where: c.member_id == ^member.id
)
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]
],
actor: actor
)
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("No cycles to delete"))}
end
end end
end end
@ -878,55 +895,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions # Helper functions
defp do_delete_all_cycles(socket, member, actor, cycles, reset_modal) do
result =
Enum.reduce_while(cycles, {:ok, 0}, fn cycle, {:ok, count} ->
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok -> {:cont, {:ok, count + 1}}
{:ok, _} -> {:cont, {:ok, count + 1}}
{:error, error} -> {:halt, {:error, error}}
end
end)
case result do
{:ok, deleted_count} when deleted_count > 0 ->
updated_member =
member
|> Ash.load!(
[:membership_fee_type, membership_fee_cycles: [:membership_fee_type]],
actor: actor
)
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> reset_modal.()
|> put_flash(:info, gettext("All cycles deleted"))}
{:ok, _} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:info, gettext("No cycles to delete"))}
{:error, error} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(error))}
end
end
defp get_available_fee_types(member, actor) do defp get_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
@ -972,10 +940,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
Enum.map_join(error.errors, ", ", fn e -> e.message end) Enum.map_join(error.errors, ", ", fn e -> e.message end)
end end
defp format_error(%Ash.Error.Forbidden{}) do
gettext("You are not allowed to perform this action.")
end
defp format_error(error) when is_binary(error), do: error defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")

View file

@ -8,20 +8,17 @@ defmodule MvWeb.MembershipFeeSettingsLive do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = current_actor(socket)
{:ok, settings} = Membership.get_settings() {:ok, settings} = Membership.get_settings()
membership_fee_types = membership_fee_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: Mv.MembershipFees, actor: actor) |> Ash.read!()
{:ok, {:ok,
socket socket

View file

@ -200,12 +200,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
actor = current_actor(socket)
membership_fee_type = membership_fee_type =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor) id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
end end
page_title = page_title =

View file

@ -35,8 +35,6 @@ defmodule MvWeb.UserLive.Form do
require Jason require Jason
alias Mv.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3] import MvWeb.Authorization, only: [can?: 3]
@ -51,18 +49,6 @@ defmodule MvWeb.UserLive.Form do
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<%= if @user && @can_assign_role do %>
<div class="mt-4">
<.input
field={@form[:role_id]}
type="select"
label={gettext("Role")}
options={Enum.map(@roles, &{&1.name, &1.id})}
prompt={gettext("Select role...")}
/>
</div>
<% end %>
<!-- Password Section --> <!-- Password Section -->
<div class="mt-6"> <div class="mt-6">
@ -81,18 +67,6 @@ defmodule MvWeb.UserLive.Form do
<%= if @show_password_fields do %> <%= if @show_password_fields do %>
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50"> <div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<p class="text-sm font-semibold text-red-800">
{gettext("SSO / OIDC user")}
</p>
<p class="mt-1 text-sm text-red-700">
{gettext(
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
)}
</p>
</div>
<% end %>
<.input <.input
field={@form[:password]} field={@form[:password]}
label={gettext("Password")} label={gettext("Password")}
@ -326,9 +300,6 @@ defmodule MvWeb.UserLive.Form do
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation). # Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User) can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
# Only admins can assign user roles (Role update permission).
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
roles = if can_assign_role, do: load_roles(actor), else: []
{:ok, {:ok,
socket socket
@ -336,8 +307,6 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user) |> assign(user: user)
|> assign(:page_title, page_title) |> assign(:page_title, page_title)
|> assign(:can_manage_member_linking, can_manage_member_linking) |> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:can_assign_role, can_assign_role)
|> assign(:roles, roles)
|> assign(:show_password_fields, false) |> assign(:show_password_fields, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
@ -388,10 +357,7 @@ defmodule MvWeb.UserLive.Form do
def handle_event("save", %{"user" => user_params}, socket) do def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket) actor = current_actor(socket)
# Include current member in params when not linking/unlinking so update_user's # First save the user without member changes
# manage_relationship(on_missing: :unrelate) does not accidentally unlink.
user_params = params_with_member_if_unchanged(socket, user_params)
case submit_form(socket.assigns.form, user_params, actor) do case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} -> {:ok, user} ->
handle_member_linking(socket, user, actor) handle_member_linking(socket, user, actor)
@ -563,20 +529,6 @@ defmodule MvWeb.UserLive.Form do
defp get_action_name(:update), do: gettext("updated") defp get_action_name(:update), do: gettext("updated")
defp get_action_name(other), do: to_string(other) defp get_action_name(other), do: to_string(other)
# When user has a linked member and we are not linking/unlinking, include current member in params
# so update_user's manage_relationship(on_missing: :unrelate) does not unlink the member.
defp params_with_member_if_unchanged(socket, params) do
user = socket.assigns.user
linking = socket.assigns.selected_member_id
unlinking = socket.assigns[:unlink_member]
if user && user.member_id && !linking && !unlinking do
Map.put(params, "member", %{"id" => user.member_id})
else
params
end
end
defp handle_member_link_error(socket, error) do defp handle_member_link_error(socket, error) do
error_message = extract_error_message(error) error_message = extract_error_message(error)
@ -620,8 +572,7 @@ defmodule MvWeb.UserLive.Form do
assigns: %{ assigns: %{
user: user, user: user,
show_password_fields: show_password_fields, show_password_fields: show_password_fields,
can_manage_member_linking: can_manage_member_linking, can_manage_member_linking: can_manage_member_linking
can_assign_role: can_assign_role
} }
} = socket } = socket
) do ) do
@ -629,25 +580,16 @@ defmodule MvWeb.UserLive.Form do
form = form =
if user do if user do
# For existing users: admin uses update_user (email + member + role_id); non-admin uses update (email only). # For existing users: admin uses update_user (email + member); non-admin uses update (email only).
# Password change uses admin_set_password for both. # Password change uses admin_set_password for both.
action = action =
cond do cond do
show_password_fields -> :admin_set_password show_password_fields -> :admin_set_password
can_manage_member_linking or can_assign_role -> :update_user can_manage_member_linking -> :update_user
true -> :update true -> :update
end end
form = AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
# Ensure role_id is always included on submit when role dropdown is shown (AshPhoenix.Form
# only submits keys in touched_forms; marking as touched avoids role change being dropped).
if can_assign_role and action == :update_user do
AshPhoenix.Form.touch(form, [:role_id])
else
form
end
else else
# For new users, use password registration if password fields are shown # For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user action = if show_password_fields, do: :register_with_password, else: :create_user
@ -726,14 +668,6 @@ defmodule MvWeb.UserLive.Form do
Mv.Membership.Member.filter_by_email_match(members, user_email_str) Mv.Membership.Member.filter_by_email_match(members, user_email_str)
end end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
case Authorization.list_roles(actor: actor) do
{:ok, roles} -> roles
{:error, _} -> []
end
end
# Extract user-friendly error message from Ash.Error # Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t() @spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do

View file

@ -35,7 +35,7 @@ defmodule MvWeb.UserLive.Index do
users = users =
Mv.Accounts.User Mv.Accounts.User
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email()) |> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor) |> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor)
sorted = Enum.sort_by(users, & &1.email) sorted = Enum.sort_by(users, & &1.email)

View file

@ -15,8 +15,6 @@
rows={@users} rows={@users}
row_id={fn user -> "row-#{user.id}" end} row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
sort_field={@sort_field}
sort_order={@sort_order}
> >
<:col <:col
:let={user} :let={user}
@ -47,7 +45,6 @@
</:col> </:col>
<:col <:col
:let={user} :let={user}
sort_field={:email}
label={ label={
sort_button(%{ sort_button(%{
field: :email, field: :email,
@ -59,28 +56,11 @@
> >
{user.email} {user.email}
</:col> </:col>
<:col :let={user} label={gettext("Role")}>
{user.role.name}
</:col>
<:col :let={user} label={gettext("Linked Member")}> <:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %> <%= if user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)} {MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %> <% else %>
<span class="text-base-content/70">{gettext("No member linked")}</span> <span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>
</:col>
<:col :let={user} label={gettext("Password")}>
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
<span>{gettext("Enabled")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</:col>
<:col :let={user} label={gettext("OIDC")}>
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
<span>{gettext("Linked")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %> <% end %>
</:col> </:col>

View file

@ -55,16 +55,8 @@ defmodule MvWeb.UserLive.Show do
<.list> <.list>
<:item title={gettext("Email")}>{@user.email}</:item> <:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
<:item title={gettext("Password Authentication")}> <:item title={gettext("Password Authentication")}>
{if MvWeb.Helpers.UserHelpers.has_password?(@user), {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
do: gettext("Enabled"),
else: gettext("Not enabled")}
</:item>
<:item title={gettext("OIDC")}>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
do: gettext("Linked"),
else: gettext("Not linked")}
</:item> </:item>
<:item title={gettext("Linked Member")}> <:item title={gettext("Linked Member")}>
<%= if @user.member do %> <%= if @user.member do %>
@ -87,9 +79,7 @@ defmodule MvWeb.UserLive.Show do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
actor = current_actor(socket) actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
user =
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do if Mv.Helpers.SystemActor.system_user?(user) do
{:ok, {:ok,

View file

@ -294,7 +294,6 @@ msgstr "Beschreibung"
msgid "Edit User" msgid "Edit User"
msgstr "Benutzer*in bearbeiten" msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enabled" msgid "Enabled"
@ -472,7 +471,6 @@ msgid "Include both letters and numbers"
msgstr "Buchstaben und Zahlen verwenden" msgstr "Buchstaben und Zahlen verwenden"
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Password" msgid "Password"
msgstr "Passwort" msgstr "Passwort"
@ -960,6 +958,7 @@ msgid "Last name"
msgstr "Nachname" msgstr "Nachname"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "None" msgid "None"
msgstr "Keine" msgstr "Keine"
@ -1671,9 +1670,6 @@ msgstr "Profil"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "Rolle" msgstr "Rolle"
@ -1969,6 +1965,11 @@ msgstr "Bezahlstatus"
msgid "Reset" msgid "Reset"
msgstr "Zurücksetzen" msgstr "Zurücksetzen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr "Nur Administrator*innen können Zyklen regenerieren"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2301,45 +2302,3 @@ msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld i
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users" msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind." msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select role..."
msgstr "Keine auswählen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action."
msgstr "Du hast keine Berechtigung, diese Aktion auszuführen."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select a membership fee type"
msgstr "Mitgliedsbeitragstyp auswählen"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Linked"
msgstr "Verknüpft"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "OIDC"
msgstr "OIDC"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not linked"
msgstr "Nicht verknüpft"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "SSO / OIDC user"
msgstr "SSO-/OIDC-Benutzer*in"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation."

View file

@ -295,7 +295,6 @@ msgstr ""
msgid "Edit User" msgid "Edit User"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enabled" msgid "Enabled"
@ -473,7 +472,6 @@ msgid "Include both letters and numbers"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Password" msgid "Password"
msgstr "" msgstr ""
@ -961,6 +959,7 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1672,9 +1671,6 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "" msgstr ""
@ -1970,6 +1966,11 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2302,45 +2303,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users" msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select role..."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select a membership fee type"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Linked"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "OIDC"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not linked"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "SSO / OIDC user"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr ""

View file

@ -295,7 +295,6 @@ msgstr ""
msgid "Edit User" msgid "Edit User"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enabled" msgid "Enabled"
@ -473,7 +472,6 @@ msgid "Include both letters and numbers"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Password" msgid "Password"
msgstr "" msgstr ""
@ -961,6 +959,7 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1672,9 +1671,6 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "" msgstr ""
@ -1970,6 +1966,11 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can regenerate cycles"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2302,45 +2303,3 @@ msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, c
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users" msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Only administrators or the linked user can change the email for members linked to users" msgstr "Only administrators or the linked user can change the email for members linked to users"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select role..."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select a membership fee type"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "OIDC"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Not linked"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "SSO / OIDC user"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr ""

View file

@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
require Ash.Query require Ash.Query
# Create example membership fee types (no admin user yet; skip authorization for bootstrap) # Create example membership fee types
for fee_type_attrs <- [ for fee_type_attrs <- [
%{ %{
name: "Standard (Jährlich)", name: "Standard (Jährlich)",
@ -39,12 +39,7 @@ for fee_type_attrs <- [
] do ] do
MembershipFeeType MembershipFeeType
|> Ash.Changeset.for_create(:create, fee_type_attrs) |> Ash.Changeset.for_create(:create, fee_type_attrs)
|> Ash.create!( |> Ash.create!(upsert?: true, upsert_identity: :unique_name)
upsert?: true,
upsert_identity: :unique_name,
authorize?: false,
domain: Mv.MembershipFees
)
end end
for attrs <- [ for attrs <- [
@ -228,7 +223,7 @@ case Accounts.User
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
{:ok, nil} -> {:ok, nil} ->
# User doesn't exist - create admin user and set password (so Password column shows "Enabled") # User doesn't exist - create admin user with password
# Use authorize?: false for bootstrap - no admin user exists yet to use as actor # Use authorize?: false for bootstrap - no admin user exists yet to use as actor
Accounts.create_user!(%{email: admin_email}, Accounts.create_user!(%{email: admin_email},
upsert?: true, upsert?: true,
@ -304,12 +299,12 @@ case Accounts.User
IO.puts("SystemActor will fall back to admin user (#{admin_email})") IO.puts("SystemActor will fall back to admin user (#{admin_email})")
end end
# Load all membership fee types for assignment (admin actor for authorization) # Load all membership fee types for assignment
# Sort by name to ensure deterministic order # Sort by name to ensure deterministic order
all_fee_types = all_fee_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) |> Ash.read!()
|> Enum.to_list() |> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates # Create sample members for testing - use upsert to prevent duplicates
@ -457,8 +452,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
end end
end) end)
# Create additional users for user-member linking examples (no password by default) # Create additional users for user-member linking examples
# Only admin gets a password (admin_set_password when created); all other users have no password.
additional_users = [ additional_users = [
%{email: "hans.mueller@example.de"}, %{email: "hans.mueller@example.de"},
%{email: "greta.schmidt@example.de"}, %{email: "greta.schmidt@example.de"},
@ -468,12 +462,15 @@ additional_users = [
created_users = created_users =
Enum.map(additional_users, fn user_attrs -> Enum.map(additional_users, fn user_attrs ->
# Use admin user as actor for additional user creation (not bootstrap)
user = user =
Accounts.create_user!(user_attrs, Accounts.create_user!(user_attrs,
upsert?: true, upsert?: true,
upsert_identity: :unique_email, upsert_identity: :unique_email,
actor: admin_user_with_role actor: admin_user_with_role
) )
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!(actor: admin_user_with_role)
# Reload user to ensure all fields (including member_id) are loaded # Reload user to ensure all fields (including member_id) are loaded
Accounts.User Accounts.User

View file

@ -54,26 +54,18 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
# Create a valid fee type # Create a valid fee type
{:ok, fee_type} = {:ok, fee_type} =
Ash.create( Ash.create(MembershipFeeType, %{
MembershipFeeType, name: "Test Fee Type #{System.unique_integer([:positive])}",
%{ amount: Decimal.new("100.00"),
name: "Test Fee Type #{System.unique_integer([:positive])}", interval: :yearly
amount: Decimal.new("100.00"), })
interval: :yearly
},
actor: actor
)
# Setting a valid fee type should work # Setting a valid fee type should work
{:ok, updated} = {:ok, updated} =
settings settings
|> Ash.Changeset.for_update( |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
:update_membership_fee_settings, default_membership_fee_type_id: fee_type.id
%{ })
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update(actor: actor) |> Ash.update(actor: actor)
assert updated.default_membership_fee_type_id == fee_type.id assert updated.default_membership_fee_type_id == fee_type.id

View file

@ -52,7 +52,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
assert changeset.valid? assert changeset.valid?
end end
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
refute changeset.valid? refute changeset.valid?
assert %{errors: errors} = changeset assert %{errors: errors} = changeset
@ -90,7 +90,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
assert changeset.valid? assert changeset.valid?
end end
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset = changeset =
member member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor) |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
refute changeset.valid? refute changeset.valid?
assert %{errors: errors} = changeset assert %{errors: errors} = changeset
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset = changeset =
member member
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor) |> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
assert changeset.valid? assert changeset.valid?
end end
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id)) error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly" assert error.message =~ "yearly"
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{actor: actor}) |> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?, refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}" "Should prevent change from #{interval1} to #{interval2}"

View file

@ -151,7 +151,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, actor) member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, actor) cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid) assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid assert updated.status == :unpaid
end end
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, actor) member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor) cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid) assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid assert updated.status == :unpaid
end end
end end

View file

@ -155,13 +155,9 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update( |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
:update_membership_fee_settings, default_membership_fee_type_id: fee_type.id
%{ })
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update!(actor: actor) |> Ash.update!(actor: actor)
# Try to delete # Try to delete
@ -180,13 +176,9 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update( |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
:update_membership_fee_settings, default_membership_fee_type_id: fee_type.id
%{ })
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update!(actor: actor) |> Ash.update!(actor: actor)
# Create a member without explicitly setting membership_fee_type_id # Create a member without explicitly setting membership_fee_type_id

View file

@ -264,13 +264,9 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update( |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
:update_membership_fee_settings, default_membership_fee_type_id: fee_type.id
%{ })
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update!(actor: actor) |> Ash.update!(actor: actor)
# Try to delete # Try to delete

View file

@ -10,6 +10,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
use Mv.DataCase, async: false use Mv.DataCase, async: false
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization
require Ash.Query require Ash.Query
@ -18,10 +19,59 @@ defmodule Mv.Accounts.UserPoliciesTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name, actor) 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
},
actor: actor
) 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, actor) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name, actor)
# 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(actor: actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
# Helper to create another user (for testing access to other users)
defp create_other_user(actor) do
create_user_with_permission_set("own_data", actor)
end
# Shared test setup for permission sets with scope :own access # Shared test setup for permission sets with scope :own access
defp setup_user_with_own_access(permission_set, actor) do defp setup_user_with_own_access(permission_set, actor) do
user = Mv.Fixtures.user_with_role_fixture(permission_set) user = create_user_with_permission_set(permission_set, actor)
other_user = Mv.Fixtures.user_with_role_fixture("own_data") other_user = create_other_user(actor)
# Reload user to ensure role is preloaded # Reload user to ensure role is preloaded
{:ok, user} = {:ok, user} =
@ -30,101 +80,217 @@ defmodule Mv.Accounts.UserPoliciesTest do
%{user: user, other_user: other_user} %{user: user, other_user: other_user}
end end
# Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User) describe "own_data permission set (Mitglied)" do
describe "non-admin permission sets (own_data, read_only, normal_user)" do setup %{actor: actor} do
setup %{actor: actor} = context do setup_user_with_own_access("own_data", actor)
permission_set = context[:permission_set] || "own_data"
setup_user_with_own_access(permission_set, actor)
end end
for permission_set <- ["own_data", "read_only", "normal_user"] do test "can read own user record", %{user: user} do
@tag permission_set: permission_set {:ok, fetched_user} =
test "can read own user record (#{permission_set})", %{user: user} do Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
{:ok, fetched_user} =
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
assert fetched_user.id == user.id assert fetched_user.id == user.id
end
test "can update own email", %{user: user} do
new_email = "updated#{System.unique_integer([:positive])}@example.com"
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end
test "cannot read other users (returns not found due to auto_filter)", %{
user: user,
other_user: other_user
} do
# Note: With auto_filter policies, when a user tries to read a user that doesn't
# match the filter (id == actor.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!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
end end
end
@tag permission_set: permission_set test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
test "can update own email (#{permission_set})", %{user: user} do assert_raise Ash.Error.Forbidden, fn ->
new_email = "updated#{System.unique_integer([:positive])}@example.com" other_user
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
{:ok, updated_user} = |> Ash.update!(actor: user)
user
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end end
end
@tag permission_set: permission_set test "list users returns only own user", %{user: user} do
test "cannot read other users - not found due to auto_filter (#{permission_set})", %{ {:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
user: user,
other_user: other_user # Should only return the own user (scope :own filters)
} do assert length(users) == 1
assert_raise Ash.Error.Invalid, fn -> assert hd(users).id == user.id
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts) end
end
test "cannot create user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end end
end
@tag permission_set: permission_set test "cannot destroy user (returns forbidden)", %{user: user} do
test "cannot update other users - forbidden (#{permission_set})", %{ assert_raise Ash.Error.Forbidden, fn ->
user: user, Ash.destroy!(user, actor: user)
other_user: other_user
} do
assert_raise Ash.Error.Forbidden, fn ->
other_user
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end end
end
end
@tag permission_set: permission_set describe "read_only permission set (Vorstand/Buchhaltung)" do
test "list users returns only own user (#{permission_set})", %{user: user} do setup %{actor: actor} do
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts) setup_user_with_own_access("read_only", actor)
end
assert length(users) == 1 test "can read own user record", %{user: user} do
assert hd(users).id == user.id {:ok, fetched_user} =
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
assert fetched_user.id == user.id
end
test "can update own email", %{user: user} do
new_email = "updated#{System.unique_integer([:positive])}@example.com"
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end
test "cannot read other users (returns not found due to auto_filter)", %{
user: user,
other_user: other_user
} do
# Note: With auto_filter policies, when a user tries to read a user that doesn't
# match the filter (id == actor.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!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
end end
end
@tag permission_set: permission_set test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
test "cannot create user - forbidden (#{permission_set})", %{user: user} do assert_raise Ash.Error.Forbidden, fn ->
assert_raise Ash.Error.Forbidden, fn -> other_user
Accounts.User |> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.Changeset.for_create(:create_user, %{ |> Ash.update!(actor: user)
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end end
end
@tag permission_set: permission_set test "list users returns only own user", %{user: user} do
test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do {:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(user, actor: user) # Should only return the own user (scope :own filters)
end assert length(users) == 1
assert hd(users).id == user.id
end
test "cannot create user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end end
end
@tag permission_set: permission_set test "cannot destroy user (returns forbidden)", %{user: user} do
test "cannot change role via update_user - forbidden (#{permission_set})", %{ assert_raise Ash.Error.Forbidden, fn ->
user: user, Ash.destroy!(user, actor: user)
other_user: other_user end
} do end
other_role = Mv.Fixtures.role_fixture("read_only") end
assert {:error, %Ash.Error.Forbidden{}} = describe "normal_user permission set (Kassenwart)" do
other_user setup %{actor: actor} do
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id}) setup_user_with_own_access("normal_user", actor)
|> Ash.update(actor: user, domain: Mv.Accounts) end
test "can read own user record", %{user: user} do
{:ok, fetched_user} =
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
assert fetched_user.id == user.id
end
test "can update own email", %{user: user} do
new_email = "updated#{System.unique_integer([:positive])}@example.com"
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end
test "cannot read other users (returns not found due to auto_filter)", %{
user: user,
other_user: other_user
} do
# Note: With auto_filter policies, when a user tries to read a user that doesn't
# match the filter (id == actor.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!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
end
end
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
assert_raise Ash.Error.Forbidden, fn ->
other_user
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end
test "list users returns only own user", %{user: user} do
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
# Should only return the own user (scope :own filters)
assert length(users) == 1
assert hd(users).id == user.id
end
test "cannot create user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot destroy user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(user, actor: user)
end end
end end
end end
describe "admin permission set" do describe "admin permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin") user = create_user_with_permission_set("admin", actor)
other_user = Mv.Fixtures.user_with_role_fixture("own_data") other_user = create_other_user(actor)
# Reload user to ensure role is preloaded # Reload user to ensure role is preloaded
{:ok, user} = {:ok, user} =
@ -177,88 +343,6 @@ defmodule Mv.Accounts.UserPoliciesTest do
# Verify user is deleted # Verify user is deleted
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts) assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
end end
test "admin can assign role to another user via update_user", %{
other_user: other_user
} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
{:ok, updated} =
other_user
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|> Ash.update(actor: admin)
assert updated.role_id == normal_user_role.id
end
end
describe "admin role assignment and last-admin validation" do
test "two admins: one can change own role to normal_user (other remains admin)", %{
actor: _actor
} do
_admin_role = Mv.Fixtures.role_fixture("admin")
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
admin_a = Mv.Fixtures.user_with_role_fixture("admin")
_admin_b = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, updated} =
admin_a
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|> Ash.update(actor: admin_a)
assert updated.role_id == normal_user_role.id
end
test "single admin: changing own role to normal_user returns validation error", %{
actor: _actor
} do
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
single_admin = Mv.Fixtures.user_with_role_fixture("admin")
assert {:error, %Ash.Error.Invalid{errors: errors}} =
single_admin
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|> Ash.update(actor: single_admin)
error_messages =
Enum.flat_map(errors, fn
%Ash.Error.Changes.InvalidAttribute{message: msg} when is_binary(msg) -> [msg]
%{message: msg} when is_binary(msg) -> [msg]
_ -> []
end)
assert Enum.any?(error_messages, fn msg ->
msg =~ "least one user must keep the Admin role" or msg =~ "Admin role"
end),
"Expected last-admin validation message, got: #{inspect(error_messages)}"
end
test "admin can switch to another admin role (two roles with permission_set_name admin)", %{
actor: _actor
} do
# Two distinct roles both with permission_set_name "admin" (e.g. "Admin" and "Superadmin")
admin_role_a = Mv.Fixtures.role_fixture("admin")
admin_role_b = Mv.Fixtures.role_fixture("admin")
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
# Ensure user has role_a so we can switch to role_b
{:ok, admin_user} =
admin_user
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_a.id})
|> Ash.update(actor: admin_user)
assert admin_user.role_id == admin_role_a.id
# Switching to another admin role must be allowed (no last-admin error)
{:ok, updated} =
admin_user
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_b.id})
|> Ash.update(actor: admin_user)
assert updated.role_id == admin_role_b.id
end
end end
describe "AshAuthentication bypass" do describe "AshAuthentication bypass" do

View file

@ -496,281 +496,6 @@ defmodule Mv.Authorization.PermissionSetsTest do
assert "*" in permissions.pages assert "*" in permissions.pages
end end
test "admin pages include explicit /settings and /membership_fee_settings" do
permissions = PermissionSets.get_permissions(:admin)
assert "/settings" in permissions.pages
assert "/membership_fee_settings" in permissions.pages
end
end
describe "get_permissions/1 - MemberGroup resource" do
test "own_data has MemberGroup read with scope :linked only" do
permissions = PermissionSets.get_permissions(:own_data)
mg_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :read
end)
mg_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :create
end)
assert mg_read != nil
assert mg_read.scope == :linked
assert mg_read.granted == true
assert mg_create == nil || mg_create.granted == false
end
test "read_only has MemberGroup read with scope :all, no create/destroy" do
permissions = PermissionSets.get_permissions(:read_only)
mg_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :read
end)
mg_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :create
end)
mg_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :destroy
end)
assert mg_read != nil
assert mg_read.scope == :all
assert mg_read.granted == true
assert mg_create == nil || mg_create.granted == false
assert mg_destroy == nil || mg_destroy.granted == false
end
test "normal_user has MemberGroup read/create/destroy with scope :all" do
permissions = PermissionSets.get_permissions(:normal_user)
mg_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :read
end)
mg_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :create
end)
mg_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :destroy
end)
assert mg_read != nil
assert mg_read.scope == :all
assert mg_read.granted == true
assert mg_create != nil
assert mg_create.scope == :all
assert mg_create.granted == true
assert mg_destroy != nil
assert mg_destroy.scope == :all
assert mg_destroy.granted == true
end
test "admin has MemberGroup read/create/destroy with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
mg_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :read
end)
mg_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :create
end)
mg_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MemberGroup" && p.action == :destroy
end)
assert mg_read != nil
assert mg_read.scope == :all
assert mg_read.granted == true
assert mg_create != nil
assert mg_create.granted == true
assert mg_destroy != nil
assert mg_destroy.granted == true
end
end
describe "get_permissions/1 - MembershipFeeType resource" do
test "all permission sets have MembershipFeeType read with scope :all" do
for set <- PermissionSets.all_permission_sets() do
permissions = PermissionSets.get_permissions(set)
mft_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :read
end)
assert mft_read != nil, "Permission set #{set} should have MembershipFeeType read"
assert mft_read.scope == :all
assert mft_read.granted == true
end
end
test "only admin has MembershipFeeType create/update/destroy" do
for set <- [:own_data, :read_only, :normal_user] do
permissions = PermissionSets.get_permissions(set)
mft_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :create
end)
mft_update =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :update
end)
mft_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :destroy
end)
assert mft_create == nil || mft_create.granted == false,
"Permission set #{set} should not allow MembershipFeeType create"
assert mft_update == nil || mft_update.granted == false,
"Permission set #{set} should not allow MembershipFeeType update"
assert mft_destroy == nil || mft_destroy.granted == false,
"Permission set #{set} should not allow MembershipFeeType destroy"
end
admin_permissions = PermissionSets.get_permissions(:admin)
mft_create =
Enum.find(admin_permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :create
end)
mft_update =
Enum.find(admin_permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :update
end)
mft_destroy =
Enum.find(admin_permissions.resources, fn p ->
p.resource == "MembershipFeeType" && p.action == :destroy
end)
assert mft_create != nil
assert mft_create.scope == :all
assert mft_create.granted == true
assert mft_update != nil
assert mft_update.granted == true
assert mft_destroy != nil
assert mft_destroy.granted == true
end
end
describe "get_permissions/1 - MembershipFeeCycle resource" do
test "all permission sets have MembershipFeeCycle read; own_data uses :linked, others :all" do
for set <- PermissionSets.all_permission_sets() do
permissions = PermissionSets.get_permissions(set)
mfc_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :read
end)
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
assert mfc_read.granted == true
expected_scope = if set == :own_data, do: :linked, else: :all
assert mfc_read.scope == expected_scope,
"Permission set #{set} should have MembershipFeeCycle read scope #{expected_scope}, got #{mfc_read.scope}"
end
end
test "read_only has MembershipFeeCycle read only, no update" do
permissions = PermissionSets.get_permissions(:read_only)
mfc_update =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :update
end)
assert mfc_update == nil || mfc_update.granted == false
end
test "normal_user has MembershipFeeCycle read/create/update/destroy with scope :all" do
permissions = PermissionSets.get_permissions(:normal_user)
mfc_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :read
end)
mfc_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :create
end)
mfc_update =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :update
end)
mfc_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :destroy
end)
assert mfc_read != nil && mfc_read.granted == true
assert mfc_create != nil && mfc_create.scope == :all && mfc_create.granted == true
assert mfc_update != nil && mfc_update.granted == true
assert mfc_destroy != nil && mfc_destroy.scope == :all && mfc_destroy.granted == true
end
test "admin has MembershipFeeCycle read/create/update/destroy with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
mfc_read =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :read
end)
mfc_create =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :create
end)
mfc_update =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :update
end)
mfc_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "MembershipFeeCycle" && p.action == :destroy
end)
assert mfc_read != nil
assert mfc_read.granted == true
assert mfc_create != nil
assert mfc_create.granted == true
assert mfc_update != nil
assert mfc_update.granted == true
assert mfc_destroy != nil
assert mfc_destroy.granted == true
end
end end
describe "valid_permission_set?/1" do describe "valid_permission_set?/1" do

View file

@ -8,30 +8,67 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
use Mv.DataCase, async: false use Mv.DataCase, async: false
alias Mv.Membership.CustomField alias Mv.Membership.CustomField
alias Mv.Accounts
alias Mv.Authorization
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor} %{actor: system_actor}
end end
defp create_custom_field do defp create_role_with_permission_set(permission_set_name, actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin") 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
},
actor: actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
defp create_user_with_permission_set(permission_set_name, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_custom_field(actor) do
{:ok, field} = {:ok, field} =
CustomField CustomField
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}", name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string value_type: :string
}) })
|> Ash.create(actor: admin, domain: Mv.Membership) |> Ash.create(actor: actor, domain: Mv.Membership)
field field
end end
describe "read access (all roles)" do describe "read access (all roles)" do
test "user with own_data can read all custom fields", %{actor: _actor} do test "user with own_data can read all custom fields", %{actor: actor} do
custom_field = create_custom_field() custom_field = create_custom_field(actor)
user = Mv.Fixtures.user_with_role_fixture("own_data") user = create_user_with_permission_set("own_data", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id) ids = Enum.map(fields, & &1.id)
@ -41,9 +78,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id assert fetched.id == custom_field.id
end end
test "user with read_only can read all custom fields", %{actor: _actor} do test "user with read_only can read all custom fields", %{actor: actor} do
custom_field = create_custom_field() custom_field = create_custom_field(actor)
user = Mv.Fixtures.user_with_role_fixture("read_only") user = create_user_with_permission_set("read_only", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id) ids = Enum.map(fields, & &1.id)
@ -53,9 +90,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id assert fetched.id == custom_field.id
end end
test "user with normal_user can read all custom fields", %{actor: _actor} do test "user with normal_user can read all custom fields", %{actor: actor} do
custom_field = create_custom_field() custom_field = create_custom_field(actor)
user = Mv.Fixtures.user_with_role_fixture("normal_user") user = create_user_with_permission_set("normal_user", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id) ids = Enum.map(fields, & &1.id)
@ -65,9 +102,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id assert fetched.id == custom_field.id
end end
test "user with admin can read all custom fields", %{actor: _actor} do test "user with admin can read all custom fields", %{actor: actor} do
custom_field = create_custom_field() custom_field = create_custom_field(actor)
user = Mv.Fixtures.user_with_role_fixture("admin") user = create_user_with_permission_set("admin", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id) ids = Enum.map(fields, & &1.id)
@ -79,9 +116,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
end end
describe "write access - non-admin cannot create/update/destroy" do describe "write access - non-admin cannot create/update/destroy" do
setup %{actor: _actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user") user = create_user_with_permission_set("normal_user", actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
%{user: user, custom_field: custom_field} %{user: user, custom_field: custom_field}
end end
@ -115,9 +152,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
end end
describe "write access - admin can create/update/destroy" do describe "write access - admin can create/update/destroy" do
setup %{actor: _actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin") user = create_user_with_permission_set("admin", actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
%{user: user, custom_field: custom_field} %{user: user, custom_field: custom_field}
end end

View file

@ -11,6 +11,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
alias Mv.Membership.{CustomField, CustomFieldValue} alias Mv.Membership.{CustomField, CustomFieldValue}
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization
require Ash.Query require Ash.Query
@ -19,9 +20,47 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
%{actor: system_actor} %{actor: system_actor}
end end
defp create_linked_member_for_user(user, _actor) do # Helper to create a role with a specific permission set
admin = Mv.Fixtures.user_with_role_fixture("admin") defp create_role_with_permission_set(permission_set_name, actor) 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
},
actor: actor
) 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, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_linked_member_for_user(user, actor) do
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{ %{
@ -29,20 +68,18 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member", last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com" email: "linked#{System.unique_integer([:positive])}@example.com"
}, },
actor: admin actor: actor
) )
user user
|> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id) |> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false) |> Ash.update(actor: actor, domain: Mv.Accounts, return_notifications?: false)
member member
end end
defp create_unlinked_member(_actor) do defp create_unlinked_member(actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{ %{
@ -50,29 +87,25 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member", last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com" email: "unlinked#{System.unique_integer([:positive])}@example.com"
}, },
actor: admin actor: actor
) )
member member
end end
defp create_custom_field do defp create_custom_field(actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, field} = {:ok, field} =
CustomField CustomField
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}", name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string value_type: :string
}) })
|> Ash.create(actor: admin, domain: Mv.Membership) |> Ash.create(actor: actor)
field field
end end
defp create_custom_field_value(member_id, custom_field_id, value) do defp create_custom_field_value(member_id, custom_field_id, value, actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
@ -80,22 +113,22 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
custom_field_id: custom_field_id, custom_field_id: custom_field_id,
value: %{"_union_type" => "string", "_union_value" => value} value: %{"_union_type" => "string", "_union_value" => value}
}) })
|> Ash.create(actor: admin, domain: Mv.Membership) |> Ash.create(actor: actor, domain: Mv.Membership)
cfv cfv
end end
describe "own_data permission set (Mitglied)" do describe "own_data permission set (Mitglied)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data") user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked") cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked") create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
{:ok, user} = {:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -144,10 +177,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
test "can create custom field value for linked member", %{ test "can create custom field value for linked member", %{
user: user, user: user,
linked_member: linked_member, linked_member: linked_member,
actor: _actor actor: actor
} do } do
# Create a second custom field via admin (own_data cannot create CustomField) # Create a second custom field via admin (own_data cannot create CustomField)
custom_field2 = create_custom_field() custom_field2 = create_custom_field(actor)
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue
@ -224,15 +257,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" do describe "read_only permission set (Vorstand/Buchhaltung)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only") user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked") cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked") create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
{:ok, user} = {:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -307,15 +340,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "normal_user permission set (Kassenwart)" do describe "normal_user permission set (Kassenwart)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user") user = create_user_with_permission_set("normal_user", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked") cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked") create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
{:ok, user} = {:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -346,10 +379,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
test "can create custom field value", %{ test "can create custom field value", %{
user: user, user: user,
unlinked_member: unlinked_member, unlinked_member: unlinked_member,
actor: _actor actor: actor
} do } do
# normal_user cannot create CustomField; use actor (admin) to create it # normal_user cannot create CustomField; use actor (admin) to create it
custom_field = create_custom_field() custom_field = create_custom_field(actor)
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue
@ -388,15 +421,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "admin permission set" do describe "admin permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin") user = create_user_with_permission_set("admin", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field() custom_field = create_custom_field(actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked") cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked") create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
{:ok, user} = {:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -424,7 +457,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
end end
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
custom_field = create_custom_field() custom_field = create_custom_field(user)
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue

View file

@ -1,140 +0,0 @@
defmodule Mv.Membership.GroupPoliciesTest do
@moduledoc """
Tests for Group resource authorization policies.
Verifies that own_data, read_only, normal_user can read groups;
normal_user and admin can create, update, and destroy groups.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_group_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, group} =
Membership.create_group(
%{name: "Test Group #{System.unique_integer([:positive])}", description: "Test"},
actor: admin
)
group
end
describe "own_data permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
end
describe "read_only permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
end
describe "normal_user permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
test "can create group", %{user: user} do
assert {:ok, created} =
Membership.create_group(
%{name: "New Group #{System.unique_integer([:positive])}", description: "New"},
actor: user
)
assert created.name =~ "New Group"
end
test "can update group", %{user: user, group: group} do
assert {:ok, updated} =
Membership.update_group(group, %{description: "Updated"}, actor: user)
assert updated.description == "Updated"
end
test "can destroy group", %{user: user, group: group} do
assert :ok = Membership.destroy_group(group, actor: user)
end
end
describe "admin permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can create group", %{user: user} do
name = "Admin Group #{System.unique_integer([:positive])}"
assert {:ok, group} =
Membership.create_group(%{name: name, description: "Admin created"}, actor: user)
assert group.name == name
end
test "can update group", %{user: user, group: group} do
assert {:ok, updated} =
Membership.update_group(group, %{description: "Updated by admin"}, actor: user)
assert updated.description == "Updated by admin"
end
test "can destroy group", %{user: user, group: group} do
assert :ok = Membership.destroy_group(group, actor: user)
assert {:error, _} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
end
end
end

View file

@ -8,6 +8,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
use Mv.DataCase, async: false use Mv.DataCase, async: false
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership alias Mv.Membership
@ -16,8 +17,49 @@ defmodule Mv.Membership.MemberEmailValidationTest do
%{actor: system_actor} %{actor: system_actor}
end end
defp create_linked_member_for_user(user, _actor) do defp create_role_with_permission_set(permission_set_name, actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin") 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
},
actor: actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
defp create_user_with_permission_set(permission_set_name, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_admin_user(actor) do
create_user_with_permission_set("admin", actor)
end
defp create_linked_member_for_user(user, actor) do
admin = create_admin_user(actor)
{:ok, member} = {:ok, member} =
Membership.create_member( Membership.create_member(
@ -37,8 +79,8 @@ defmodule Mv.Membership.MemberEmailValidationTest do
member member
end end
defp create_unlinked_member(_actor) do defp create_unlinked_member(actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = create_admin_user(actor)
{:ok, member} = {:ok, member} =
Membership.create_member( Membership.create_member(
@ -55,7 +97,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
describe "unlinked member" do describe "unlinked member" do
test "normal_user can update email of unlinked member", %{actor: actor} do test "normal_user can update email of unlinked member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = create_user_with_permission_set("normal_user", actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
new_email = "new#{System.unique_integer([:positive])}@example.com" new_email = "new#{System.unique_integer([:positive])}@example.com"
@ -67,7 +109,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
end end
test "validation does not block when member has no linked user", %{actor: actor} do test "validation does not block when member has no linked user", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = create_user_with_permission_set("normal_user", actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
new_email = "other#{System.unique_integer([:positive])}@example.com" new_email = "other#{System.unique_integer([:positive])}@example.com"
@ -79,10 +121,10 @@ defmodule Mv.Membership.MemberEmailValidationTest do
describe "linked member another user's member" do describe "linked member another user's member" do
test "normal_user cannot update email of another user's linked member", %{actor: actor} do test "normal_user cannot update email of another user's linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data") user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user_b = create_user_with_permission_set("normal_user", actor)
new_email = "other#{System.unique_integer([:positive])}@example.com" new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:error, %Ash.Error.Invalid{} = error} = assert {:error, %Ash.Error.Invalid{} = error} =
@ -93,9 +135,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
end end
test "admin can update email of linked member", %{actor: actor} do test "admin can update email of linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data") user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = create_admin_user(actor)
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com" new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
@ -108,7 +150,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
describe "linked member own member" do describe "linked member own member" do
test "own_data user can update email of their own linked member", %{actor: actor} do test "own_data user can update email of their own linked member", %{actor: actor} do
own_data_user = Mv.Fixtures.user_with_role_fixture("own_data") own_data_user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(own_data_user, actor) linked_member = create_linked_member_for_user(own_data_user, actor)
{:ok, own_data_user} = {:ok, own_data_user} =
@ -126,7 +168,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
end end
test "normal_user with linked member can update email of that same member", %{actor: actor} do test "normal_user with linked member can update email of that same member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = create_user_with_permission_set("normal_user", actor)
linked_member = create_linked_member_for_user(normal_user, actor) linked_member = create_linked_member_for_user(normal_user, actor)
{:ok, normal_user} = {:ok, normal_user} =
@ -146,9 +188,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
describe "no-op / other fields" do describe "no-op / other fields" do
test "updating only other attributes on linked member as normal_user does not trigger validation error", test "updating only other attributes on linked member as normal_user does not trigger validation error",
%{actor: actor} do %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data") user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user_b = create_user_with_permission_set("normal_user", actor)
assert {:ok, updated} = assert {:ok, updated} =
Membership.update_member(linked_member, %{first_name: "UpdatedName"}, Membership.update_member(linked_member, %{first_name: "UpdatedName"},
@ -160,9 +202,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
end end
test "updating email of linked member as admin succeeds", %{actor: actor} do test "updating email of linked member as admin succeeds", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data") user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = create_admin_user(actor)
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com" new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
@ -175,7 +217,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
describe "read_only" do describe "read_only" do
test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") read_only_user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(read_only_user, actor) linked_member = create_linked_member_for_user(read_only_user, actor)
{:ok, read_only_user} = {:ok, read_only_user} =

View file

@ -1,234 +0,0 @@
defmodule Mv.Membership.MemberGroupPoliciesTest do
@moduledoc """
Tests for MemberGroup resource authorization policies.
Verifies own_data can only read linked member's associations;
read_only can read all, cannot create/destroy;
normal_user and admin can read, create, destroy.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_member_fixture do
Mv.Fixtures.member_fixture()
end
defp create_group_fixture do
Mv.Fixtures.group_fixture()
end
defp create_member_group_fixture(member_id, group_id) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member_group} =
Membership.create_member_group(%{member_id: member_id, group_id: group_id}, actor: admin)
member_group
end
describe "own_data permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
member = create_member_fixture()
group = create_group_fixture()
# Link user to member so actor.member_id is set
admin = Mv.Fixtures.user_with_role_fixture("admin")
user =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin)
{:ok, user} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
mg_linked = create_member_group_fixture(member.id, group.id)
# MemberGroup for another member (not linked to user)
other_member = create_member_fixture()
other_group = create_group_fixture()
mg_other = create_member_group_fixture(other_member.id, other_group.id)
%{user: user, member: member, group: group, mg_linked: mg_linked, mg_other: mg_other}
end
test "can read member_groups for linked member only", %{user: user, mg_linked: mg_linked} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids
refute Enum.empty?(list)
end
test "list returns only member_groups where member_id == actor.member_id", %{
user: user,
mg_linked: mg_linked,
mg_other: mg_other
} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids
refute mg_other.id in ids
end
test "cannot create member_group (returns forbidden)", %{user: user, actor: _actor} do
# Use fresh member/group so we assert on Forbidden, not on duplicate validation
other_member = create_member_fixture()
other_group = create_group_fixture()
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member_group(
%{member_id: other_member.id, group_id: other_group.id},
actor: user
)
end
test "cannot destroy member_group (returns forbidden)", %{user: user, mg_linked: mg_linked} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.destroy_member_group(mg_linked, actor: user)
end
end
describe "read_only permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "cannot create member_group (returns forbidden)", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "cannot destroy member_group (returns forbidden)", %{user: user, mg: mg} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.destroy_member_group(mg, actor: user)
end
end
describe "normal_user permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "can create member_group", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "can destroy member_group", %{user: user, mg: mg} do
assert :ok = Membership.destroy_member_group(mg, actor: user)
end
end
describe "admin permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "admin with member_id set (linked to member) still reads all member_groups", %{
actor: actor
} do
# Admin linked to a member (e.g. viewing as member context) must still get :all scope,
# not restricted to linked member's groups (bypass is only for own_data).
admin = Mv.Fixtures.user_with_role_fixture("admin")
linked_member = create_member_fixture()
other_member = create_member_fixture()
group_a = create_group_fixture()
group_b = create_group_fixture()
admin =
admin
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, linked_member.id)
|> Ash.update(actor: actor)
{:ok, admin} = Ash.load(admin, :role, domain: Mv.Accounts, actor: actor)
mg_linked = create_member_group_fixture(linked_member.id, group_a.id)
mg_other = create_member_group_fixture(other_member.id, group_b.id)
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: admin, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids, "Admin with member_id must see linked member's MemberGroups"
assert mg_other.id in ids,
"Admin with member_id must see all MemberGroups (:all), not only linked"
end
test "can create member_group", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "can destroy member_group", %{user: user, mg: mg} do
assert :ok = Membership.destroy_member_group(mg, actor: user)
end
end
end

View file

@ -12,6 +12,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
alias Mv.Membership alias Mv.Membership
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization
require Ash.Query require Ash.Query
@ -20,9 +21,58 @@ defmodule Mv.Membership.MemberPoliciesTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name, actor) 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
},
actor: actor
) 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, actor) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name, actor)
# 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(actor: actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user(actor) do
create_user_with_permission_set("admin", actor)
end
# Helper to create a member linked to a user # Helper to create a member linked to a user
defp create_linked_member_for_user(user, _actor) do defp create_linked_member_for_user(user, actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = create_admin_user(actor)
# Create member # Create member
# NOTE: We need to ensure the member is actually persisted to the database # NOTE: We need to ensure the member is actually persisted to the database
@ -55,8 +105,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
end end
# Helper to create an unlinked member (no user relationship) # Helper to create an unlinked member (no user relationship)
defp create_unlinked_member(_actor) do defp create_unlinked_member(actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = create_admin_user(actor)
{:ok, member} = {:ok, member} =
Membership.create_member( Membership.create_member(
@ -73,7 +123,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "own_data permission set (Mitglied)" do describe "own_data permission set (Mitglied)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data") user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
@ -157,7 +207,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" do describe "read_only permission set (Vorstand/Buchhaltung)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only") user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
@ -223,7 +273,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "normal_user permission set (Kassenwart)" do describe "normal_user permission set (Kassenwart)" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user") user = create_user_with_permission_set("normal_user", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
@ -280,7 +330,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "admin permission set" do describe "admin permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin") user = create_user_with_permission_set("admin", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor) unlinked_member = create_unlinked_member(actor)
@ -347,7 +397,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
# read_only has Member.read scope :all, but the special case ensures # 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. # 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. # This test verifies the special case works independently of permission sets.
user = Mv.Fixtures.user_with_role_fixture("read_only") user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id # Reload user to get updated member_id
@ -366,7 +416,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
# own_data has Member.read scope :linked, but the special case ensures # own_data has Member.read scope :linked, but the special case ensures
# users can ALWAYS read their linked member regardless of permission set. # users can ALWAYS read their linked member regardless of permission set.
user = Mv.Fixtures.user_with_role_fixture("own_data") user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id # Reload user to get updated member_id
@ -387,7 +437,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
} do } do
# Update is NOT handled by special case - it's handled by HasPermission # Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked. # with :linked scope. own_data has Member.update scope :linked.
user = Mv.Fixtures.user_with_role_fixture("own_data") user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor) linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id # Reload user to get updated member_id

View file

@ -1,294 +0,0 @@
defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
@moduledoc """
Tests for MembershipFeeCycle resource authorization policies.
Verifies own_data can only read :linked (linked member's cycles);
read_only can only read (no create/update/destroy);
normal_user and admin can read, create, update, destroy (including mark_as_paid).
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_member_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
member
end
defp create_fee_type_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, fee_type} =
MembershipFees.create_membership_fee_type(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("10.00"),
interval: :yearly,
description: "Test"
},
actor: admin
)
fee_type
end
defp create_cycle_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
member = create_member_fixture()
fee_type = create_fee_type_fixture()
{:ok, cycle} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: admin
)
cycle
end
describe "own_data permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_member_fixture()
other_member = create_member_fixture()
fee_type = create_fee_type_fixture()
admin = Mv.Fixtures.user_with_role_fixture("admin")
user =
user
|> Ash.Changeset.for_update(:update, %{}, domain: Mv.Accounts)
|> Ash.Changeset.force_change_attribute(:member_id, linked_member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts)
{:ok, user} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
{:ok, cycle_linked} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: linked_member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: admin
)
{:ok, cycle_other} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: other_member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.add(Date.utc_today(), -365),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: admin
)
%{user: user, cycle_linked: cycle_linked, cycle_other: cycle_other}
end
test "can read only linked member's cycles", %{
user: user,
cycle_linked: cycle_linked,
cycle_other: cycle_other
} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.read(actor: user, domain: Mv.MembershipFees)
ids = Enum.map(list, & &1.id)
assert cycle_linked.id in ids
refute cycle_other.id in ids
end
end
describe "read_only permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
cycle = create_cycle_fixture()
%{actor: actor, user: user, cycle: cycle}
end
test "can read membership_fee_cycles (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "cannot update cycle (returns forbidden)", %{user: user, cycle: cycle} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user)
end
test "cannot mark_as_paid (returns forbidden)", %{user: user, cycle: cycle} do
assert {:error, %Ash.Error.Forbidden{}} =
cycle
|> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees)
|> Ash.update(actor: user, domain: Mv.MembershipFees)
end
test "cannot create cycle (returns forbidden)", %{user: user, actor: _actor} do
member = create_member_fixture()
fee_type = create_fee_type_fixture()
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: user
)
end
test "cannot destroy cycle (returns forbidden)", %{user: user, cycle: cycle} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.destroy_membership_fee_cycle(cycle, actor: user)
end
end
describe "normal_user permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
cycle = create_cycle_fixture()
%{actor: actor, user: user, cycle: cycle}
end
test "can read membership_fee_cycles (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "can update cycle status", %{user: user, cycle: cycle} do
assert {:ok, updated} =
MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user)
assert updated.status == :paid
end
test "can mark_as_paid", %{user: user, cycle: cycle} do
assert {:ok, updated} =
cycle
|> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees)
|> Ash.update(actor: user, domain: Mv.MembershipFees)
assert updated.status == :paid
end
test "can create cycle", %{user: user, actor: _actor} do
member = create_member_fixture()
fee_type = create_fee_type_fixture()
assert {:ok, created} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: user
)
assert created.member_id == member.id
end
test "can destroy cycle", %{user: user, cycle: cycle} do
assert :ok = MembershipFees.destroy_membership_fee_cycle(cycle, actor: user)
end
end
describe "admin permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
cycle = create_cycle_fixture()
%{actor: actor, user: user, cycle: cycle}
end
test "can read membership_fee_cycles (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "can update cycle", %{user: user, cycle: cycle} do
assert {:ok, updated} =
MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user)
assert updated.status == :paid
end
test "can mark_as_paid", %{user: user, cycle: cycle} do
cycle_unpaid =
cycle
|> Ash.Changeset.for_update(:mark_as_unpaid, %{}, domain: Mv.MembershipFees)
|> Ash.update!(actor: user, domain: Mv.MembershipFees)
assert {:ok, updated} =
cycle_unpaid
|> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees)
|> Ash.update(actor: user, domain: Mv.MembershipFees)
assert updated.status == :paid
end
test "can create cycle", %{user: user, actor: _actor} do
member = create_member_fixture()
fee_type = create_fee_type_fixture()
assert {:ok, created} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"),
status: :unpaid
},
actor: user
)
assert created.member_id == member.id
end
test "can destroy cycle", %{user: user, cycle: cycle} do
assert :ok = MembershipFees.destroy_membership_fee_cycle(cycle, actor: user)
end
end
end

View file

@ -1,260 +0,0 @@
defmodule Mv.MembershipFees.MembershipFeeTypePoliciesTest do
@moduledoc """
Tests for MembershipFeeType resource authorization policies.
Verifies all roles (own_data, read_only, normal_user, admin) can read;
only admin can create, update, and destroy; non-admin create/update/destroy Forbidden.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_membership_fee_type_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, fee_type} =
MembershipFees.create_membership_fee_type(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("10.00"),
interval: :yearly,
description: "Test"
},
actor: admin
)
fee_type
end
describe "own_data permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
fee_type = create_membership_fee_type_fixture()
%{actor: actor, user: user, fee_type: fee_type}
end
test "can read membership_fee_types (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeType
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "can read single membership_fee_type", %{user: user, fee_type: fee_type} do
{:ok, found} =
Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type.id,
actor: user,
domain: Mv.MembershipFees
)
assert found.id == fee_type.id
end
test "cannot create membership_fee_type (returns forbidden)", %{user: user} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.create_membership_fee_type(
%{
name: "New Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("5.00"),
interval: :monthly
},
actor: user
)
end
test "cannot update membership_fee_type (returns forbidden)", %{
user: user,
fee_type: fee_type
} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"},
actor: user
)
end
test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: _actor} do
# Use a fee type with no members/cycles so destroy would succeed if authorized
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, isolated} =
MembershipFees.create_membership_fee_type(
%{
name: "Isolated #{System.unique_integer([:positive])}",
amount: Decimal.new("1.00"),
interval: :yearly
},
actor: admin
)
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.destroy_membership_fee_type(isolated, actor: user)
end
end
describe "read_only permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
fee_type = create_membership_fee_type_fixture()
%{actor: actor, user: user, fee_type: fee_type}
end
test "can read membership_fee_types (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeType
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "cannot create membership_fee_type (returns forbidden)", %{user: user} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.create_membership_fee_type(
%{
name: "New Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("5.00"),
interval: :monthly
},
actor: user
)
end
test "cannot update membership_fee_type (returns forbidden)", %{
user: user,
fee_type: fee_type
} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"},
actor: user
)
end
test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: _actor} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, isolated} =
MembershipFees.create_membership_fee_type(
%{
name: "Isolated #{System.unique_integer([:positive])}",
amount: Decimal.new("1.00"),
interval: :yearly
},
actor: admin
)
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.destroy_membership_fee_type(isolated, actor: user)
end
end
describe "normal_user permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
fee_type = create_membership_fee_type_fixture()
%{actor: actor, user: user, fee_type: fee_type}
end
test "can read membership_fee_types (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeType
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "cannot create membership_fee_type (returns forbidden)", %{user: user} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.create_membership_fee_type(
%{
name: "New Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("5.00"),
interval: :monthly
},
actor: user
)
end
test "cannot update membership_fee_type (returns forbidden)", %{
user: user,
fee_type: fee_type
} do
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"},
actor: user
)
end
test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: _actor} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, isolated} =
MembershipFees.create_membership_fee_type(
%{
name: "Isolated #{System.unique_integer([:positive])}",
amount: Decimal.new("1.00"),
interval: :yearly
},
actor: admin
)
assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.destroy_membership_fee_type(isolated, actor: user)
end
end
describe "admin permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
fee_type = create_membership_fee_type_fixture()
%{actor: actor, user: user, fee_type: fee_type}
end
test "can read membership_fee_types (list)", %{user: user} do
{:ok, list} =
Mv.MembershipFees.MembershipFeeType
|> Ash.read(actor: user, domain: Mv.MembershipFees)
assert is_list(list)
end
test "can create membership_fee_type", %{user: user} do
name = "Admin Fee #{System.unique_integer([:positive])}"
assert {:ok, created} =
MembershipFees.create_membership_fee_type(
%{name: name, amount: Decimal.new("20.00"), interval: :quarterly},
actor: user
)
assert created.name == name
end
test "can update membership_fee_type", %{user: user, fee_type: fee_type} do
new_name = "Updated #{System.unique_integer([:positive])}"
assert {:ok, updated} =
MembershipFees.update_membership_fee_type(fee_type, %{name: new_name}, actor: user)
assert updated.name == new_name
end
test "can destroy membership_fee_type", %{user: user} do
{:ok, isolated} =
MembershipFees.create_membership_fee_type(
%{
name: "To Delete #{System.unique_integer([:positive])}",
amount: Decimal.new("1.00"),
interval: :yearly
},
actor: user
)
assert :ok = MembershipFees.destroy_membership_fee_type(isolated, actor: user)
end
end
end

View file

@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: actor) |> Ash.load!(:membership_fee_type)
# Use a fixed date in 2024 to ensure 2023 is last completed # Use a fixed date in 2024 to ensure 2023 is last completed
today = ~D[2024-06-15] today = ~D[2024-06-15]
@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles and fee type (will be empty) # Load cycles and fee type (will be empty)
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: actor) |> Ash.load!(:membership_fee_type)
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today()) last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
assert last_cycle == nil assert last_cycle == nil
@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: actor) |> Ash.load!(:membership_fee_type)
result = MembershipFeeHelpers.get_current_cycle(member, today) result = MembershipFeeHelpers.get_current_cycle(member, today)

View file

@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
end end
describe "create form" do describe "create form" do
test "creates new membership fee type", %{conn: conn, user: user} do test "creates new membership fee type", %{conn: conn} do
{:ok, view, _html} = live(conn, "/membership_fee_types/new") {:ok, view, _html} = live(conn, "/membership_fee_types/new")
form_data = %{ form_data = %{
@ -67,13 +67,12 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert to == "/membership_fee_types" assert to == "/membership_fee_types"
# Verify type was created (use actor so read is authorized) # Verify type was created
type = type =
MembershipFeeType MembershipFeeType
|> Ash.Query.filter(name == "New Type") |> Ash.Query.filter(name == "New Type")
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user) |> Ash.read_one!()
assert type != nil, "Expected membership fee type to be created"
assert type.amount == Decimal.new("75.00") assert type.amount == Decimal.new("75.00")
assert type.interval == :yearly assert type.interval == :yearly
end end
@ -141,7 +140,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder" assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
end end
test "amount change can be confirmed", %{conn: conn, user: user} do test "amount change can be confirmed", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
@ -160,17 +159,12 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"}) |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_submit() |> render_submit()
# Amount should be updated (use actor so read is authorized) # Amount should be updated
updated_type = updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
MembershipFeeType
|> Ash.Query.filter(id == ^fee_type.id)
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
assert updated_type != nil
assert updated_type.amount == Decimal.new("75.00") assert updated_type.amount == Decimal.new("75.00")
end end
test "amount change can be cancelled", %{conn: conn, user: user} do test "amount change can be cancelled", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
@ -184,13 +178,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|> element("button[phx-click='cancel_amount_change']") |> element("button[phx-click='cancel_amount_change']")
|> render_click() |> render_click()
# Amount should remain unchanged (use actor so read is authorized) # Amount should remain unchanged
updated_type = updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
MembershipFeeType
|> Ash.Query.filter(id == ^fee_type.id)
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
assert updated_type != nil
assert updated_type.amount == Decimal.new("50.00") assert updated_type.amount == Decimal.new("50.00")
end end

View file

@ -61,7 +61,6 @@ defmodule MvWeb.ProfileNavigationTest do
end end
@tag :skip @tag :skip
# credo:disable-for-next-line Credo.Check.Design.TagTODO
# TODO: Implement user initials in navbar avatar - see issue #170 # TODO: Implement user initials in navbar avatar - see issue #170
test "shows user initials in avatar", %{conn: conn} do test "shows user initials in avatar", %{conn: conn} do
# Setup: Create and login a user # Setup: Create and login a user

View file

@ -127,12 +127,10 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
# Use fixed date in 2024 to ensure 2023 is last completed # Use fixed date in 2024 to ensure 2023 is last completed
# We need to manually set the date for the helper function # We need to manually set the date for the helper function
@ -185,8 +183,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
status = MembershipFeeStatus.get_cycle_status_for_member(member, true) status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
@ -224,8 +222,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Load cycles and fee type first (will be empty) # Load cycles and fee type first (will be empty)
member = member =
member member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
status = MembershipFeeStatus.get_cycle_status_for_member(member, false) status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
@ -275,14 +273,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member2 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
@ -304,14 +300,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member2 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
@ -333,14 +327,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member2 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
@ -362,14 +354,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member2 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
@ -383,14 +373,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member1 = create_member(%{membership_fee_type_id: fee_type.id}) member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type, actor: system_actor) |> Ash.load!(:membership_fee_type)
end) end)
# filter_unpaid_members should still work for backwards compatibility # filter_unpaid_members should still work for backwards compatibility

View file

@ -28,6 +28,21 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|> Ash.create!(actor: system_actor) |> Ash.create!(actor: system_actor)
end end
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
# Helper to create a cycle # Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do defp create_cycle(member, fee_type, attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -58,7 +73,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "cycles table display" do describe "cycles table display" do
test "displays all cycles for member", %{conn: conn} do test "displays all cycles for member", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid}) _cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) _cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
@ -80,7 +95,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
test "table columns show correct data", %{conn: conn} do test "table columns show correct data", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")}) fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{ create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01], cycle_start: ~D[2023-01-01],
@ -109,7 +124,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"}) yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"}) _monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id}) member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}") {:ok, _view, html} = live(conn, "/members/#{member.id}")
@ -117,30 +132,20 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
assert html =~ "Yearly Type" assert html =~ "Yearly Type"
end end
test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{ test "shows no type message when no type assigned", %{conn: conn} do
conn: conn member = create_member(%{})
} do
member = Mv.Fixtures.member_fixture(%{})
{:ok, view, html} = live(conn, "/members/#{member.id}") {:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show message about no type assigned # Should show message about no type assigned
assert html =~ "No membership fee type assigned" || html =~ "No type" assert html =~ "No membership fee type assigned" || html =~ "No type"
# Switch to membership fees tab: message and no Regenerate Cycles button
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
refute has_element?(view, "button[phx-click='regenerate_cycles']"),
"Regenerate Cycles should be hidden when no membership fee type is assigned"
end end
end end
describe "status change actions" do describe "status change actions" do
test "mark as paid works", %{conn: conn} do test "mark as paid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
@ -171,7 +176,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
test "mark as suspended works", %{conn: conn} do test "mark as suspended works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
@ -202,7 +207,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
test "mark as unpaid works", %{conn: conn} do test "mark as unpaid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
@ -235,7 +240,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "cycle regeneration" do describe "cycle regeneration" do
test "manual regeneration button exists and can be clicked", %{conn: conn} do test "manual regeneration button exists and can be clicked", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) member = create_member(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}") {:ok, view, _html} = live(conn, "/members/#{member.id}")
@ -261,7 +266,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "edge cases" do describe "edge cases" do
test "handles members without membership fee type gracefully", %{conn: conn} do test "handles members without membership fee type gracefully", %{conn: conn} do
# No fee type # No fee type
member = Mv.Fixtures.member_fixture(%{}) member = create_member(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}") {:ok, _view, html} = live(conn, "/members/#{member.id}")
@ -269,120 +274,4 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
assert html =~ member.first_name assert html =~ member.first_name
end end
end end
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
@tag role: :read_only
test "read_only does not see Regenerate Cycles, Delete All Cycles, or Create Cycle buttons",
%{
conn: conn
} do
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
_cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
refute has_element?(view, "button[phx-click='regenerate_cycles']")
refute has_element?(view, "button[phx-click='delete_all_cycles']")
refute has_element?(view, "button[phx-click='open_create_cycle_modal']")
end
@tag role: :read_only
test "read_only does not see Paid, Unpaid, Suspended, or Delete buttons in cycles table", %{
conn: conn
} do
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Row action buttons must not be present for read_only
refute has_element?(view, "button[phx-click='mark_cycle_status']")
refute has_element?(view, "button[phx-click='delete_cycle']")
# Sanity: cycle row is present (read is allowed)
assert has_element?(view, "tr[id='cycle-#{cycle.id}']")
end
end
describe "read_only cannot delete all cycles (policy enforced via Ash.destroy)" do
@tag role: :read_only
test "Ash.destroy returns Forbidden for read_only so handler would reject", %{
current_user: read_only_user
} do
# The handler uses Ash.destroy per cycle, so if the handler were triggered
# (e.g. via dev tools), the server would enforce policy and show an error.
# This test verifies that Ash.destroy(cycle, actor: read_only_user) returns Forbidden.
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
assert {:error, %Ash.Error.Forbidden{}} =
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
end
end
describe "read_only cannot trigger regenerate_cycles (handler enforces can?)" do
@tag role: :read_only
test "read_only cannot create MembershipFeeCycle so regenerate_cycles handler would show flash error",
%{current_user: read_only_user} do
# The regenerate_cycles handler checks can?(actor, :create, MembershipFeeCycle) before
# calling the generator. If a read_only user triggered the event (e.g. via DevTools),
# the handler returns flash error and no new cycles are created.
# This test verifies the condition the handler uses.
refute MvWeb.Authorization.can?(read_only_user, :create, MembershipFeeCycle),
"read_only must not be allowed to create MembershipFeeCycle so handler rejects regenerate_cycles"
end
end
describe "confirm_delete_all_cycles handler (policy enforced)" do
@tag role: :admin
test "admin can delete all cycles via UI and cycles are removed", %{conn: conn} do
# Use English locale so confirmation "Yes" matches gettext("Yes")
conn = put_session(conn, :locale, "en")
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
_c1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
_c2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
view
|> element("button[phx-click='delete_all_cycles']")
|> render_click()
view
|> element("input[phx-keyup='update_delete_all_confirmation']")
|> render_keyup(%{"value" => "Yes"})
view
|> element("button[phx-click='confirm_delete_all_cycles']")
|> render_click()
_html = render(view)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
remaining =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: system_actor)
assert remaining == [],
"Expected all cycles to be deleted (handler enforces policy via Ash.destroy)"
end
end
end end

View file

@ -742,18 +742,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
assert conn.status == 200 assert conn.status == 200
end end
@tag role: :normal_user
test "GET /groups/new returns 200", %{conn: conn} do
conn = get(conn, "/groups/new")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /groups/:slug/edit returns 200", %{conn: conn, group_slug: slug} do
conn = get(conn, "/groups/#{slug}/edit")
assert conn.status == 200
end
@tag role: :normal_user @tag role: :normal_user
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}/show/edit") conn = get(conn, "/members/#{id}/show/edit")
@ -842,6 +830,22 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
assert redirected_to(conn) == "/users/#{user.id}" assert redirected_to(conn) == "/users/#{user.id}"
end end
@tag role: :normal_user
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/groups/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /groups/:slug/edit redirects to user profile", %{
conn: conn,
current_user: user,
group_slug: slug
} do
conn = get(conn, "/groups/#{slug}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user @tag role: :normal_user
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/admin/roles") conn = get(conn, "/admin/roles")

View file

@ -213,35 +213,6 @@ defmodule MvWeb.UserLive.FormTest do
assert not is_nil(updated_user.hashed_password) assert not is_nil(updated_user.hashed_password)
assert updated_user.hashed_password != "" assert updated_user.hashed_password != ""
end end
test "admin can change user role and change persists", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
role_a = Mv.Fixtures.role_fixture("normal_user")
role_b = Mv.Fixtures.role_fixture("read_only")
user = create_test_user(%{email: "rolechange@example.com"})
{:ok, user} = Mv.Accounts.update_user(user, %{role_id: role_a.id}, actor: system_actor)
assert user.role_id == role_a.id
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
view
|> form("#user-form",
user: %{
email: "rolechange@example.com",
role_id: role_b.id
}
)
|> render_submit()
assert_redirected(view, "/users")
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
assert updated_user.role_id == role_b.id,
"Expected role_id to persist as #{role_b.id}, got #{inspect(updated_user.role_id)}"
end
end end
describe "edit user form - validation" do describe "edit user form - validation" do

View file

@ -55,6 +55,7 @@ defmodule MvWeb.UserLive.IndexTest do
# Should show ascending indicator (up arrow) # Should show ascending indicator (up arrow)
assert html =~ "hero-chevron-up" assert html =~ "hero-chevron-up"
assert html =~ ~s(aria-sort="ascending")
# Test actual sort order: alpha should appear before mike, mike before zulu # Test actual sort order: alpha should appear before mike, mike before zulu
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0) alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
@ -75,6 +76,7 @@ defmodule MvWeb.UserLive.IndexTest do
# Should now show descending indicator (down arrow) # Should now show descending indicator (down arrow)
assert html =~ "hero-chevron-down" assert html =~ "hero-chevron-down"
assert html =~ ~s(aria-sort="descending")
# Test actual sort order reversed: zulu should now appear before mike, mike before alpha # Test actual sort order reversed: zulu should now appear before mike, mike before alpha
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0) alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
@ -105,6 +107,7 @@ defmodule MvWeb.UserLive.IndexTest do
# Click again to toggle back to ascending # Click again to toggle back to ascending
html = view |> element("button[phx-value-field='email']") |> render_click() html = view |> element("button[phx-value-field='email']") |> render_click()
assert html =~ "hero-chevron-up" assert html =~ "hero-chevron-up"
assert html =~ ~s(aria-sort="ascending")
# Should be back to original ascending order # Should be back to original ascending order
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0) alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
@ -376,45 +379,6 @@ defmodule MvWeb.UserLive.IndexTest do
end end
end end
describe "Password column display" do
test "user without password shows em dash in Password column", %{conn: conn} do
# User created with hashed_password: nil (no password) - must not get default password
user_no_pw =
create_test_user(%{
email: "no-password@example.com",
hashed_password: nil
})
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/users")
assert html =~ "no-password@example.com"
# Password column must show "—" (em dash) for user without password, not "Enabled"
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
assert row =~ "", "Password column should show em dash for user without password"
refute row =~ "Enabled",
"Password column must not show Enabled when user has no password"
end
test "user with password shows Enabled in Password column", %{conn: conn} do
user_with_pw =
create_test_user(%{
email: "with-password@example.com",
password: "test123"
})
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/users")
assert html =~ "with-password@example.com"
row = view |> element("tr#row-#{user_with_pw.id}") |> render()
assert row =~ "Enabled", "Password column should show Enabled when user has password"
end
end
describe "member linking display" do describe "member linking display" do
@tag :slow @tag :slow
test "displays linked member name in user list", %{conn: conn} do test "displays linked member name in user list", %{conn: conn} do