Compare commits

...

28 commits

Author SHA1 Message Date
Renovate Bot
6aadf4f93b Update Mix dependencies
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 12:13:20 +01:00
d13fbef890 Merge pull request 'Complete Permissions for Groups, Membership Fees, and User Role Assignment closes #404' (#405) from feature/404_permission_completeness into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #405
2026-02-04 11:47:17 +01:00
083592489f ARIA: set aria-sort on th for sortable columns
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
- Table: optional col sort_field; th gets aria-sort when col is sorted.
- User index: pass sort_field/sort_order to table, sort_field: :email on email col.
2026-02-04 11:40:23 +01:00
24d130ffb5 OIDC: use UserHelpers.has_oidc? in index and show
- Index OIDC column and show OIDC item use has_oidc? instead of raw oidc_id.
- Avoids empty string showing as Linked.
2026-02-04 11:40:21 +01:00
503401f2e6 Setting: remove unused actor in default_fee_type validation
- Docs: Regenerate Cycles server-side enforcement note in membership-fee-architecture.
2026-02-04 11:40:19 +01:00
d7c6d20483 User form: red warning for OIDC users when setting/changing password
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
- Show alert when user has oidc_id and password section is visible.
- Explains that password here does not change SSO/identity provider password.
2026-02-04 11:07:01 +01:00
b6d1a27bc9 Seeds: only admin gets password; additional users without password
- Additional users (hans, greta, maria, thomas) created without admin_set_password.
- Removed no-password@example.de user.
2026-02-04 11:06:59 +01:00
541c79e501 ARIA: remove aria-sort from sort button; Password column tests
- Sort button: aria-sort removed (button role does not support it).
- Index tests: remove aria-sort assertions; add Password column display tests.
2026-02-04 11:06:55 +01:00
c6082f2831 Users list and show: Role, Password, OIDC columns; UserHelpers
- Index: load :role; columns Role, Password (has_password?), OIDC; contrast fix.
- Show: Role, OIDC (Linked/Not linked); has_password? for Password Authentication.
- UserHelpers: has_password?/1, has_oidc?/1. Gettext: new strings and DE translations.
2026-02-04 11:06:52 +01:00
7eba21dc9c Hide Regenerate Cycles button when no membership fee type assigned
All checks were successful
continuous-integration/drone/push Build is passing
- Button only shown when @member.membership_fee_type is set (same as Create Cycle).
- Test: no-type view asserts Regenerate Cycles button is not present.
2026-02-04 09:38:26 +01:00
c035d0f141 Docs: groups and roles/permissions architecture, Group moduledoc
All checks were successful
continuous-integration/drone/push Build is passing
- groups-architecture: normal_user and admin can manage groups.
- roles-and-permissions: matrix and MembershipFeeCycle :linked for own_data.
- group_policies_test: update moduledoc.
2026-02-04 09:20:26 +01:00
178f5a01c7 MembershipFeeCycle: own_data read :linked via bypass and HasPermission scope
- own_data gets read scope :linked; apply_scope in HasPermission; bypass check for own_data.
- PermissionSetsTest expects own_data :linked, others :all for MFC read.
2026-02-04 09:20:10 +01:00
890a4d3752 MemberGroup: restrict bypass to own_data via MemberGroupReadLinkedForOwnData
- ActorPermissionSetIs check; bypass policy filters by member_id for own_data only.
- Admin with member_id still gets :all via HasPermission. Tests added.
2026-02-04 09:19:57 +01:00
67ce514ba0 User: fix last-admin validation and forbid non-admin role_id change
- Last-admin only when target role is non-admin (admins may switch admin roles).
- Use Ash.Changeset.get_attribute for new role_id. Tests: admin role switch, non-admin update_user role_id forbidden.
2026-02-04 09:19:47 +01:00
dbd0a57292 Secure regenerate_cycles: require can?(:create, MembershipFeeCycle) in handler
- Handler returns flash error when non-admin triggers event (e.g. DevTools).
- Test: read_only cannot create MembershipFeeCycle so handler rejects.
2026-02-04 09:19:37 +01:00
03d3a7eb1b Docs and tests: fix CODE_GUIDELINES structure, use Mv.Fixtures in show_membership_fees_test
All checks were successful
continuous-integration/drone/push Build is passing
- CODE_GUIDELINES: correct custom_field/custom_field_value descriptions, add fixtures.ex to test support
- show_membership_fees_test: use Mv.Fixtures.member_fixture, remove redundant create_member helper
2026-02-04 01:02:22 +01:00
a2e1054c8d Tests: use Mv.Fixtures, fix warnings, Credo TODO disable
- Policy tests: use Fixtures where applicable; create_custom_field() fix in custom_field_value.
- Replace unused actor with _actor, remove unused alias Accounts in policy tests.
- profile_navigation_test: disable Credo for intentional TODO comment.
2026-02-04 00:34:12 +01:00
3a92398d54 user_policies_test: data-driven tests for own_data, read_only, normal_user
Single describe with @tag permission_set and for-loop; one setup per permission set.
2026-02-04 00:34:02 +01:00
085b6be769 show_membership_fees_test: format long assert line 2026-02-04 00:34:01 +01:00
182d34fe58 MemberLive: confirm_delete_all_cycles via Ash.destroy, reduce current_actor
- Delete each cycle with Ash.destroy(actor:) so policies apply; add do_delete_all_cycles/5.
- Use positive can? check; remove duplicate current_actor(socket) in change_membership_fee_type.
2026-02-04 00:34:00 +01:00
e799f0271c Refactor PermissionSets: define admin permissions via perm_all()
Use perm/3 helper for admin resource permissions (DRY). MemberGroup
keeps read/create/destroy only (no update in domain).
2026-02-04 00:33:58 +01:00
c4459ebb92 Docs, gettext, and remaining test updates
All checks were successful
continuous-integration/drone/push Build is passing
- groups-architecture and membership-fee-architecture docs
- Gettext: add/correct German for authorization and membership fee type
- membership_fee_helpers_test and membership_fee_status_test adjustments
2026-02-03 23:52:31 +01:00
101fd39f18 Fee settings and fee type form: pass actor for MembershipFeeType read
- membership_fee_settings_live: current_actor(socket), Ash.read! with actor
- membership_fee_type_live/form: Ash.get! with actor in mount
- check_page_permission_test: normal_user /groups/new and /groups/:slug/edit allowed
- membership_fee_type_live form_test: actor for Ash.read_one!/get!
2026-02-03 23:52:27 +01:00
e3bea17827 Member show & MembershipFees: permissions, delete all, regenerate, errors
- Show: handle_info :member_updated and :put_flash; Linked User only when can_access_page? /users
- MembershipFeesComponent: can_create_cycle/can_destroy_cycle/can_update_cycle; buttons gated
- Delete all cycles via Ash.destroy (policy enforced); format_error Forbidden
- Regenerate cycles for normal_user and admin (no admin-only check)
- Member form: format_error tuple for membership_fee_type_id; Select a membership fee type (no None)
- show_membership_fees_test: read_only UI and policy tests
2026-02-03 23:52:24 +01:00
8ec4a07103 User form: persist role, member linking, Forbidden handling
- User resource: update_user accepts role_id, manage_relationship :member
- user_live/form: touch role_id, params_with_member_if_unchanged to avoid unlink
- Handle Forbidden in form, extract error message for display
- user_policies_test and form_test coverage
2026-02-03 23:52:20 +01:00
5ed41555e9 Member/Setting/validations: domain, actor, and seeds
- setting.ex: domain/authorize for default_membership_fee_type_id check
- validate_same_interval: require membership_fee_type (no None)
- set_membership_fee_start_date: domain/actor for fee type lookup
- Validations: domain/authorize for cross-resource checks
- helpers.ex, email_sync change, seeds.exs actor/authorize fixes
- Update related tests
2026-02-03 23:52:16 +01:00
5889683854 Add resource policies for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle
- Group/MemberGroup/MembershipFeeType/MembershipFeeCycle: HasPermission policy
- normal_user: Group and MembershipFeeCycle create/update/destroy; pages /groups/new, /groups/:slug/edit
- Add policy tests for all four resources
2026-02-03 23:52:12 +01:00
893f9453bd Add PermissionSets for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle
- Extend permission_sets.ex with resources and pages for new domains
- Adjust HasPermission check for resource/action/scope
- Update roles-and-permissions and implementation-plan docs
- Add permission_sets_test.exs coverage
2026-02-03 23:52:09 +01:00
62 changed files with 2883 additions and 1007 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,7 +194,8 @@ 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
@ -1247,7 +1248,8 @@ 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:** Architecture Design - Ready for Implementation **Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md))
--- ---
@ -412,15 +412,17 @@ 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:** `groups` **Resource:** `Group` (and `MemberGroup`)
**Actions:** **Actions:**
- `read` - View groups (all users with member read permission) - `read` - View groups (all permission sets)
- `create` - Create groups (admin only) - `create` - Create groups (normal_user and admin)
- `update` - Edit groups (admin only) - `update` - Edit groups (normal_user and admin)
- `destroy` - Delete groups (admin only) - `destroy` - Delete groups (normal_user and admin)
**Scopes:** **Scopes:**
- `:all` - All groups (for all permission sets that have read access) - `:all` - All groups (for all permission sets that have read access)
@ -442,7 +444,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. Only admins 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. normal_user and admin can manage (create/update/destroy) groups.
### Member-Group Association Permissions ### Member-Group Association Permissions

View file

@ -334,20 +334,18 @@ lib/
### Permission System Integration ### Permission System Integration
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) **Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
**Required Permissions:** **PermissionSets (lib/mv/authorization/permission_sets.ex):**
- `MembershipFeeType.create/update/destroy` - Admin only - **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- `MembershipFeeType.read` - Admin, Treasurer, Board - **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer - **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.read` - Admin, Treasurer, Board, Own member
**Policy Patterns:** **Resource Policies:**
- Use existing HasPermission check - **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- Leverage existing roles (Admin, Kassenwart) - **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
- Member can read own cycles (linked via member_id)
### LiveView Integration ### LiveView Integration
@ -357,7 +355,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 regenerating cycles - Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
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,6 +97,10 @@ 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**
@ -105,6 +109,7 @@ 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**
@ -121,6 +126,8 @@ 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**
@ -684,6 +691,12 @@ 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
@ -1195,6 +1208,36 @@ 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,10 +78,11 @@ 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) - ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
- ✅ Page-level permissions via Phoenix Plug - ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
- ✅ 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,6 +8,9 @@ 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
@ -146,9 +149,10 @@ 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
# This prevents direct foreign key manipulation, forcing use of manage_relationship # Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
accept [:email] # member_id is NOT in accept list - use argument :member for relationship management.
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
@ -387,6 +391,63 @@ 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,7 +36,8 @@ 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
@ -63,6 +64,13 @@ 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)
@ -136,7 +144,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))
|> maybe_exclude_id(exclude_id) |> Helpers.query_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor) opts = Helpers.ash_actor_opts(actor)
@ -155,7 +163,4 @@ 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,7 +39,8 @@ 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
@ -56,6 +57,26 @@ 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)
@ -118,7 +139,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)
|> maybe_exclude_id(exclude_id) |> Helpers.query_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor) opts = Helpers.ash_actor_opts(actor)
@ -135,7 +156,4 @@ 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,12 +155,15 @@ 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
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do # Check existence only; action is already restricted by policy (e.g. admin).
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) calculate_and_set_start_date(changeset, context)
end end
end end
@ -56,10 +56,13 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end end
end end
defp calculate_and_set_start_date(changeset) do defp calculate_and_set_start_date(changeset, context) 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), {:ok, interval} <- get_interval(membership_fee_type_id, opts),
{: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)
@ -118,8 +121,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end end
end end
defp get_interval(membership_fee_type_id) do defp get_interval(membership_fee_type_id, opts) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) 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) validate_interval_match(changeset, context)
else else
changeset changeset
end end
@ -33,9 +33,10 @@ 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) do defp validate_interval_match(changeset, context) 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)
@ -48,13 +49,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) validate_intervals_match(changeset, current_type_id, new_type_id, actor)
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) do defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
case get_intervals(current_type_id, new_type_id) do case get_intervals(current_type_id, new_type_id, actor) 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
@ -85,11 +86,16 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end end
end end
# Get intervals for both types # Get intervals for both types (actor required for authorization when resource has policies)
defp get_intervals(current_type_id, new_type_id) do defp get_intervals(current_type_id, new_type_id, actor) do
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do opts = if actor, do: [actor: actor], else: []
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,7 +28,8 @@ 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"
@ -83,6 +84,19 @@ 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,7 +24,8 @@ 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"
@ -61,6 +62,13 @@ 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))
|> maybe_exclude_id(exclude_member_id) |> Mv.Helpers.query_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,7 +101,4 @@ 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

@ -0,0 +1,44 @@
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,6 +50,7 @@ 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
@ -278,36 +279,28 @@ 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
case {resource_name, record} do result =
{"User", %{id: user_id}} when not is_nil(user_id) -> case {resource_name, record} do
# Check if this user's ID matches the actor's ID (scope :own) # Scope :own
if user_id == actor.id do {"User", %{id: user_id}} when not is_nil(user_id) ->
{:ok, true} user_id == actor.id
else
{:ok, false}
end
{"Member", %{id: member_id}} when not is_nil(member_id) -> # Scope :linked
# Check if this member's ID matches the actor's member_id {"Member", %{id: member_id}} when not is_nil(member_id) ->
if member_id == actor.member_id do member_id == actor.member_id
{: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) ->
# Check if this CFV's member_id matches the actor's member_id cfv_member_id == actor.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) ->
# For other cases or when record is not available, return :unknown mg_member_id == actor.member_id
# This will cause Ash to use auto_filter instead
{:ok, :unknown} _ ->
end :unknown
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")
@ -347,24 +340,20 @@ 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) # User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
# Filter: member.id == actor.member_id linked_filter_by_member_id(actor, :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
# Filter: custom_field_value.member_id == actor.member_id linked_filter_by_member_id(actor, :member_id)
# If actor has no member_id, return no results
if is_nil(actor.member_id) do "MemberGroup" ->
{:filter, expr(false)} # MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
else linked_filter_by_member_id(actor, :member_id)
{:filter, expr(member_id == ^actor.member_id)}
end "MembershipFeeCycle" ->
# 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
@ -372,6 +361,17 @@ 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

@ -0,0 +1,63 @@
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

@ -0,0 +1,62 @@
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,6 +58,27 @@ 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.
@ -94,29 +115,21 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do def get_permissions(:own_data) do
%{ %{
resources: [ resources:
# User: Can read/update own credentials only user_own_credentials() ++
# IMPORTANT: "read_only" refers to member data, NOT user credentials. [
# All permission sets grant User.update :own to allow password changes. perm("Member", :read, :linked),
%{resource: "User", action: :read, scope: :own, granted: true}, perm("Member", :update, :linked),
%{resource: "User", action: :update, scope: :own, granted: true}, perm("CustomFieldValue", :read, :linked),
perm("CustomFieldValue", :update, :linked),
# Member: Can read/update linked member perm("CustomFieldValue", :create, :linked),
%{resource: "Member", action: :read, scope: :linked, granted: true}, perm("CustomFieldValue", :destroy, :linked)
%{resource: "Member", action: :update, scope: :linked, granted: true}, ] ++
custom_field_read_all() ++
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member group_read_all() ++
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true}, [perm("MemberGroup", :read, :linked)] ++
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true}, membership_fee_type_read_all() ++
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true}, [perm("MembershipFeeCycle", :read, :linked)],
%{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
@ -133,25 +146,17 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:read_only) do def get_permissions(:read_only) do
%{ %{
resources: [ resources:
# User: Can read/update own credentials only user_own_credentials() ++
# IMPORTANT: "read_only" refers to member data, NOT user credentials. [
# All permission sets grant User.update :own to allow password changes. perm("Member", :read, :all),
%{resource: "User", action: :read, scope: :own, granted: true}, perm("CustomFieldValue", :read, :all)
%{resource: "User", action: :update, scope: :own, granted: true}, ] ++
custom_field_read_all() ++
# Member: Can read all members, no modifications group_read_all() ++
%{resource: "Member", action: :read, scope: :all, granted: true}, [perm("MemberGroup", :read, :all)] ++
membership_fee_type_read_all() ++
# CustomFieldValue: Can read all custom field values membership_fee_cycle_read_all(),
%{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)
@ -176,31 +181,37 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:normal_user) do def get_permissions(:normal_user) do
%{ %{
resources: [ resources:
# User: Can read/update own credentials only user_own_credentials() ++
# IMPORTANT: "read_only" refers to member data, NOT user credentials. [
# All permission sets grant User.update :own to allow password changes. perm("Member", :read, :all),
%{resource: "User", action: :read, scope: :own, granted: true}, perm("Member", :create, :all),
%{resource: "User", action: :update, scope: :own, granted: true}, perm("Member", :update, :all),
# destroy intentionally omitted for safety
# Member: Full CRUD except destroy (safety) perm("CustomFieldValue", :read, :all),
%{resource: "Member", action: :read, scope: :all, granted: true}, perm("CustomFieldValue", :create, :all),
%{resource: "Member", action: :create, scope: :all, granted: true}, perm("CustomFieldValue", :update, :all),
%{resource: "Member", action: :update, scope: :all, granted: true}, perm("CustomFieldValue", :destroy, :all)
# Note: destroy intentionally omitted for safety ] ++
custom_field_read_all() ++
# CustomFieldValue: Full CRUD [
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, perm("Group", :read, :all),
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true}, perm("Group", :create, :all),
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true}, perm("Group", :update, :all),
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, perm("Group", :destroy, :all)
] ++
# CustomField: Read only (admin manages definitions) [
%{resource: "CustomField", action: :read, scope: :all, granted: true}, perm("MemberGroup", :read, :all),
perm("MemberGroup", :create, :all),
# Group: Can read all perm("MemberGroup", :destroy, :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)
@ -221,52 +232,39 @@ 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:
# User: Full management including other users perm_all("User") ++
%{resource: "User", action: :read, scope: :all, granted: true}, perm_all("Member") ++
%{resource: "User", action: :create, scope: :all, granted: true}, perm_all("CustomFieldValue") ++
%{resource: "User", action: :update, scope: :all, granted: true}, perm_all("CustomField") ++
%{resource: "User", action: :destroy, scope: :all, granted: true}, perm_all("Role") ++
perm_all("Group") ++
# Member: Full CRUD member_group_perms ++
%{resource: "Member", action: :read, scope: :all, granted: true}, perm_all("MembershipFeeType") ++
%{resource: "Member", action: :create, scope: :all, granted: true}, perm_all("MembershipFeeCycle"),
%{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,6 +27,10 @@ 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,6 +5,8 @@ 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.
@ -24,4 +26,22 @@ 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)
|> maybe_exclude_id(exclude_user_id) |> Mv.Helpers.query_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,7 +76,4 @@ 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,6 +545,9 @@ 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"
@ -560,7 +563,13 @@ defmodule MvWeb.CoreComponents do
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th> <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}
@ -646,6 +655,16 @@ 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,7 +20,6 @@ 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>
@ -33,12 +32,4 @@ 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

@ -0,0 +1,58 @@
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,7 +177,8 @@ 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 || ""}
> >
<option value="">{gettext("None")}</option> <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<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}
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
</option> </option>
<% end %> <% end %>
</select> </select>
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %> <%= for error <- List.wrap(@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,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
/> />
</div> </div>
<%!-- Linked User --%> <%!-- Linked User: only show when current user can see other users (e.g. admin).
<div> read_only cannot see linked user, so hide the section to avoid "No user linked" when
<.data_field label={gettext("Linked User")}> a user is linked but not visible. --%>
<%= if @member.user do %> <%= if can_access_page?(@current_user, "/users") do %>
<.link <div>
navigate={~p"/users/#{@member.user}"} <.data_field label={gettext("Linked User")}>
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" <%= if @member.user do %>
> <.link
<.icon name="hero-user" class="size-4" /> navigate={~p"/users/#{@member.user}"}
{@member.user.email} class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
</.link> >
<% else %> <.icon name="hero-user" class="size-4" />
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> {@member.user.email}
<% end %> </.link>
</.data_field> <% else %>
</div> <span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Notes --%> <%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %> <%= if @member.notes && String.trim(@member.notes) != "" do %>
@ -287,6 +291,23 @@ 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,6 +14,7 @@ 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
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <% end %>
</div> </div>
<%!-- Action Buttons --%> <%!-- Action Buttons (only when user has permission) --%>
<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: "")]}
@ -61,7 +63,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)} :if={Enum.any?(@cycles) and @can_destroy_cycle}
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"
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{gettext("Delete All Cycles")} {gettext("Delete All Cycles")}
</.button> </.button>
<.button <.button
:if={@member.membership_fee_type} :if={@member.membership_fee_type != nil and @can_create_cycle}
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"
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
<span <%= if @can_update_cycle do %>
class="font-mono cursor-pointer hover:text-primary" <span
phx-click="edit_cycle_amount" class="font-mono cursor-pointer hover:text-primary"
phx-value-cycle_id={cycle.id} phx-click="edit_cycle_amount"
phx-target={@myself} phx-value-cycle_id={cycle.id}
title={gettext("Click to edit amount")} phx-target={@myself}
> title={gettext("Click to edit amount")}
{MembershipFeeHelpers.format_currency(cycle.amount)} >
</span> {MembershipFeeHelpers.format_currency(cycle.amount)}
</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")}>
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<:action :let={cycle}> <:action :let={cycle}>
<div class="flex gap-1"> <div class="flex gap-1">
<button <%= if @can_update_cycle do %>
:if={cycle.status != :paid} <button
type="button" :if={cycle.status != :paid}
phx-click="mark_cycle_status" type="button"
phx-value-cycle_id={cycle.id} phx-click="mark_cycle_status"
phx-value-status="paid" phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-value-status="paid"
class="btn btn-sm btn-success" phx-target={@myself}
title={gettext("Mark as paid")} class="btn btn-sm btn-success"
> title={gettext("Mark as paid")}
<.icon name="hero-check-circle" class="size-4" /> >
{gettext("Paid")} <.icon name="hero-check-circle" class="size-4" />
</button> {gettext("Paid")}
<button </button>
:if={cycle.status != :suspended} <button
type="button" :if={cycle.status != :suspended}
phx-click="mark_cycle_status" type="button"
phx-value-cycle_id={cycle.id} phx-click="mark_cycle_status"
phx-value-status="suspended" phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-value-status="suspended"
class="btn btn-sm btn-outline btn-warning" phx-target={@myself}
title={gettext("Mark as suspended")} class="btn btn-sm btn-outline btn-warning"
> title={gettext("Mark as suspended")}
<.icon name="hero-pause-circle" class="size-4" /> >
{gettext("Suspended")} <.icon name="hero-pause-circle" class="size-4" />
</button> {gettext("Suspended")}
<button </button>
:if={cycle.status != :unpaid} <button
type="button" :if={cycle.status != :unpaid}
phx-click="mark_cycle_status" type="button"
phx-value-cycle_id={cycle.id} phx-click="mark_cycle_status"
phx-value-status="unpaid" phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-value-status="unpaid"
class="btn btn-sm btn-error" phx-target={@myself}
title={gettext("Mark as unpaid")} class="btn btn-sm btn-error"
> title={gettext("Mark as unpaid")}
<.icon name="hero-x-circle" class="size-4" /> >
{gettext("Unpaid")} <.icon name="hero-x-circle" class="size-4" />
</button> {gettext("Unpaid")}
<button </button>
type="button" <% end %>
phx-click="delete_cycle" <%= if @can_destroy_cycle do %>
phx-value-cycle_id={cycle.id} <button
phx-target={@myself} type="button"
class="btn btn-sm btn-error btn-outline" phx-click="delete_cycle"
title={gettext("Delete cycle")} phx-value-cycle_id={cycle.id}
> phx-target={@myself}
<.icon name="hero-trash" class="size-4" /> class="btn btn-sm btn-error btn-outline"
{gettext("Delete")} title={gettext("Delete cycle")}
</button> >
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<% end %>
</div> </div>
</:action> </:action>
</.table> </.table>
@ -408,11 +418,19 @@ 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)
@ -439,7 +457,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, current_actor(socket)) get_available_fee_types(updated_member, actor)
) )
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))} |> put_flash(:info, gettext("Membership fee type removed"))}
@ -470,13 +488,9 @@ 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!(
@ -502,7 +516,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, current_actor(socket)) get_available_fee_types(updated_member, actor)
) )
|> 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."))}
@ -554,17 +568,15 @@ 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)
# SECURITY: Only admins can manually regenerate cycles via UI if can?(actor, :create, MembershipFeeCycle) do
# 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 =
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
else else
{:noreply, {:noreply,
socket socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))} |> assign(:regenerating, false)
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end end
end end
@ -722,61 +735,31 @@ 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
@ -895,6 +878,55 @@ 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
@ -940,6 +972,10 @@ 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,17 +8,20 @@ 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!() |> Ash.read!(domain: Mv.MembershipFees, actor: actor)
{:ok, {:ok,
socket socket

View file

@ -200,10 +200,12 @@ 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) id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
end end
page_title = page_title =

View file

@ -35,6 +35,8 @@ 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]
@ -49,6 +51,18 @@ 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">
@ -67,6 +81,18 @@ 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")}
@ -300,6 +326,9 @@ 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
@ -307,6 +336,8 @@ 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, [])
@ -357,7 +388,10 @@ 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)
# First save the user without member changes # Include current member in params when not linking/unlinking so update_user's
# 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)
@ -529,6 +563,20 @@ 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)
@ -572,7 +620,8 @@ 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
@ -580,16 +629,25 @@ defmodule MvWeb.UserLive.Form do
form = form =
if user do if user do
# For existing users: admin uses update_user (email + member); non-admin uses update (email only). # For existing users: admin uses update_user (email + member + role_id); 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 -> :update_user can_manage_member_linking or can_assign_role -> :update_user
true -> :update true -> :update
end end
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor) form =
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
@ -668,6 +726,14 @@ 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], actor: actor) |> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
sorted = Enum.sort_by(users, & &1.email) sorted = Enum.sort_by(users, & &1.email)

View file

@ -15,6 +15,8 @@
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}
@ -45,6 +47,7 @@
</:col> </:col>
<:col <:col
:let={user} :let={user}
sort_field={:email}
label={ label={
sort_button(%{ sort_button(%{
field: :email, field: :email,
@ -56,11 +59,28 @@
> >
{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/50">{gettext("No member linked")}</span> <span class="text-base-content/70">{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,8 +55,16 @@ 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 @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} {if MvWeb.Helpers.UserHelpers.has_password?(@user),
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 %>
@ -79,7 +87,9 @@ 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

@ -1,22 +1,22 @@
%{ %{
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"}, "ash": {:hex, :ash, "3.14.1", "22e0ac5dfd4c7d502bd103f0b4380defd66d7c6c83b3a4f54af7045f13da00d7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "776a5963790d5af79855ddca1718a037d06b49063a6b97fae9110050b3d5127d"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"}, "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"}, "ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"},
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"}, "ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"}, "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
@ -28,21 +28,21 @@
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"}, "live_debugger": {:hex, :live_debugger, "0.5.1", "7302a4fda1920ba541b456c2d7a97acc3c7f9d7b938b5435927883b709c968a2", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "797fdca7cc60d7588c6e285b0d7ea73f2dce8b123bac43eae70271fa519bb907"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
@ -57,26 +57,26 @@
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"}, "spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "spitfire": {:hex, :spitfire, "0.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"},
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"}, "swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},

View file

@ -294,6 +294,7 @@ 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"
@ -471,6 +472,7 @@ 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"
@ -958,7 +960,6 @@ 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"
@ -1670,6 +1671,9 @@ 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"
@ -1965,11 +1969,6 @@ 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})"
@ -2302,3 +2301,45 @@ 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,6 +295,7 @@ 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"
@ -472,6 +473,7 @@ 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 ""
@ -959,7 +961,6 @@ 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 ""
@ -1671,6 +1672,9 @@ 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 ""
@ -1966,11 +1970,6 @@ 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})"
@ -2303,3 +2302,45 @@ 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,6 +295,7 @@ 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"
@ -472,6 +473,7 @@ 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 ""
@ -959,7 +961,6 @@ 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 ""
@ -1671,6 +1672,9 @@ 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 ""
@ -1966,11 +1970,6 @@ 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})"
@ -2303,3 +2302,45 @@ 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 # Create example membership fee types (no admin user yet; skip authorization for bootstrap)
for fee_type_attrs <- [ for fee_type_attrs <- [
%{ %{
name: "Standard (Jährlich)", name: "Standard (Jährlich)",
@ -39,7 +39,12 @@ 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!(upsert?: true, upsert_identity: :unique_name) |> Ash.create!(
upsert?: true,
upsert_identity: :unique_name,
authorize?: false,
domain: Mv.MembershipFees
)
end end
for attrs <- [ for attrs <- [
@ -223,7 +228,7 @@ case Accounts.User
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
{:ok, nil} -> {:ok, nil} ->
# User doesn't exist - create admin user with password # User doesn't exist - create admin user and set password (so Password column shows "Enabled")
# 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,
@ -299,12 +304,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 # Load all membership fee types for assignment (admin actor for authorization)
# 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!() |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> 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
@ -452,7 +457,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
end end
end) end)
# Create additional users for user-member linking examples # Create additional users for user-member linking examples (no password by default)
# 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"},
@ -462,15 +468,12 @@ 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,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
# Create a valid fee type # Create a valid fee type
{:ok, fee_type} = {:ok, fee_type} =
Ash.create(MembershipFeeType, %{ Ash.create(
name: "Test Fee Type #{System.unique_integer([:positive])}", MembershipFeeType,
amount: Decimal.new("100.00"), %{
interval: :yearly name: "Test Fee Type #{System.unique_integer([:positive])}",
}) 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(:update_membership_fee_settings, %{ |> Ash.Changeset.for_update(
default_membership_fee_type_id: fee_type.id :update_membership_fee_settings,
}) %{
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
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, %{}, action: :mark_as_unpaid) assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, 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, %{}, action: :mark_as_unpaid) assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
assert updated.status == :unpaid assert updated.status == :unpaid
end end
end end

View file

@ -155,9 +155,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{ |> Ash.Changeset.for_update(
default_membership_fee_type_id: fee_type.id :update_membership_fee_settings,
}) %{
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update!(actor: actor) |> Ash.update!(actor: actor)
# Try to delete # Try to delete
@ -176,9 +180,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{ |> Ash.Changeset.for_update(
default_membership_fee_type_id: fee_type.id :update_membership_fee_settings,
}) %{
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,9 +264,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()
settings settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{ |> Ash.Changeset.for_update(
default_membership_fee_type_id: fee_type.id :update_membership_fee_settings,
}) %{
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,7 +10,6 @@ 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
@ -19,59 +18,10 @@ 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 = create_user_with_permission_set(permission_set, actor) user = Mv.Fixtures.user_with_role_fixture(permission_set)
other_user = create_other_user(actor) other_user = Mv.Fixtures.user_with_role_fixture("own_data")
# Reload user to ensure role is preloaded # Reload user to ensure role is preloaded
{:ok, user} = {:ok, user} =
@ -80,217 +30,101 @@ defmodule Mv.Accounts.UserPoliciesTest do
%{user: user, other_user: other_user} %{user: user, other_user: other_user}
end end
describe "own_data permission set (Mitglied)" do # Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User)
setup %{actor: actor} do describe "non-admin permission sets (own_data, read_only, normal_user)" do
setup_user_with_own_access("own_data", actor) setup %{actor: actor} = context do
permission_set = context[:permission_set] || "own_data"
setup_user_with_own_access(permission_set, actor)
end end
test "can read own user record", %{user: user} do for permission_set <- ["own_data", "read_only", "normal_user"] do
{:ok, fetched_user} = @tag permission_set: permission_set
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts) test "can read own user record (#{permission_set})", %{user: user} do
{: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
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do @tag permission_set: permission_set
assert_raise Ash.Error.Forbidden, fn -> test "can update own email (#{permission_set})", %{user: user} do
other_user new_email = "updated#{System.unique_integer([:positive])}@example.com"
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user) {: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 end
end
test "list users returns only own user", %{user: user} do @tag permission_set: permission_set
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts) test "cannot read other users - not found due to auto_filter (#{permission_set})", %{
user: user,
# Should only return the own user (scope :own filters) other_user: other_user
assert length(users) == 1 } do
assert hd(users).id == user.id assert_raise Ash.Error.Invalid, fn ->
end Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
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
test "cannot destroy user (returns forbidden)", %{user: user} do @tag permission_set: permission_set
assert_raise Ash.Error.Forbidden, fn -> test "cannot update other users - forbidden (#{permission_set})", %{
Ash.destroy!(user, actor: user) 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 end
end
end
describe "read_only permission set (Vorstand/Buchhaltung)" do @tag permission_set: permission_set
setup %{actor: actor} do test "list users returns only own user (#{permission_set})", %{user: user} do
setup_user_with_own_access("read_only", actor) {:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
end
test "can read own user record", %{user: user} do assert length(users) == 1
{:ok, fetched_user} = assert hd(users).id == user.id
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
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do @tag permission_set: permission_set
assert_raise Ash.Error.Forbidden, fn -> test "cannot create user - forbidden (#{permission_set})", %{user: user} do
other_user assert_raise Ash.Error.Forbidden, fn ->
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"}) Accounts.User
|> Ash.update!(actor: user) |> Ash.Changeset.for_create(:create_user, %{
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end end
end
test "list users returns only own user", %{user: user} do @tag permission_set: permission_set
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts) test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
# Should only return the own user (scope :own filters) Ash.destroy!(user, actor: user)
assert length(users) == 1 end
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
test "cannot destroy user (returns forbidden)", %{user: user} do @tag permission_set: permission_set
assert_raise Ash.Error.Forbidden, fn -> test "cannot change role via update_user - forbidden (#{permission_set})", %{
Ash.destroy!(user, actor: user) user: user,
end other_user: other_user
end } do
end other_role = Mv.Fixtures.role_fixture("read_only")
describe "normal_user permission set (Kassenwart)" do assert {:error, %Ash.Error.Forbidden{}} =
setup %{actor: actor} do other_user
setup_user_with_own_access("normal_user", actor) |> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
end |> Ash.update(actor: user, domain: Mv.Accounts)
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 = create_user_with_permission_set("admin", actor) user = Mv.Fixtures.user_with_role_fixture("admin")
other_user = create_other_user(actor) other_user = Mv.Fixtures.user_with_role_fixture("own_data")
# Reload user to ensure role is preloaded # Reload user to ensure role is preloaded
{:ok, user} = {:ok, user} =
@ -343,6 +177,88 @@ 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,6 +496,281 @@ 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,67 +8,30 @@ 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_role_with_permission_set(permission_set_name, actor) do defp create_custom_field do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}" admin = Mv.Fixtures.user_with_role_fixture("admin")
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: actor, domain: Mv.Membership) |> Ash.create(actor: admin, 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(actor) custom_field = create_custom_field()
user = create_user_with_permission_set("own_data", actor) user = Mv.Fixtures.user_with_role_fixture("own_data")
{: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)
@ -78,9 +41,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(actor) custom_field = create_custom_field()
user = create_user_with_permission_set("read_only", actor) user = Mv.Fixtures.user_with_role_fixture("read_only")
{: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)
@ -90,9 +53,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(actor) custom_field = create_custom_field()
user = create_user_with_permission_set("normal_user", actor) user = Mv.Fixtures.user_with_role_fixture("normal_user")
{: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)
@ -102,9 +65,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(actor) custom_field = create_custom_field()
user = create_user_with_permission_set("admin", actor) user = Mv.Fixtures.user_with_role_fixture("admin")
{: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)
@ -116,9 +79,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 = create_user_with_permission_set("normal_user", actor) user = Mv.Fixtures.user_with_role_fixture("normal_user")
custom_field = create_custom_field(actor) custom_field = create_custom_field()
%{user: user, custom_field: custom_field} %{user: user, custom_field: custom_field}
end end
@ -152,9 +115,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 = create_user_with_permission_set("admin", actor) user = Mv.Fixtures.user_with_role_fixture("admin")
custom_field = create_custom_field(actor) custom_field = create_custom_field()
%{user: user, custom_field: custom_field} %{user: user, custom_field: custom_field}
end end

View file

@ -11,7 +11,6 @@ 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
@ -20,47 +19,9 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a role with a specific permission set 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
# 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(
%{ %{
@ -68,18 +29,20 @@ 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: actor actor: admin
) )
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: actor, domain: Mv.Accounts, return_notifications?: false) |> Ash.update(actor: admin, 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(
%{ %{
@ -87,25 +50,29 @@ 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: actor actor: admin
) )
member member
end end
defp create_custom_field(actor) do defp create_custom_field 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: actor) |> Ash.create(actor: admin, domain: Mv.Membership)
field field
end end
defp create_custom_field_value(member_id, custom_field_id, value, actor) do defp create_custom_field_value(member_id, custom_field_id, value) 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, %{
@ -113,22 +80,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: actor, domain: Mv.Membership) |> Ash.create(actor: admin, 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 = create_user_with_permission_set("own_data", actor) user = Mv.Fixtures.user_with_role_fixture("own_data")
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(actor) custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{: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)
@ -177,10 +144,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(actor) custom_field2 = create_custom_field()
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue
@ -257,15 +224,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 = create_user_with_permission_set("read_only", actor) user = Mv.Fixtures.user_with_role_fixture("read_only")
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(actor) custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{: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)
@ -340,15 +307,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 = create_user_with_permission_set("normal_user", actor) user = Mv.Fixtures.user_with_role_fixture("normal_user")
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(actor) custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{: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)
@ -379,10 +346,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(actor) custom_field = create_custom_field()
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue
@ -421,15 +388,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 = create_user_with_permission_set("admin", actor) user = Mv.Fixtures.user_with_role_fixture("admin")
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(actor) custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked = cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{: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)
@ -457,7 +424,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(user) custom_field = create_custom_field()
{:ok, cfv} = {:ok, cfv} =
CustomFieldValue CustomFieldValue

View file

@ -0,0 +1,140 @@
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,7 +8,6 @@ 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
@ -17,49 +16,8 @@ defmodule Mv.Membership.MemberEmailValidationTest do
%{actor: system_actor} %{actor: system_actor}
end end
defp create_role_with_permission_set(permission_set_name, actor) do defp create_linked_member_for_user(user, _actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}" admin = Mv.Fixtures.user_with_role_fixture("admin")
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(
@ -79,8 +37,8 @@ defmodule Mv.Membership.MemberEmailValidationTest do
member member
end end
defp create_unlinked_member(actor) do defp create_unlinked_member(_actor) do
admin = create_admin_user(actor) admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} = {:ok, member} =
Membership.create_member( Membership.create_member(
@ -97,7 +55,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 = create_user_with_permission_set("normal_user", actor) normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
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"
@ -109,7 +67,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 = create_user_with_permission_set("normal_user", actor) normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
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"
@ -121,10 +79,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 = create_user_with_permission_set("own_data", actor) user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = create_user_with_permission_set("normal_user", actor) normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
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} =
@ -135,9 +93,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 = create_user_with_permission_set("own_data", actor) user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
admin = create_admin_user(actor) admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com" new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
@ -150,7 +108,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 = create_user_with_permission_set("own_data", actor) own_data_user = Mv.Fixtures.user_with_role_fixture("own_data")
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} =
@ -168,7 +126,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 = create_user_with_permission_set("normal_user", actor) normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
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} =
@ -188,9 +146,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 = create_user_with_permission_set("own_data", actor) user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = create_user_with_permission_set("normal_user", actor) normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
assert {:ok, updated} = assert {:ok, updated} =
Membership.update_member(linked_member, %{first_name: "UpdatedName"}, Membership.update_member(linked_member, %{first_name: "UpdatedName"},
@ -202,9 +160,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 = create_user_with_permission_set("own_data", actor) user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor) linked_member = create_linked_member_for_user(user_a, actor)
admin = create_admin_user(actor) admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com" new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
@ -217,7 +175,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 = create_user_with_permission_set("read_only", actor) read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
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

@ -0,0 +1,234 @@
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,7 +12,6 @@ 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
@ -21,58 +20,9 @@ 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 = create_admin_user(actor) admin = Mv.Fixtures.user_with_role_fixture("admin")
# 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
@ -105,8 +55,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 = create_admin_user(actor) admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} = {:ok, member} =
Membership.create_member( Membership.create_member(
@ -123,7 +73,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 = create_user_with_permission_set("own_data", actor) user = Mv.Fixtures.user_with_role_fixture("own_data")
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)
@ -207,7 +157,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 = create_user_with_permission_set("read_only", actor) user = Mv.Fixtures.user_with_role_fixture("read_only")
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)
@ -273,7 +223,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 = create_user_with_permission_set("normal_user", actor) user = Mv.Fixtures.user_with_role_fixture("normal_user")
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)
@ -330,7 +280,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 = create_user_with_permission_set("admin", actor) user = Mv.Fixtures.user_with_role_fixture("admin")
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)
@ -397,7 +347,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 = create_user_with_permission_set("read_only", actor) user = Mv.Fixtures.user_with_role_fixture("read_only")
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
@ -416,7 +366,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 = create_user_with_permission_set("own_data", actor) user = Mv.Fixtures.user_with_role_fixture("own_data")
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
@ -437,7 +387,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 = create_user_with_permission_set("own_data", actor) user = Mv.Fixtures.user_with_role_fixture("own_data")
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

@ -0,0 +1,294 @@
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

@ -0,0 +1,260 @@
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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
# 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
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} do test "creates new membership fee type", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, "/membership_fee_types/new") {:ok, view, _html} = live(conn, "/membership_fee_types/new")
form_data = %{ form_data = %{
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert to == "/membership_fee_types" assert to == "/membership_fee_types"
# Verify type was created # Verify type was created (use actor so read is authorized)
type = type =
MembershipFeeType MembershipFeeType
|> Ash.Query.filter(name == "New Type") |> Ash.Query.filter(name == "New Type")
|> Ash.read_one!() |> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
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
@ -140,7 +141,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} do test "amount change can be confirmed", %{conn: conn, user: user} 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")
@ -159,12 +160,17 @@ 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 # Amount should be updated (use actor so read is authorized)
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id)) updated_type =
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} do test "amount change can be cancelled", %{conn: conn, user: user} 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")
@ -178,8 +184,13 @@ 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 # Amount should remain unchanged (use actor so read is authorized)
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id)) updated_type =
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,6 +61,7 @@ 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,10 +127,12 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
# 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
@ -183,8 +185,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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, true) status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
@ -222,8 +224,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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, false) status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
@ -273,12 +275,14 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
@ -300,12 +304,14 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
@ -327,12 +333,14 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
@ -354,12 +362,14 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
@ -373,12 +383,14 @@ 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]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
# filter_unpaid_members should still work for backwards compatibility # filter_unpaid_members should still work for backwards compatibility

View file

@ -28,21 +28,6 @@ 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()
@ -73,7 +58,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 = create_member(%{membership_fee_type_id: fee_type.id}) member = Mv.Fixtures.member_fixture(%{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})
@ -95,7 +80,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 = create_member(%{membership_fee_type_id: fee_type.id}) member = Mv.Fixtures.member_fixture(%{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],
@ -124,7 +109,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 = create_member(%{membership_fee_type_id: yearly_type.id}) member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}") {:ok, _view, html} = live(conn, "/members/#{member.id}")
@ -132,20 +117,30 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
assert html =~ "Yearly Type" assert html =~ "Yearly Type"
end end
test "shows no type message when no type assigned", %{conn: conn} do test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{
member = create_member(%{}) conn: conn
} 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 = create_member(%{membership_fee_type_id: fee_type.id}) 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}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
@ -176,7 +171,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 = create_member(%{membership_fee_type_id: fee_type.id}) 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}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
@ -207,7 +202,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 = create_member(%{membership_fee_type_id: fee_type.id}) 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: :paid}) cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
@ -240,7 +235,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 = create_member(%{membership_fee_type_id: fee_type.id}) member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}") {:ok, view, _html} = live(conn, "/members/#{member.id}")
@ -266,7 +261,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 = create_member(%{}) member = Mv.Fixtures.member_fixture(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}") {:ok, _view, html} = live(conn, "/members/#{member.id}")
@ -274,4 +269,120 @@ 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,6 +742,18 @@ 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")
@ -830,22 +842,6 @@ 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,6 +213,35 @@ 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,7 +55,6 @@ 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)
@ -76,7 +75,6 @@ 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)
@ -107,7 +105,6 @@ 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)
@ -379,6 +376,45 @@ 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