Compare commits

...
Sign in to create a new pull request.

85 commits

Author SHA1 Message Date
cc02748cc6 Merge pull request 'Fix prod admin initialisation' (#410) from fix/admin_init into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #410
2026-02-04 21:41:38 +01:00
ad54b0c462 Release.seed_admin: ensure app started when run via bin/mv eval
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
Application.ensure_all_started(:mv) so Ash/Telemetry work (ETS table exists).
Fixes Unknown Error / telemetry_handler_table in production entrypoint.
2026-02-04 21:33:41 +01:00
6ab0365a8c Merge pull request 'Init an admin user in prod closes #381' (#409) from feature/381_init_admin into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #409
2026-02-04 20:53:00 +01:00
ad42a53919 OIDC sign-in: robust after_action for get? result, non-bang role sync
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
- sign_in_with_rauthy after_action normalizes result (nil/struct/list) to list before Enum.each.
- OidcRoleSync.do_set_role uses Ash.update and swallows errors so auth is not blocked; skip update if role already correct.
2026-02-04 20:25:54 +01:00
c5f1fdce0a Code-review follow-ups: policy, docs, seed_admin behaviour
All checks were successful
continuous-integration/drone/push Build is passing
- Use OidcRoleSyncContext for set_role_from_oidc_sync; document JWT peek risk.
- seed_admin without password sets Admin role on existing user (OIDC-only); update docs and test.
- Fix DE translation for 'access this page'; add get? true comment in User.
2026-02-04 19:44:43 +01:00
d573a22769 Tests: accept single user or list from read_sign_in_with_rauthy (get? true)
All checks were successful
continuous-integration/drone/push Build is passing
Handle {:ok, user}, {:ok, nil} in addition to {:ok, [user]}, {:ok, []}.
2026-02-04 18:13:30 +01:00
58a5b086ad OIDC: pass oauth_tokens to role sync; get? true for sign_in; return record in register
- sign_in_with_rauthy: get? true so Ash returns single user; pass oauth_tokens to OidcRoleSync.
- register_with_rauthy: pass oauth_tokens to OidcRoleSync; return {:ok, record} to preserve token.
2026-02-04 18:13:30 +01:00
d441009c8a Refactor: remove debug instrumentation from OidcRoleSync
Drop temporary logging used to diagnose OIDC groups sync in dev.
2026-02-04 18:13:30 +01:00
d37fc03a37 Fix: load OIDC role sync config from ENV in all environments
OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM were only set in prod block;
in dev admin_group was nil so role sync never ran. Move config outside
prod block so dev/test get ENV values.
2026-02-04 18:13:30 +01:00
55fef5a993 Docs and .env.example for admin bootstrap and OIDC role sync
Documents ADMIN_EMAIL/PASSWORD, seed_admin, entrypoint; OIDC_ADMIN_GROUP_NAME,
OIDC_GROUPS_CLAIM and role sync on register/sign-in.
2026-02-04 18:13:30 +01:00
99722dee26 Add OidcRoleSync: apply Admin/Mitglied from OIDC groups
Register and sign-in call apply_admin_role_from_user_info; users in configured
admin group get Admin role, others get Mitglied. Internal User action + bypass policy.
2026-02-04 18:13:30 +01:00
a6e35da0f7 Add OIDC role sync config (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM)
Mv.OidcRoleSyncConfig reads from config; runtime.exs overrides from ENV in prod.
2026-02-04 18:13:30 +01:00
50c8a0dc9a Seeds: call Mv.Release.seed_admin to avoid duplication
Replaces inline admin creation with seed_admin(); exercises same path as entrypoint.
Dev/test: set ADMIN_EMAIL default and ADMIN_PASSWORD fallback before calling.
2026-02-04 18:13:30 +01:00
e065b39ed4 Add Mv.Release.seed_admin for admin bootstrap from ENV
Creates/updates admin user from ADMIN_EMAIL and ADMIN_PASSWORD or ADMIN_PASSWORD_FILE.
Idempotent; no fallback password in production. Called from docker entrypoint and seeds.
2026-02-04 18:13:30 +01:00
b177e41882 Add Role.get_admin_role for Release.seed_admin
Used by Mv.Release to resolve Admin role when creating/updating admin user from ENV.
2026-02-04 18:13:30 +01:00
09a4b7c937 Seeds: use ADMIN_PASSWORD/ADMIN_PASSWORD_FILE; fallback only in dev/test
No fallback in production; prod uses Release.seed_admin in entrypoint.
2026-02-04 18:13:30 +01:00
7a56a0920b Call seed_admin in docker entrypoint after migrate
Ensures admin user is created/updated from ENV on every container start.
2026-02-04 18:13:30 +01:00
59d94cf1c6 Merge pull request 'Polishs import UI closes #337' (#398) from feature/337_polish_import into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #398
2026-02-04 16:50:43 +01:00
361331b76e fix linting errors
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-04 16:36:13 +01:00
3415faeb21 Merge branch 'main' into feature/337_polish_import
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 16:28:55 +01:00
82b3182267 Merge pull request 'Permission system hardening: Role policies and member user-link restriction closes #406' (#407) from feature/406_permission_hardening into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #407
2026-02-04 14:52:49 +01:00
95472424b1
Fix member unlink: use User update_user action
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
UnrelateUserWhenArgumentNil used User :update which only accepts :email.
Switch to :update_user with member: nil so manage_relationship clears member_id.
2026-02-04 14:46:23 +01:00
5194b20b5c
Fix unlink-by-omission: on_missing :ignore, test, doc, string-key
Some checks failed
continuous-integration/drone/push Build is failing
- Member update_member: on_missing :unrelate → :ignore (no unlink when :user omitted)
- Test: normal_user update linked member without :user keeps link
- Doc: unlink only explicit (user: nil), admin-only; Actor.admin?(nil) note
- Check: defense-in-depth for "user" string key
2026-02-04 14:07:39 +01:00
543fded102
Harden member user-link check: argument presence, nil actor, policy scope
- Forbid on :user argument presence (not value) to block unlink via nil/empty
- Defensive nil actor handling; policy restricted to create/update only
- Test: Ash.load with actor; test non-admin cannot unlink via user: nil
- Docs: unlink behaviour and policy split
2026-02-04 14:07:39 +01:00
34e049ef32
Refactor member user-link tests: shared setup
Use describe-level setup for normal_user, admin, unlinked_member.
2026-02-04 14:07:39 +01:00
54e419ed4c
Docs: permission hardening Role and member user link
Role: Ash policies (HasPermission); read for all, create/update/destroy admin only.
User–member link: only admins may set :user on Member create/update (ForbidMemberUserLinkUnlessAdmin).
2026-02-04 14:07:39 +01:00
26fbafdd9d
Restrict member user link to admins (forbid policy)
Add ForbidMemberUserLinkUnlessAdmin check; forbid_if on Member create/update.
Fix member user-link tests: pass :user in params, assert via reload.
2026-02-04 14:07:38 +01:00
4d3a64c177
Add Role resource policies (defense-in-depth)
- PermissionSets: Role read :all for own_data, read_only, normal_user; admin keeps full CRUD
- Role resource: authorizers and policies with HasPermission
- Tests: role_policies_test.exs (read all, create/update/destroy admin only)
- Fix existing tests to pass actor or authorize?: false for Role operations
2026-02-04 14:07:38 +01:00
10f37a1246 Merge pull request 'Update Mix dependencies' (#392) from renovate/mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #392
2026-02-04 14:06:30 +01:00
40e75f4066 refactor: reduce nesting in HasPermission.strict_check_with_permissions
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
Extract strict_check_filter_scope/4 to satisfy Credo max depth 2.
2026-02-04 13:29:41 +01:00
f7ba98c36b
refactor: reduce nesting in SyncUserEmailToMember.sync_email
Some checks failed
continuous-integration/drone/push Build is failing
Extract apply_sync/1 and sync_by_record_type/4 to satisfy Credo max depth 2.
2026-02-04 13:03:36 +01:00
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
36b7031dca Merge pull request 'chore(deps): update renovate/renovate docker tag to v42.95' (#393) from renovate/renovate-renovate-42.x into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #393
2026-02-03 19:52:08 +01:00
Renovate Bot
fa5afba6ba chore(deps): update renovate/renovate docker tag to v42.95
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:51:42 +01:00
0c313824fb Merge pull request 'chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2' (#391) from renovate/ghcr.io-sebadob-rauthy-0.x into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #391
2026-02-03 19:51:09 +01:00
Renovate Bot
f45ae66f18 chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:49:48 +01:00
c2bafe4acf Merge pull request 'Apply UI Authorization to Existing LiveViews closes #400' (#403) from feature/400_ui_authorization into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #403
2026-02-03 17:30:15 +01:00
cbc9376b7b Tests: data-testid selectors, scoped delete, sidebar testid
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
Member/User auth tests use data-testid and #row-id selectors.
Sidebar auth tests assert on data-testid=sidebar-administration.
Sidebar test expects data-testid in expanded-menu-group markup.
2026-02-03 17:16:15 +01:00
ee6bfbacbb User LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:13 +01:00
a4b13cef49 Member LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:11 +01:00
286972964d CoreComponents: allow data-testid on button
Include data-testid in button rest for test selectors.
2026-02-03 17:16:10 +01:00
c36812bf3f Authorization: document can_access_page? nil-safety
Doc and example for nil user returning false.
2026-02-03 17:16:09 +01:00
2ddd22078d Sidebar: use PagePaths, add testid for Administration
Gate menu items via PagePaths; add data-testid=sidebar-administration
for stable tests. menu_group accepts optional testid attr.
2026-02-03 17:16:08 +01:00
9e8910344e Add MvWeb.PagePaths for central sidebar/page paths
Single source for path strings used by Sidebar and can_access_page?.
Keep in sync with router when routes change.
2026-02-03 17:16:07 +01:00
1426ef1d38
Add sidebar authorization tests
All checks were successful
continuous-integration/drone/push Build is passing
Assert menu visibility per role: admin, read_only, normal_user,
own_data, nil user, user without role.
2026-02-03 16:56:52 +01:00
f779fd61e0
Gate sidebar menu items by can_access_page?
Members, Fee Types and Administration subitems only shown when user
has page permission. Add admin_menu_visible? helper. Sidebar test
uses admin user so menu items render.
2026-02-03 16:56:52 +01:00
cc9e530d80
Add User LiveView authorization tests
Covers admin, read_only, member, normal_user for Index and Show.
Asserts New User / Edit / Delete visibility and redirect for non-admin.
2026-02-03 16:56:51 +01:00
2f67c7099d
Apply UI authorization to User LiveViews (Index and Show)
Gate New User button, Edit and Delete links with can?/3.
Edit button on User Show visible only when user can update the user.
2026-02-03 16:56:51 +01:00
5e361ba400
Add Member LiveView authorization tests
Covers read_only, normal_user, admin, own_data for Index and Show.
Asserts New Member / Edit / Delete visibility and redirect for Mitglied.
2026-02-03 16:56:51 +01:00
505e31653a
Apply UI authorization to Member LiveViews (Index and Show)
Gate New Member button, Edit and Delete links with can?/3.
Edit button on Member Show visible only when user can update the member.
2026-02-03 16:56:51 +01:00
d3ad7c5013 Merge pull request 'Member Email Validation for Linked Members closes #397' (#399) from feature/397_emailsync_permission into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #399
2026-02-03 16:35:40 +01:00
131904f172
Test: assert on error field :email instead of message string
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-02-03 16:07:47 +01:00
47b6a16177
Doc: Actor maybe_load_role comment; ActorIsAdmin system user = admin 2026-02-03 16:07:39 +01:00
60a4181255
Validation: error message admin or linked user; resolve_actor fallback 2026-02-03 16:07:26 +01:00
4e6b7305b6
Doc: Loader auth-independent for link checks; email-sync rule rationale 2026-02-03 16:07:13 +01:00
4ea31f0f37 Add email-change permission validation for linked members
All checks were successful
continuous-integration/drone/push Build is passing
Only admins or the linked user may change a linked member's email.
- New validation EmailChangePermission (uses Actor.admin?, Loader.get_linked_user).
- Register on Member update_member; docs and gettext.
2026-02-03 14:35:32 +01:00
ad02f8914f Use EmailSync.Loader.get_linked_user in EmailNotUsedByOtherUser
Remove duplicate get_linked_user_id; reuse Loader for linked user lookup.
2026-02-03 14:35:08 +01:00
3d46ba655f Add Actor.permission_set_name/1 and admin?/1 for consistent capability checks
- Actor.permission_set_name(actor) returns role's permission set (supports nil role load).
- Actor.admin?(actor) returns true for system user or admin permission set.
- ActorIsAdmin policy check delegates to Actor.admin?/1.
2026-02-03 14:34:24 +01:00
102 changed files with 5423 additions and 1231 deletions

View file

@ -273,7 +273,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:42.81
image: renovate/renovate:42.95
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -11,9 +11,22 @@ PHX_HOST=localhost
# Recommended: Association settings
ASSOCIATION_NAME="Sportsclub XYZ"
# Optional: Admin user (created/updated on container start via Release.seed_admin)
# In production, set these so the first admin can log in. Change password without redeploy:
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
# ADMIN_EMAIL=admin@example.com
# ADMIN_PASSWORD=secure-password
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
# Optional: OIDC Configuration
# These have defaults in docker-compose.prod.yml, only override if needed
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
# OIDC_ADMIN_GROUP_NAME=admin
# OIDC_GROUPS_CLAIM=groups

View file

@ -81,8 +81,8 @@ lib/
├── membership/ # Membership domain
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue type resource
│ ├── setting.ex # Global settings (singleton resource)
│ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource
@ -198,7 +198,8 @@ test/
├── seeds_test.exs # Database seed tests
└── support/ # 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
@ -1339,7 +1340,8 @@ test/
│ └── components/
└── support/ # Test helpers
├── 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:**

View file

@ -58,6 +58,11 @@ config :mv,
max_rows: 1000
]
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
config :mv, :oidc_role_sync,
admin_group_name: nil,
groups_claim: "groups"
# Configures the endpoint
config :mv, MvWeb.Endpoint,
url: [host: "localhost"],

View file

@ -89,6 +89,11 @@ if System.get_env("PHX_SERVER") do
config :mv, MvWeb.Endpoint, server: true
end
# OIDC group → Admin role sync: read from ENV in all environments (dev/test/prod)
config :mv, :oidc_role_sync,
admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"),
groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups"
if config_env() == :prod do
database_url = build_database_url.()

View file

@ -25,7 +25,7 @@ services:
rauthy:
container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.33.4
image: ghcr.io/sebadob/rauthy:0.34.2
environment:
- LOCAL_TEST=true
- SMTP_URL=mailcrab

View file

@ -0,0 +1,54 @@
# Admin Bootstrap and OIDC Role Sync
## Overview
- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before server). Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
## Admin Bootstrap (Part A)
### Environment Variables
- `ADMIN_EMAIL` Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
- `ADMIN_PASSWORD` Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
- `ADMIN_PASSWORD_FILE` Path to a file containing the password (e.g. Docker secret).
### Release Task
- `Mv.Release.seed_admin/0` Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
### Entrypoint
- rel/overlays/bin/docker-entrypoint.sh After migrate, runs seed_admin(), then starts the server.
### Seeds (Dev/Test)
- priv/repo/seeds.exs Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test.
## OIDC Role Sync (Part B)
### Configuration
- `OIDC_ADMIN_GROUP_NAME` OIDC group name that maps to the Admin role. If unset, no role sync.
- `OIDC_GROUPS_CLAIM` JWT claim name for group list (default "groups").
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
### Sync Logic
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
### Where It Runs
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
### Internal Action
- User.set_role_from_oidc_sync Internal update (role_id only). Used by OidcRoleSync; not exposed.
## See Also
- .env.example Admin and OIDC group env vars.
- lib/mv/release.ex seed_admin/0.
- lib/mv/oidc_role_sync.ex Sync implementation.
- docs/oidc-account-linking.md OIDC account linking.

View file

@ -4,6 +4,7 @@
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
5. **Linked member email change** - When a member is linked, only administrators or the linked user may change that member's email (Member resource validation `EmailChangePermission`). Because User.email wins on link and changes sync Member → User, allowing anyone to change a linked member's email would overwrite that user's account email; this rule keeps sync under control.
---

View file

@ -4,7 +4,7 @@
**Feature:** Groups Management
**Version:** 1.0
**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
**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)
**Resource:** `groups`
**Resource:** `Group` (and `MemberGroup`)
**Actions:**
- `read` - View groups (all users with member read permission)
- `create` - Create groups (admin only)
- `update` - Edit groups (admin only)
- `destroy` - Delete groups (admin only)
- `read` - View groups (all permission sets)
- `create` - Create groups (normal_user and admin)
- `update` - Edit groups (normal_user and admin)
- `destroy` - Delete groups (normal_user and admin)
**Scopes:**
- `:all` - All groups (for all permission sets that have read access)
@ -442,7 +444,7 @@ lib/
**Own Data Permission Set:**
- `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

View file

@ -334,20 +334,18 @@ lib/
### 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.read` - Admin, Treasurer, Board
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- **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.
**Policy Patterns:**
**Resource Policies:**
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own cycles (linked via member_id)
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
### LiveView Integration
@ -357,7 +355,7 @@ lib/
2. MembershipFeeCycle table component (member detail view)
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
- 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)
4. Member list column (membership fee status)

View file

@ -97,6 +97,10 @@ Control CRUD operations on:
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- 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**
@ -105,6 +109,7 @@ Control access to LiveView pages:
- Show pages (detail views)
- Form pages (create/edit)
- Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
**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
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
- **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**
@ -684,6 +691,12 @@ Quick reference table showing what each permission set allows:
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | 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
@ -1012,16 +1025,21 @@ defmodule Mv.Membership.Member do
authorize_if expr(id == ^actor(:member_id))
end
# 2. GENERAL: Check permissions from role
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
# - :read_only → can READ all members (scope :all), no update permission
# - :normal_user → can CRUD all members (scope :all)
# - :admin → can CRUD all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
# 2. READ/DESTROY: Check permissions only (no :user argument on these actions)
policy action_type([:read, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
# 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all
policy action_type([:create, :update]) do
description "Forbid user link unless admin; then check permissions"
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
authorize_if Mv.Authorization.Checks.HasPermission
end
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
@ -1041,6 +1059,8 @@ end
- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
**Usermember link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)).
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
@ -1135,23 +1155,20 @@ end
**Location:** `lib/mv/authorization/role.ex`
**Special Protection:** System roles cannot be deleted.
**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary.
**Special Protection:** System roles cannot be deleted (validation on destroy).
```elixir
defmodule Mv.Authorization.Role do
use Ash.Resource, ...
use Ash.Resource,
authorizers: [Ash.Policy.Authorizer]
policies do
# Only admin can manage roles
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
description "Check permissions from user's role (read all, create/update/destroy admin only)"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid
policy action_type([:read, :create, :update, :destroy]) do
forbid_if always()
end
end
# Prevent deletion of system roles
@ -1188,13 +1205,43 @@ end
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read | ❌ | ❌ | ❌ | ❌ | ✅ |
| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
*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
@ -2002,7 +2049,10 @@ Users and Members are separate entities that can be linked. Special rules:
- A user cannot link themselves to an existing member
- A user CAN create a new member and be directly linked to it (self-service)
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
**Enforcement:**
- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
- **Member side:** Only admins may set or change the usermember link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only.
### Approach: Separate Ash Actions

View file

@ -78,10 +78,11 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
- ✅ Hardcoded PermissionSets module with 4 permission sets
- ✅ Role database table and CRUD interface
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
- ✅ Page-level permissions via Phoenix Plug
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
- ✅ UI authorization helpers for conditional rendering
- ✅ 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
**Benefits of Hardcoded Approach:**

View file

@ -8,6 +8,9 @@ defmodule Mv.Accounts.User do
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr
postgres do
table "users"
repo Mv.Repo
@ -146,9 +149,10 @@ defmodule Mv.Accounts.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."
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
# 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
argument :member, :map, allow_nil?: true
@ -183,6 +187,13 @@ defmodule Mv.Accounts.User do
require_atomic? false
end
# Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync.
# Same "at least one admin" validation as update_user (see validations where action_is).
update :set_role_from_oidc_sync do
accept [:role_id]
require_atomic? false
end
# Admin action for direct password changes in admin panel
# Uses the official Ash Authentication HashPasswordChange with correct context
update :admin_set_password do
@ -247,6 +258,8 @@ defmodule Mv.Accounts.User do
end
read :sign_in_with_rauthy do
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
get? true
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
@ -256,6 +269,27 @@ defmodule Mv.Accounts.User do
# linked their account via OIDC. Password-only users (oidc_id = nil)
# cannot be accessed via OIDC login without password verification.
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
user_info = Ash.Query.get_argument(query, :user_info) || %{}
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
users =
case result do
nil -> []
u when is_struct(u, User) -> [u]
list when is_list(list) -> list
_ -> []
end
Enum.each(users, fn user ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
end)
{:ok, result}
end)
end
create :register_with_rauthy do
@ -293,6 +327,18 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{}
Ash.Changeset.after_action(changeset, fn _cs, record ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens)
# Return original record so __metadata__.token (from GenerateTokenChange) is preserved
{:ok, record}
end)
end
end
end
@ -319,6 +365,13 @@ defmodule Mv.Accounts.User do
authorize_if Mv.Authorization.Checks.ActorIsAdmin
end
# set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
# Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set.
bypass action(:set_role_from_oidc_sync) do
description "Internal: OIDC role sync (server-side only)"
authorize_if Mv.Authorization.Checks.OidcRoleSyncContext
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
@ -387,6 +440,63 @@ defmodule Mv.Accounts.User do
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, :set_role_from_oidc_sync])]
# 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.
validate fn changeset, _context ->

View file

@ -36,7 +36,8 @@ defmodule Mv.Membership.Group do
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
alias Mv.Helpers
@ -63,6 +64,13 @@ defmodule Mv.Membership.Group do
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
validate present(:name)
@ -136,7 +144,7 @@ defmodule Mv.Membership.Group do
query =
Mv.Membership.Group
|> 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)
@ -155,7 +163,4 @@ defmodule Mv.Membership.Group do
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -25,6 +25,7 @@ defmodule Mv.Membership.Member do
- Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically
@ -152,16 +153,18 @@ defmodule Mv.Membership.Member do
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
# When :user argument is present and nil/empty, unrelate (admin-only via policy).
# Must run before manage_relationship; on_missing: :ignore then does nothing for nil input.
change Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil
# Manage the user relationship during member update
# on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may
# change the link; unlink is explicit via user: nil, forbidden for non-admins by policy).
change manage_relationship(:user, :user,
# Look up existing user and relate to it
on_lookup: :relate,
# Error if user doesn't exist in database
on_no_match: :error,
# Error if user is already linked to another member (prevents "stealing")
on_match: :error,
# If no user provided, remove existing relationship (allows user removal)
on_missing: :unrelate
on_missing: :ignore
)
# Sync member email to user when email changes (Member → User)
@ -311,14 +314,18 @@ defmodule Mv.Membership.Member do
authorize_if expr(id == ^actor(:member_id))
end
# GENERAL: Check permissions from user's role
# HasPermission handles update permissions correctly:
# - :own_data → can update linked member (scope :linked)
# - :read_only → cannot update any member (no update permission)
# - :normal_user → can update all members (scope :all)
# - :admin → can update all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
# READ/DESTROY: Check permissions only (no :user argument on these actions)
policy action_type([:read, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
# CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
policy action_type([:create, :update]) do
description "Forbid user link unless admin; then check permissions"
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
authorize_if Mv.Authorization.Checks.HasPermission
end
@ -381,6 +388,9 @@ defmodule Mv.Membership.Member do
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Only admins or the linked user may change a linked member's email (prevents breaking sync)
validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
# Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member

View file

@ -0,0 +1,50 @@
defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
@moduledoc """
When :user argument is present and nil/empty on update_member, unrelate the current user.
With on_missing: :ignore, manage_relationship does not unrelate when input is nil/[].
This change handles explicit unlink (user: nil or user: %{}) by updating the linked
User to set member_id = nil. Only runs when the argument key is present (policy
ForbidMemberUserLinkUnlessAdmin ensures only admins can pass :user).
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
if unlink_requested?(changeset) do
unrelate_current_user(changeset)
else
changeset
end
end
defp unlink_requested?(changeset) do
args = changeset.arguments || %{}
if Map.has_key?(args, :user) or Map.has_key?(args, "user") do
user_arg = Ash.Changeset.get_argument(changeset, :user)
user_arg == nil or (is_map(user_arg) and map_size(user_arg) == 0)
else
false
end
end
defp unrelate_current_user(changeset) do
member = changeset.data
actor = Map.get(changeset.context || %{}, :actor)
case Ash.load(member, :user, domain: Mv.Membership, authorize?: false) do
{:ok, %{user: user}} when not is_nil(user) ->
# User's :update action only accepts [:email]; use :update_user so
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
user
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
changeset
_ ->
changeset
end
end
end

View file

@ -39,7 +39,8 @@ defmodule Mv.Membership.MemberGroup do
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
@ -56,6 +57,26 @@ defmodule Mv.Membership.MemberGroup do
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
validate present(:member_id)
validate present(:group_id)
@ -118,7 +139,7 @@ defmodule Mv.Membership.MemberGroup do
query =
Mv.Membership.MemberGroup
|> 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)
@ -135,7 +156,4 @@ defmodule Mv.Membership.MemberGroup do
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -155,12 +155,15 @@ defmodule Mv.Membership.Setting do
on: [:create, :update]
# Validate default_membership_fee_type_id exists if set
validate fn changeset, _context ->
validate fn changeset, context ->
fee_type_id =
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
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

View file

@ -31,12 +31,12 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
alias Mv.MembershipFees.CalendarCycles
@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
if has_start_date?(changeset) do
changeset
else
calculate_and_set_start_date(changeset)
calculate_and_set_start_date(changeset, context)
end
end
@ -56,10 +56,13 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
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),
{: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
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
@ -118,8 +121,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end
end
defp get_interval(membership_fee_type_id) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
defp get_interval(membership_fee_type_id, opts) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
{:ok, %{interval: interval}} -> {:ok, interval}
{:error, _} -> {:error, :membership_fee_type_not_found}
end

View file

@ -19,9 +19,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
def change(changeset, _opts, context) do
if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset)
validate_interval_match(changeset, context)
else
changeset
end
@ -33,9 +33,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end
# 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)
new_type_id = get_new_type_id(changeset)
actor = Map.get(context || %{}, :actor)
cond do
# 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
true ->
validate_intervals_match(changeset, current_type_id, new_type_id)
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
case get_intervals(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, actor) do
{:ok, current_interval, new_interval} ->
if current_interval == new_interval do
changeset
@ -85,11 +86,16 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end
end
# Get intervals for both types
defp get_intervals(current_type_id, new_type_id) do
# Get intervals for both types (actor required for authorization when resource has policies)
defp get_intervals(current_type_id, new_type_id, actor) do
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.interval, new_type.interval}

View file

@ -28,7 +28,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "membership_fee_cycles"
@ -83,6 +84,19 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
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
uuid_v7_primary_key :id

View file

@ -24,7 +24,8 @@ defmodule Mv.MembershipFees.MembershipFeeType do
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "membership_fee_types"
@ -61,6 +62,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
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
# Prevent interval changes after creation
validate fn changeset, _context ->

View file

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

View file

@ -1,6 +1,7 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
Helper functions for ensuring User actors have required data loaded.
Helper functions for ensuring User actors have required data loaded
and for querying actor capabilities (e.g. admin, permission set).
## Actor Invariant
@ -27,8 +28,11 @@ defmodule Mv.Authorization.Actor do
assign(socket, :current_user, user)
end
# In tests
user = Actor.ensure_loaded(user)
# Check if actor is admin (policy checks, validations)
if Actor.admin?(actor), do: ...
# Get permission set name (string or nil)
ps_name = Actor.permission_set_name(actor)
## Security Note
@ -47,6 +51,8 @@ defmodule Mv.Authorization.Actor do
require Logger
alias Mv.Helpers.SystemActor
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
@ -96,4 +102,45 @@ defmodule Mv.Authorization.Actor do
actor
end
end
@doc """
Returns the actor's permission set name (string or atom) from their role, or nil.
Ensures role is loaded (including when role is nil). Supports both atom and
string keys for session/socket assigns. Use for capability checks consistent
with `ActorIsAdmin` and `HasPermission`.
"""
@spec permission_set_name(Mv.Accounts.User.t() | map() | nil) :: String.t() | atom() | nil
def permission_set_name(nil), do: nil
def permission_set_name(actor) do
actor = actor |> ensure_loaded() |> maybe_load_role()
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
end
@doc """
Returns true if the actor is the system user or has the admin permission set.
Use for validations and policy checks that require admin capability (e.g.
changing a linked member's email). Consistent with `ActorIsAdmin` policy check.
"""
@spec admin?(Mv.Accounts.User.t() | map() | nil) :: boolean()
def admin?(nil), do: false
def admin?(actor) do
SystemActor.system_user?(actor) or permission_set_name(actor) in ["admin", :admin]
end
# Load role only when it is nil (e.g. actor from session without role). ensure_loaded/1
# already handles %Ash.NotLoaded{}, so we do not double-load in the normal Ash path.
defp maybe_load_role(%Mv.Accounts.User{role: nil} = user) do
case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded} -> loaded
_ -> user
end
end
defp maybe_load_role(actor), do: actor
end

View file

@ -1,22 +1,18 @@
defmodule Mv.Authorization.Checks.ActorIsAdmin do
@moduledoc """
Policy check: true when the actor's role has permission_set_name "admin".
Policy check: true when the actor is the system user or has permission_set_name "admin".
Used to restrict actions (e.g. User.update_user for member link/unlink) to admins only.
Delegates to `Mv.Authorization.Actor.admin?/1`, which returns true for the system actor
or for a user whose role has permission_set_name "admin".
"""
use Ash.Policy.SimpleCheck
alias Mv.Authorization.Actor
@impl true
def describe(_opts), do: "actor has admin permission set"
@impl true
def match?(nil, _context, _opts), do: false
def match?(actor, _context, _opts) do
ps_name =
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
ps_name == "admin"
end
def match?(actor, _context, _opts), do: Actor.admin?(actor)
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

@ -0,0 +1,71 @@
defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
@moduledoc """
Policy check: forbids setting or changing the memberuser link unless the actor is admin.
Used on Member create_member and update_member actions. When the `:user` argument
**is present** (key in arguments, regardless of value), only admins may perform the action.
This covers:
- **Linking:** `user: %{id: user_id}` only admin
- **Unlinking:** explicit `user: nil` or `user: %{}` on update_member only admin
Non-admin users can create and update members only when they do **not** pass the
`:user` argument; omitting `:user` leaves the relationship unchanged.
## Unlink semantics (update_member)
The Member resource uses `on_missing: :ignore` for the `:user` relationship on update.
So **omitting** `:user` from params does **not** change the link (no "unlink by omission").
Unlink is only possible by **explicitly** passing `:user` (e.g. `user: nil`), which this
check forbids for non-admins. Admins may link or unlink via the `:user` argument.
## Usage
In Member resource policies, restrict to create/update only:
policy action_type([:create, :update]) do
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
authorize_if Mv.Authorization.Checks.HasPermission
end
## Behaviour
- If the `:user` argument **key is not present** does not forbid.
- If `:user` is present (any value, including nil or %{}) and actor is not admin forbids.
- If actor is nil treated as non-admin (forbid when :user present). `Actor.admin?(nil)` is defined and returns false.
- If actor is admin (or system actor) does not forbid.
"""
use Ash.Policy.Check
alias Mv.Authorization.Actor
@impl true
def describe(_opts), do: "forbid setting memberuser link unless actor is admin"
@impl true
def strict_check(actor, authorizer, _opts) do
# Nil actor: treat as non-admin (Actor.admin?(nil) returns false; no crash)
actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor)
if user_argument_present?(authorizer) and not Actor.admin?(actor) do
{:ok, true}
else
{:ok, false}
end
end
# Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value).
# Check argument key presence (atom or string) for defense-in-depth.
defp user_argument_present?(authorizer) do
args = get_arguments(authorizer) || %{}
Map.has_key?(args, :user) or Map.has_key?(args, "user")
end
defp get_arguments(authorizer) do
subject = authorizer.changeset || authorizer.subject
cond do
is_struct(subject, Ash.Changeset) -> subject.arguments
is_struct(subject, Ash.ActionInput) -> subject.arguments
true -> %{}
end
end
end

View file

@ -50,6 +50,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:linked** - Filters based on resource type:
- 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)
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id Member.id User.member_id)
## Error Handling
@ -131,26 +132,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource_name
) do
:authorized ->
# For :all scope, authorize directly
{:ok, true}
{:filter, filter_expr} ->
# For :own/:linked scope:
# - With a record, evaluate filter against record for strict authorization
# - Without a record (queries/lists), return false
#
# NOTE: Returning false here forces the use of expr-based bypass policies.
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
# when strict_check returns :unknown. Instead, resources should use bypass policies
# with expr() directly for filter-based authorization (see User resource).
if record do
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
# No record yet (e.g., read/list queries) - deny at strict_check level
# Resources must use expr-based bypass policies for list filtering
# Create: use a dedicated check that does not return a filter (e.g. CustomFieldValueCreateScope)
{:ok, false}
end
strict_check_filter_scope(record, filter_expr, actor, resource_name)
false ->
{:ok, false}
@ -174,6 +159,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
end
# For :own/:linked scope: with record evaluate filter; without record deny (resources use bypass + expr).
defp strict_check_filter_scope(record, filter_expr, actor, resource_name) do
if record do
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
{:ok, false}
end
end
@impl true
def auto_filter(actor, authorizer, _opts) do
resource = authorizer.resource
@ -278,36 +272,28 @@ defmodule Mv.Authorization.Checks.HasPermission do
# For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"User", %{id: user_id}} when not is_nil(user_id) ->
# Check if this user's ID matches the actor's ID (scope :own)
if user_id == actor.id do
{:ok, true}
else
{:ok, false}
end
result =
case {resource_name, record} do
# Scope :own
{"User", %{id: user_id}} when not is_nil(user_id) ->
user_id == actor.id
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
# Scope :linked
{"Member", %{id: member_id}} when not is_nil(member_id) ->
member_id == actor.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
if cfv_member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
cfv_member_id == actor.member_id
_ ->
# For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
{:ok, :unknown}
end
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) ->
mg_member_id == actor.member_id
_ ->
:unknown
end
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
out
end
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
@ -347,24 +333,20 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
# If actor has no member_id, return no results (use false or impossible condition)
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(id == ^actor.member_id)}
end
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
linked_filter_by_member_id(actor, :id)
"CustomFieldValue" ->
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
# If actor has no member_id, return no results
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(member_id == ^actor.member_id)}
end
linked_filter_by_member_id(actor, :member_id)
"MemberGroup" ->
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
linked_filter_by_member_id(actor, :member_id)
"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
@ -372,6 +354,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
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)
defp log_auth_failure(actor, resource, action, reason) do
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

@ -0,0 +1,18 @@
defmodule Mv.Authorization.Checks.OidcRoleSyncContext do
@moduledoc """
Policy check: true when the action is run from OIDC role sync (context.private.oidc_role_sync).
Used to allow the internal set_role_from_oidc_sync action only when called by Mv.OidcRoleSync,
which sets context.private.oidc_role_sync when performing the update.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "called from OIDC role sync (context.private.oidc_role_sync)"
@impl true
def match?(_actor, authorizer, _opts) do
context = Map.get(authorizer, :context) || %{}
get_in(context, [:private, :oidc_role_sync]) == true
end
end

View file

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

View file

@ -37,7 +37,8 @@ defmodule Mv.Authorization.Role do
"""
use Ash.Resource,
domain: Mv.Authorization,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "roles"
@ -86,6 +87,13 @@ defmodule Mv.Authorization.Role do
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Role access: read for all permission sets, create/update/destroy for admin only (PermissionSets)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
validate one_of(
:permission_set_name,
@ -173,4 +181,18 @@ defmodule Mv.Authorization.Role do
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
end
@doc """
Returns the Admin role if it exists.
Used by release tasks (e.g. seed_admin) and OIDC role sync to assign the admin role.
"""
@spec get_admin_role() :: {:ok, t() | nil} | {:error, term()}
def get_admin_role do
require Ash.Query
__MODULE__
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
end
end

View file

@ -27,6 +27,10 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
Modified changeset with email synchronization applied, or original changeset
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
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
@ -40,26 +44,29 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
defp sync_email(changeset) do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
%Mv.Membership.Member{} ->
# Member-side: Override member email in result with user email
Helpers.override_with_linked_email(result, user.email)
%Mv.Accounts.User{} ->
# User-side: Sync user email to linked member in DB
Helpers.sync_email_to_linked_record(result, member, user.email)
end
else
_ -> result
end
apply_sync(result)
end)
end
defp apply_sync(result) do
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
sync_by_record_type(result, record, user, member)
else
_ -> result
end
end
# When called from Member-side, we update the member in the result.
# When called from User-side, we sync user email to the linked member in DB.
defp sync_by_record_type(result, %Mv.Membership.Member{}, user, _member) do
Helpers.override_with_linked_email(result, user.email)
end
defp sync_by_record_type(result, %Mv.Accounts.User{}, user, member) do
Helpers.sync_email_to_linked_record(result, member, user.email)
end
# Retrieves user and member - works for both resource types
# Uses system actor via Loader functions
defp get_user_and_member(%Mv.Accounts.User{} = user) do

View file

@ -3,13 +3,15 @@ defmodule Mv.EmailSync.Loader do
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
## Authorization
## Authorization-independent link checks
This module runs systemically and uses the system actor for all operations.
This ensures that email synchronization always works, regardless of user permissions.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as email sync is a mandatory side effect.
All functions use the **system actor** for the load. Link existence
(linked vs not linked) is therefore determined **independently of the
current request actor**. This is required so that validations (e.g.
`EmailChangePermission`, `EmailNotUsedByOtherUser`) can correctly decide
"member is linked" even when the current user would not have read permission
on the related User. Using the request actor would otherwise allow
treating a linked member as unlinked and bypass the permission rule.
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor

View file

@ -5,6 +5,8 @@ defmodule Mv.Helpers do
Provides utilities that are not specific to a single domain or layer.
"""
require Ash.Query
@doc """
Converts an actor to Ash options list for authorization.
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()
def ash_actor_opts(nil), do: []
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

View file

@ -0,0 +1,75 @@
defmodule Mv.Membership.Member.Validations.EmailChangePermission do
@moduledoc """
Validates that only admins or the linked user may change a linked member's email.
This validation runs on member update when the email attribute is changing.
It allows the change only if:
- The member is not linked to a user, or
- The actor has the admin permission set (via `Mv.Authorization.Actor.admin?/1`), or
- The actor is the user linked to this member (actor.member_id == member.id).
This prevents non-admins from changing another user's linked member email,
which would sync to that user's account and break email synchronization.
Missing actor is not allowed; the system actor counts as admin (via `Actor.admin?/1`).
"""
use Ash.Resource.Validation
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Authorization.Actor
alias Mv.EmailSync.Loader
@doc """
Validates that the actor may change the member's email when the member is linked.
Only runs when the email attribute is changing (checked inside). Skips when
member is not linked. Allows when actor is admin or owns the linked member.
"""
@impl true
def validate(changeset, _opts, context) do
if Ash.Changeset.changing_attribute?(changeset, :email) do
validate_linked_member_email_change(changeset, context)
else
:ok
end
end
defp validate_linked_member_email_change(changeset, context) do
linked_user = Loader.get_linked_user(changeset.data)
if is_nil(linked_user) do
:ok
else
actor = resolve_actor(changeset, context)
member_id = changeset.data.id
if Actor.admin?(actor) or actor_owns_member?(actor, member_id) do
:ok
else
msg =
dgettext(
"default",
"Only administrators or the linked user can change the email for members linked to users"
)
{:error, field: :email, message: msg}
end
end
end
# Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
defp resolve_actor(changeset, context) do
ctx = changeset.context || %{}
get_in(ctx, [:private, :actor]) ||
Map.get(ctx, :actor) ||
(context && Map.get(context, :actor))
end
defp actor_owns_member?(nil, _member_id), do: false
defp actor_owns_member?(actor, member_id) do
actor_member_id = Map.get(actor, :member_id) || Map.get(actor, "member_id")
actor_member_id == member_id
end
end

View file

@ -8,6 +8,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
alias Mv.EmailSync.Loader
alias Mv.Helpers
require Logger
@ -32,7 +34,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
linked_user = Loader.get_linked_user(changeset.data)
linked_user_id = if linked_user, do: linked_user.id, else: nil
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@ -53,7 +56,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
query =
Mv.Accounts.User
|> 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()
opts = Helpers.ash_actor_opts(system_actor)
@ -73,19 +76,4 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
alias Mv.Helpers.SystemActor
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end
end
end

148
lib/mv/oidc_role_sync.ex Normal file
View file

@ -0,0 +1,148 @@
defmodule Mv.OidcRoleSync do
@moduledoc """
Syncs user role from OIDC user_info (e.g. groups claim Admin role).
Used after OIDC registration (register_with_rauthy) and on sign-in so that
users in the configured admin group get the Admin role; others get Mitglied.
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
Groups are read from user_info (ID token claims) first; if missing or empty,
the access_token from oauth_tokens is decoded as JWT and the groups claim is
read from there (e.g. Rauthy puts groups in the access token when scope
includes "groups").
## JWT access token (security)
The access_token payload is read without signature verification (peek only).
We rely on the fact that `oauth_tokens` is only ever passed from the
verified OIDC callback (Assent/AshAuthentication after provider token
exchange). If callers passed untrusted or tampered tokens, group claims
could be forged and a user could be assigned the Admin role. Therefore:
do not call this module with user-supplied tokens; it is intended only
for the internal flow from the OIDC callback.
"""
alias Mv.Accounts.User
alias Mv.Authorization.Role
alias Mv.OidcRoleSyncConfig
@doc """
Applies Admin or Mitglied role to the user based on OIDC groups claim.
- If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user.
- If groups (from user_info or access_token) contain the configured admin group: assigns Admin role.
- Otherwise: assigns Mitglied role (downgrade if user was Admin).
user_info is a map (e.g. from ID token claims); oauth_tokens is optional and may
contain "access_token" (JWT) from which the groups claim is read when not in user_info.
"""
@spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
when is_map(user_info) do
admin_group = OidcRoleSyncConfig.oidc_admin_group_name()
if is_nil(admin_group) or admin_group == "" do
:ok
else
claim = OidcRoleSyncConfig.oidc_groups_claim()
groups = groups_from_user_info(user_info, claim)
groups =
if Enum.empty?(groups), do: groups_from_access_token(oauth_tokens, claim), else: groups
target_role = if admin_group in groups, do: :admin, else: :mitglied
set_user_role(user, target_role)
end
end
defp groups_from_user_info(user_info, claim) do
value = user_info[claim] || user_info[String.to_existing_atom(claim)]
normalize_groups(value)
rescue
ArgumentError -> normalize_groups(user_info[claim])
end
defp groups_from_access_token(nil, _claim), do: []
defp groups_from_access_token(oauth_tokens, _claim) when not is_map(oauth_tokens), do: []
defp groups_from_access_token(oauth_tokens, claim) do
access_token = oauth_tokens["access_token"] || oauth_tokens[:access_token]
if is_binary(access_token) do
case peek_jwt_claims(access_token) do
{:ok, claims} ->
value = claims[claim] || safe_get_atom(claims, claim)
normalize_groups(value)
_ ->
[]
end
else
[]
end
end
defp safe_get_atom(map, key) when is_binary(key) do
try do
Map.get(map, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
end
defp safe_get_atom(_map, _key), do: nil
defp peek_jwt_claims(token) do
parts = String.split(token, ".")
if length(parts) == 3 do
[_h, payload_b64, _sig] = parts
case Base.url_decode64(payload_b64, padding: false) do
{:ok, payload} -> Jason.decode(payload)
_ -> :error
end
else
:error
end
end
defp normalize_groups(nil), do: []
defp normalize_groups(list) when is_list(list), do: Enum.map(list, &to_string/1)
defp normalize_groups(single) when is_binary(single), do: [single]
defp normalize_groups(_), do: []
defp set_user_role(user, :admin) do
case Role.get_admin_role() do
{:ok, %Role{} = role} ->
do_set_role(user, role)
_ ->
:ok
end
end
defp set_user_role(user, :mitglied) do
case Role.get_mitglied_role() do
{:ok, %Role{} = role} ->
do_set_role(user, role)
_ ->
:ok
end
end
defp do_set_role(user, role) do
if user.role_id == role.id do
:ok
else
user
|> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id})
|> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}})
|> Ash.update(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}})
|> case do
{:ok, _} -> :ok
{:error, _} -> :ok
end
end
end
end

View file

@ -0,0 +1,24 @@
defmodule Mv.OidcRoleSyncConfig do
@moduledoc """
Runtime configuration for OIDC group role sync (e.g. admin group Admin role).
Reads from Application config `:mv, :oidc_role_sync`:
- `:admin_group_name` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `:groups_claim` JWT/user_info claim name for groups (default: `"groups"`).
Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
"""
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
def oidc_admin_group_name do
get(:admin_group_name)
end
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
get(:groups_claim) || "groups"
end
defp get(key) do
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
end
end

View file

@ -2,9 +2,22 @@ defmodule Mv.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
## Tasks
- `migrate/0` - Runs all pending Ecto migrations.
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
to update the admin password without redeploying.
"""
@app :mv
alias Mv.Accounts
alias Mv.Accounts.User
alias Mv.Authorization.Role
require Ash.Query
def migrate do
load_app()
@ -18,6 +31,158 @@ defmodule Mv.Release do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
@doc """
Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE).
Starts the application if not already running (required when called via `bin/mv eval`;
Ash/Telemetry need the running app). Idempotent.
- If ADMIN_EMAIL is unset: no-op (idempotent).
- If ADMIN_PASSWORD (and ADMIN_PASSWORD_FILE) are unset and the user does not exist:
no user is created (no fallback password in production).
- If both ADMIN_EMAIL and ADMIN_PASSWORD are set: creates or updates the user with
Admin role and the given password. Safe to run on every deployment or via
`bin/mv eval "Mv.Release.seed_admin()"` to change the admin password without redeploying.
"""
def seed_admin do
# Ensure app (and Telemetry/Ash deps) are started when run via bin/mv eval
case Application.ensure_all_started(@app) do
{:ok, _} -> :ok
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
end
admin_email = get_env("ADMIN_EMAIL", nil)
admin_password = get_env_or_file("ADMIN_PASSWORD", nil)
cond do
is_nil(admin_email) or admin_email == "" ->
:ok
is_nil(admin_password) or admin_password == "" ->
ensure_admin_role_only(admin_email)
true ->
ensure_admin_user(admin_email, admin_password)
end
end
defp ensure_admin_role_only(email) do
case Role.get_admin_role() do
{:ok, nil} ->
:ok
{:ok, %Role{} = admin_role} ->
case get_user_by_email(email) do
{:ok, %User{} = user} ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
:ok
_ ->
:ok
end
{:error, _} ->
:ok
end
end
defp ensure_admin_user(email, password) do
if is_nil(password) or password == "" do
:ok
else
do_ensure_admin_user(email, password)
end
end
defp do_ensure_admin_user(email, password) do
case Role.get_admin_role() do
{:ok, nil} ->
# Admin role does not exist (e.g. migrations not run); skip
:ok
{:ok, %Role{} = admin_role} ->
case get_user_by_email(email) do
{:ok, nil} ->
create_admin_user(email, password, admin_role)
{:ok, user} ->
update_admin_user(user, password, admin_role)
{:error, _} ->
:ok
end
{:error, _} ->
:ok
end
end
defp create_admin_user(email, password, admin_role) do
case Accounts.create_user(%{email: email}, authorize?: false) do
{:ok, user} ->
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok
{:error, _} ->
:ok
end
end
defp update_admin_user(user, password, admin_role) do
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok
end
defp get_user_by_email(email) do
User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
end
defp get_env(key, default) do
System.get_env(key, default)
end
defp get_env_or_file(var_name, default) do
file_var = "#{var_name}_FILE"
case System.get_env(file_var) do
nil ->
System.get_env(var_name, default)
file_path ->
case File.read(file_path) do
{:ok, content} ->
String.trim_trailing(content)
{:error, _} ->
default
end
end
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end

View file

@ -97,12 +97,18 @@ defmodule MvWeb.Authorization do
@doc """
Checks if user can access a specific page.
Nil-safe: returns false when user is nil (e.g. unauthenticated or layout
assigns regression), so callers do not need to guard.
## Examples
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can_access_page?(admin, "/admin/roles")
true
iex> can_access_page?(nil, "/members")
false
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can_access_page?(mitglied, "/members")
false

View file

@ -97,12 +97,13 @@ defmodule MvWeb.CoreComponents do
<.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method)
attr :rest, :global, include: ~w(href navigate patch method data-testid)
attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
def button(assigns) do
rest = assigns.rest
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
@ -544,6 +545,9 @@ defmodule MvWeb.CoreComponents do
attr :label, :string
attr :class, :string
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
slot :action, doc: "the slot for showing user actions in the last table column"
@ -559,7 +563,13 @@ defmodule MvWeb.CoreComponents do
<table class="table table-zebra">
<thead>
<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}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
@ -645,6 +655,16 @@ defmodule MvWeb.CoreComponents do
"""
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 """
Renders a data list.

View file

@ -4,6 +4,8 @@ defmodule MvWeb.Layouts.Sidebar do
"""
use MvWeb, :html
alias MvWeb.PagePaths
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
@ -70,34 +72,57 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_menu(assigns) do
~H"""
<ul class="menu flex-1 w-full p-2" role="menubar">
<.menu_item
href={~p"/members"}
icon="hero-users"
label={gettext("Members")}
/>
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<!-- Nested Admin Menu -->
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
<%= if can_access_page?(@current_user, PagePaths.members()) do %>
<.menu_item
href={~p"/members"}
icon="hero-users"
label={gettext("Members")}
/>
<.menu_subitem href={~p"/admin/import-export"} label={gettext("Import/Export")} />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
</.menu_group>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<% end %>
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group
icon="hero-cog-6-tooth"
label={gettext("Administration")}
testid="sidebar-administration"
>
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
<.menu_subitem href={~p"/admin/import-export"} label={gettext("Import/Export")} />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
<% end %>
</.menu_group>
<% end %>
</ul>
"""
end
defp admin_menu_visible?(user) do
Enum.any?(PagePaths.admin_menu_paths(), &can_access_page?(user, &1))
end
attr :href, :string, required: true, doc: "Navigation path"
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
@ -120,12 +145,13 @@ defmodule MvWeb.Layouts.Sidebar do
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
attr :label, :string, required: true, doc: "Menu group label"
attr :testid, :string, default: nil, doc: "data-testid for stable test selectors"
slot :inner_block, required: true, doc: "Submenu items"
defp menu_group(assigns) do
~H"""
<!-- Expanded Mode: Always open div structure -->
<li role="none" class="expanded-menu-group">
<li role="none" class="expanded-menu-group" data-testid={@testid}>
<div
class="flex items-center gap-3"
role="group"
@ -139,7 +165,7 @@ defmodule MvWeb.Layouts.Sidebar do
</ul>
</li>
<!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right">
<div class="collapsed-menu-group dropdown dropdown-right" data-testid={@testid}>
<button
type="button"
tabindex="0"

View file

@ -20,7 +20,6 @@ defmodule MvWeb.TableComponents do
type="button"
phx-click="sort"
phx-value-field={@field}
aria-sort={aria_sort(@sort_field, @sort_order, @field)}
class="flex items-center gap-1 hover:underline focus:outline-none"
>
<span>{@label}</span>
@ -33,12 +32,4 @@ defmodule MvWeb.TableComponents do
</button>
"""
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

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"
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 %>
<option
value={fee_type.id}
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
</option>
<% end %>
</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>
<% end %>
<%= if @interval_warning do %>

View file

@ -23,9 +23,11 @@
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<% end %>
</:actions>
</.header>
@ -84,6 +86,7 @@
<.table
id="members"
rows={@members}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
@ -297,16 +300,23 @@
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={member}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="member-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
<%= if can?(@current_user, :update, @member) do %>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit Member")}
</.button>
<% end %>
</div>
<%!-- Tab Navigation --%>
@ -119,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
@ -281,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
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(:edit), do: gettext("Edit Member")

View file

@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization, only: [can?: 3]
alias Mv.Membership
alias Mv.MembershipFees
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
</div>
<%!-- Action Buttons --%>
<%!-- Action Buttons (only when user has permission) --%>
<div class="flex gap-2 mb-4">
<.button
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="regenerate_cycles"
phx-target={@myself}
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"))}
</.button>
<.button
:if={Enum.any?(@cycles)}
:if={Enum.any?(@cycles) and @can_destroy_cycle}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col>
<:col :let={cycle} label={gettext("Amount")}>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
<%= if @can_update_cycle do %>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
<% else %>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
<% end %>
</:col>
<:col :let={cycle} label={gettext("Status")}>
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<:action :let={cycle}>
<div class="flex gap-1">
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<%= if @can_update_cycle do %>
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<% end %>
<%= if @can_destroy_cycle do %>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<% end %>
</div>
</:action>
</.table>
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Get available fee types (filtered to same interval if member has a type)
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,
socket
|> assign(assigns)
|> assign(:cycles, cycles)
|> 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(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
@ -439,7 +457,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, [])
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -470,13 +488,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
updated_member
|> Ash.load!(
@ -502,7 +516,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, cycles)
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -554,17 +568,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
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)
# SECURITY: Only admins can manually regenerate cycles via UI
# Cycle generation itself uses system actor, but UI access should be restricted
if actor.role && actor.role.permission_set_name == "admin" do
if can?(actor, :create, MembershipFeeCycle) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
else
{:noreply,
socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|> assign(:regenerating, false)
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
end
@ -722,61 +735,31 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
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,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> 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
@ -895,6 +878,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# 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
all_types =
MembershipFeeType
@ -940,6 +972,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
Enum.map_join(error.errors, ", ", fn e -> e.message 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), do: gettext("An error occurred")

View file

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

View file

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

View file

@ -35,6 +35,8 @@ defmodule MvWeb.UserLive.Form do
require Jason
alias Mv.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
@ -50,6 +52,18 @@ defmodule MvWeb.UserLive.Form do
<.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" />
<%= 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 -->
<div class="mt-6">
<label class="flex items-center space-x-2">
@ -67,6 +81,18 @@ defmodule MvWeb.UserLive.Form do
<%= if @show_password_fields do %>
<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
field={@form[: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).
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,
socket
@ -307,6 +336,8 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user)
|> assign(:page_title, page_title)
|> 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(:member_search_query, "")
|> assign(:available_members, [])
@ -357,7 +388,10 @@ defmodule MvWeb.UserLive.Form do
def handle_event("save", %{"user" => user_params}, socket) do
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
{:ok, user} ->
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(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
error_message = extract_error_message(error)
@ -572,7 +620,8 @@ defmodule MvWeb.UserLive.Form do
assigns: %{
user: user,
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
) do
@ -580,16 +629,25 @@ defmodule MvWeb.UserLive.Form do
form =
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.
action =
cond do
show_password_fields -> :admin_set_password
can_manage_member_linking -> :update_user
can_manage_member_linking or can_assign_role -> :update_user
true -> :update
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
# For new users, use password registration if password fields are shown
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)
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
@spec extract_error_message(any()) :: String.t()
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 =
Mv.Accounts.User
|> 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)

View file

@ -2,13 +2,22 @@
<.header>
{gettext("Listing Users")}
<:actions>
<.button variant="primary" navigate={~p"/users/new"}>
<.icon name="hero-plus" /> {gettext("New User")}
</.button>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
<.icon name="hero-plus" /> {gettext("New User")}
</.button>
<% end %>
</:actions>
</.header>
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}>
<.table
id="users"
rows={@users}
row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
sort_field={@sort_field}
sort_order={@sort_order}
>
<:col
:let={user}
label={
@ -38,6 +47,7 @@
</:col>
<:col
:let={user}
sort_field={:email}
label={
sort_button(%{
field: :email,
@ -49,11 +59,28 @@
>
{user.email}
</:col>
<:col :let={user} label={gettext("Role")}>
{user.role.name}
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% 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 %>
</:col>
@ -62,16 +89,23 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={user}>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<%= if can?(@current_user, :destroy, user) do %>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="user-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -41,16 +41,30 @@ defmodule MvWeb.UserLive.Show do
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button>
<%= if can?(@current_user, :update, @user) do %>
<.button
variant="primary"
navigate={~p"/users/#{@user}/edit?return_to=show"}
data-testid="user-edit"
>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button>
<% end %>
</:actions>
</.header>
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
<: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 title={gettext("Linked Member")}>
<%= if @user.member do %>
@ -73,7 +87,9 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
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
{:ok,

42
lib/mv_web/page_paths.ex Normal file
View file

@ -0,0 +1,42 @@
defmodule MvWeb.PagePaths do
@moduledoc """
Central path strings for UI authorization and sidebar menu.
Keep in sync with `MvWeb.Router`. Used by Sidebar and `can_access_page?/2`
so route changes (prefix, rename) are updated in one place.
"""
# Sidebar top-level menu paths
@members "/members"
@membership_fee_types "/membership_fee_types"
# Administration submenu paths (all must match router)
@users "/users"
@groups "/groups"
@admin_roles "/admin/roles"
@membership_fee_settings "/membership_fee_settings"
@settings "/settings"
@admin_page_paths [
@users,
@groups,
@admin_roles,
@membership_fee_settings,
@settings
]
@doc "Path for Members index (sidebar and page permission check)."
def members, do: @members
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
def membership_fee_types, do: @membership_fee_types
@doc "Paths for Administration menu; show group if user can access any of these."
def admin_menu_paths, do: @admin_page_paths
def users, do: @users
def groups, do: @groups
def admin_roles, do: @admin_roles
def membership_fee_settings, do: @membership_fee_settings
def settings, do: @settings
end

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_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_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_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_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_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_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.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.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.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.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"},
"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"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"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"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"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"},
"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"},
"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"},
@ -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"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"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"},
"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"},
"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"},
"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"},
"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"},
"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"},
"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"},
"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"},
@ -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_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_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_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"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"},
"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, "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"},
"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"},
"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"},
"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"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
"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.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"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"},
"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"},

View file

@ -294,6 +294,7 @@ msgstr "Beschreibung"
msgid "Edit User"
msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enabled"
@ -471,6 +472,7 @@ msgid "Include both letters and numbers"
msgstr "Buchstaben und Zahlen verwenden"
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Password"
msgstr "Passwort"
@ -958,7 +960,6 @@ msgid "Last name"
msgstr "Nachname"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "None"
msgstr "Keine"
@ -1670,6 +1671,9 @@ msgstr "Profil"
#: lib/mv_web/live/role_live/form.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
msgid "Role"
msgstr "Rolle"
@ -1965,11 +1969,6 @@ msgstr "Bezahlstatus"
msgid "Reset"
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/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@ -2307,7 +2306,7 @@ msgstr "Import/Export"
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this page."
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -2319,22 +2318,54 @@ msgstr "Mitgliederdaten verwalten"
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr "Benutzerdefinierte Felder"
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
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."
#~ #: lib/mv_web/live/global_settings_live.ex
#: 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."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Manage Custom Fields"
#~ msgstr "Benutzerdefinierte Felder verwalten"
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"

View file

@ -295,6 +295,7 @@ msgstr ""
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enabled"
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Password"
msgstr ""
@ -959,7 +961,6 @@ msgid "Last name"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "None"
msgstr ""
@ -1671,6 +1672,9 @@ msgstr ""
#: lib/mv_web/live/role_live/form.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
msgid "Role"
msgstr ""
@ -1966,11 +1970,6 @@ msgstr ""
msgid "Reset"
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/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@ -2319,3 +2318,50 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users"
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"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enabled"
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Password"
msgstr ""
@ -959,7 +961,6 @@ msgid "Last name"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "None"
msgstr ""
@ -1671,6 +1672,9 @@ msgstr ""
#: lib/mv_web/live/role_live/form.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
msgid "Role"
msgstr ""
@ -1966,11 +1970,6 @@ msgstr ""
msgid "Reset"
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/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@ -2320,22 +2319,54 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr ""
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
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"
#~ #: lib/mv_web/live/global_settings_live.ex
#: 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 ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Manage Custom Fields"
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr ""

View file

@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
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 <- [
%{
name: "Standard (Jährlich)",
@ -39,7 +39,12 @@ for fee_type_attrs <- [
] do
MembershipFeeType
|> 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
for attrs <- [
@ -127,8 +132,15 @@ for attrs <- [
)
end
# Get admin email from environment variable or use default
# Admin email: default for dev/test so seed_admin has a target
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
System.put_env("ADMIN_EMAIL", admin_email)
# In dev/test, set fallback password so seed_admin creates the admin user when none is set
if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and
is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do
System.put_env("ADMIN_PASSWORD", "testpassword")
end
# Create all authorization roles (idempotent - creates only if they don't exist)
# Roles are created using create_role_with_system_flag to allow setting is_system_role
@ -209,39 +221,9 @@ if is_nil(admin_role) do
raise "Failed to create or find admin role. Cannot proceed with member seeding."
end
# Assign admin role to user with ADMIN_EMAIL (if user exists)
# This handles both existing users (e.g., from OIDC) and newly created users
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, existing_admin_user} when not is_nil(existing_admin_user) ->
# User already exists (e.g., via OIDC) - assign admin role
# Use authorize?: false for bootstrap - this is initial setup
existing_admin_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
{:ok, nil} ->
# User doesn't exist - create admin user with password
# Use authorize?: false for bootstrap - no admin user exists yet to use as actor
Accounts.create_user!(%{email: admin_email},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!(authorize?: false)
|> then(fn user ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
{:error, error} ->
raise "Failed to check for existing admin user: #{inspect(error)}"
end
# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE).
# Reduces duplication and exercises the same path as production entrypoint.
Mv.Release.seed_admin()
# Load admin user with role for use as actor in member operations
# This ensures all member operations have proper authorization
@ -299,12 +281,12 @@ case Accounts.User
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
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
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates
@ -452,7 +434,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
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 = [
%{email: "hans.mueller@example.de"},
%{email: "greta.schmidt@example.de"},
@ -462,15 +445,12 @@ additional_users = [
created_users =
Enum.map(additional_users, fn user_attrs ->
# Use admin user as actor for additional user creation (not bootstrap)
user =
Accounts.create_user!(user_attrs,
upsert?: true,
upsert_identity: :unique_email,
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
Accounts.User
@ -744,7 +724,14 @@ IO.puts("📝 Created sample data:")
IO.puts(" - Global settings: club_name = #{default_club_name}")
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
IO.puts(" - Admin user: #{admin_email} (password: testpassword)")
password_configured =
System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil
IO.puts(
" - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})"
)
IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(

View file

@ -4,6 +4,9 @@ set -e
echo "==> Running database migrations..."
/app/bin/migrate
echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..."
/app/bin/mv eval "Mv.Release.seed_admin()"
echo "==> Starting application..."
exec /app/bin/server

View file

@ -118,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do
)
case result do
{:ok, found_user} when is_struct(found_user) ->
assert found_user.id == user.id
assert found_user.oidc_id == "oidc_identifier_12345"
{:ok, [found_user]} ->
assert found_user.id == user.id
assert found_user.oidc_id == "oidc_identifier_12345"
@ -125,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do
{:ok, []} ->
flunk("User should be found by oidc_id")
{:ok, nil} ->
flunk("User should be found by oidc_id")
{:error, error} ->
flunk("Unexpected error: #{inspect(error)}")
end
@ -219,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
# Either returns empty/nil OR authentication error - both mean "user not found"
case result do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
:ok
@ -260,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
# Either returns empty/nil OR authentication error - both mean "user not found"
case result do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
:ok

View file

@ -54,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
# Create a valid fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
Ash.create(
MembershipFeeType,
%{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
},
actor: actor
)
# Setting a valid fee type should work
{:ok, updated} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update(actor: actor)
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},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?
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},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?
assert %{errors: errors} = changeset
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
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},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?,
"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)
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
end
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, 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
end
end

View file

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

View file

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

View file

@ -10,7 +10,6 @@ defmodule Mv.Accounts.UserPoliciesTest do
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
@ -19,59 +18,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
%{actor: system_actor}
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
defp setup_user_with_own_access(permission_set, actor) do
user = create_user_with_permission_set(permission_set, actor)
other_user = create_other_user(actor)
user = Mv.Fixtures.user_with_role_fixture(permission_set)
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
# Reload user to ensure role is preloaded
{:ok, user} =
@ -80,217 +30,101 @@ defmodule Mv.Accounts.UserPoliciesTest do
%{user: user, other_user: other_user}
end
describe "own_data permission set (Mitglied)" do
setup %{actor: actor} do
setup_user_with_own_access("own_data", actor)
# Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User)
describe "non-admin permission sets (own_data, read_only, normal_user)" do
setup %{actor: actor} = context do
permission_set = context[:permission_set] || "own_data"
setup_user_with_own_access(permission_set, actor)
end
test "can read own user record", %{user: user} do
{:ok, fetched_user} =
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
for permission_set <- ["own_data", "read_only", "normal_user"] do
@tag permission_set: permission_set
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
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)
assert fetched_user.id == user.id
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)
@tag permission_set: permission_set
test "can update own email (#{permission_set})", %{user: user} do
new_email = "updated#{System.unique_integer([:positive])}@example.com"
{: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
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)
@tag permission_set: permission_set
test "cannot read other users - not found due to auto_filter (#{permission_set})", %{
user: user,
other_user: other_user
} do
assert_raise Ash.Error.Invalid, fn ->
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
end
end
end
test "cannot destroy user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(user, actor: user)
@tag permission_set: permission_set
test "cannot update other users - forbidden (#{permission_set})", %{
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
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup %{actor: actor} do
setup_user_with_own_access("read_only", actor)
end
@tag permission_set: permission_set
test "list users returns only own user (#{permission_set})", %{user: user} do
{:ok, users} = Ash.read(Accounts.User, 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)
assert length(users) == 1
assert hd(users).id == user.id
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)
@tag permission_set: permission_set
test "cannot create user - forbidden (#{permission_set})", %{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 "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)
@tag permission_set: permission_set
test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(user, actor: user)
end
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
@tag permission_set: permission_set
test "cannot change role via update_user - forbidden (#{permission_set})", %{
user: user,
other_user: other_user
} do
other_role = Mv.Fixtures.role_fixture("read_only")
describe "normal_user permission set (Kassenwart)" do
setup %{actor: actor} do
setup_user_with_own_access("normal_user", actor)
end
test "can read own user record", %{user: user} do
{:ok, fetched_user} =
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
assert fetched_user.id == user.id
end
test "can update own email", %{user: user} do
new_email = "updated#{System.unique_integer([:positive])}@example.com"
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
end
test "cannot read other users (returns not found due to auto_filter)", %{
user: user,
other_user: other_user
} do
# Note: With auto_filter policies, when a user tries to read a user that doesn't
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
# This is the expected behavior - the filter makes the record "invisible" to the user.
assert_raise Ash.Error.Invalid, fn ->
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
end
end
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
assert_raise Ash.Error.Forbidden, fn ->
other_user
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end
test "list users returns only own user", %{user: user} do
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
# Should only return the own user (scope :own filters)
assert length(users) == 1
assert hd(users).id == user.id
end
test "cannot create user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot destroy user (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(user, actor: user)
assert {:error, %Ash.Error.Forbidden{}} =
other_user
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
|> Ash.update(actor: user, domain: Mv.Accounts)
end
end
end
describe "admin permission set" do
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
other_user = create_other_user(actor)
user = Mv.Fixtures.user_with_role_fixture("admin")
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
# Reload user to ensure role is preloaded
{:ok, user} =
@ -343,6 +177,88 @@ defmodule Mv.Accounts.UserPoliciesTest do
# Verify user is deleted
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
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
describe "AshAuthentication bypass" do

View file

@ -496,6 +496,281 @@ defmodule Mv.Authorization.PermissionSetsTest do
assert "*" in permissions.pages
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
describe "valid_permission_set?/1" do

View file

@ -0,0 +1,226 @@
defmodule Mv.Authorization.RolePoliciesTest do
@moduledoc """
Tests for Role resource authorization policies.
Rule: All permission sets (own_data, read_only, normal_user, admin) can **read** roles.
Only **admin** can create, update, or destroy roles.
"""
use Mv.DataCase, async: false
alias Mv.Authorization
alias Mv.Authorization.Role
describe "read access - all permission sets can read roles" do
setup do
# Create a role to read (via system_actor; once policies exist, system_actor is admin)
role = Mv.Fixtures.role_fixture("read_only")
%{role: role}
end
@tag :permission_set_own_data
test "own_data can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_own_data
test "own_data can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_read_only
test "read_only can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_read_only
test "read_only can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_normal_user
test "normal_user can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_normal_user
test "normal_user can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_admin
test "admin can list roles", %{role: _role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
{:ok, roles} = Authorization.list_roles(actor: admin)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_admin
test "admin can get role by id", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
{:ok, loaded} = Ash.get(Role, role.id, actor: admin, domain: Mv.Authorization)
assert loaded.id == role.id
end
end
describe "create/update/destroy - only admin allowed" do
setup do
# Non-system role for destroy test (role_fixture creates non-system roles)
role = Mv.Fixtures.role_fixture("normal_user")
%{role: role}
end
test "admin can create_role", %{role: _role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:ok, _created} = Authorization.create_role(attrs, actor: admin)
end
test "admin can update_role", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
assert {:ok, updated} =
Authorization.update_role(role, %{description: "Updated by admin"}, actor: admin)
assert updated.description == "Updated by admin"
end
test "admin can destroy non-system role", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
assert :ok = Authorization.destroy_role(role, actor: admin)
end
test "own_data cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "own_data cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "own_data cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
test "read_only cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "read_only cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "read_only cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
test "normal_user cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "normal_user"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "normal_user cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "normal_user cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
end
end

View file

@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do
end
describe "permission_set_name validation" do
test "accepts valid permission set names" do
test "accepts valid permission set names", %{actor: actor} do
attrs = %{
name: "Test Role",
permission_set_name: "own_data"
}
assert {:ok, role} = Authorization.create_role(attrs)
assert {:ok, role} = Authorization.create_role(attrs, actor: actor)
assert role.permission_set_name == "own_data"
end
test "rejects invalid permission set names" do
test "rejects invalid permission set names", %{actor: actor} do
attrs = %{
name: "Test Role",
permission_set_name: "invalid_set"
}
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.create_role(attrs, actor: actor)
assert error_message(errors, :permission_set_name) =~ "must be one of"
end
test "accepts all four valid permission sets" do
test "accepts all four valid permission sets", %{actor: actor} do
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
for permission_set <- valid_sets do
@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do
permission_set_name: permission_set
}
assert {:ok, _role} = Authorization.create_role(attrs)
assert {:ok, _role} = Authorization.create_role(attrs, actor: actor)
end
end
end
@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do
{:ok, system_role} = Ash.create(changeset, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.destroy_role(system_role)
Authorization.destroy_role(system_role, actor: actor)
message = error_message(errors, :is_system_role)
assert message =~ "Cannot delete system role"
end
test "allows deletion of non-system roles" do
test "allows deletion of non-system roles", %{actor: actor} do
# is_system_role defaults to false, so regular create works
{:ok, regular_role} =
Authorization.create_role(%{
name: "Regular Role",
permission_set_name: "read_only"
})
Authorization.create_role(
%{name: "Regular Role", permission_set_name: "read_only"},
actor: actor
)
assert :ok = Authorization.destroy_role(regular_role)
assert :ok = Authorization.destroy_role(regular_role, actor: actor)
end
end
describe "name uniqueness" do
test "enforces unique role names" do
test "enforces unique role names", %{actor: actor} do
attrs = %{
name: "Unique Role",
permission_set_name: "own_data"
}
assert {:ok, _} = Authorization.create_role(attrs)
assert {:ok, _} = Authorization.create_role(attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.create_role(attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert error_message(errors, :name) =~ "has already been taken"
end
end

View file

@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
end
# Helper function to ensure admin role exists
# Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false)
defp ensure_admin_role do
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
nil ->
{:ok, role} =
Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
Authorization.create_role(
%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
},
authorize?: false
)
role
@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do
_ ->
{:ok, role} =
Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
Authorization.create_role(
%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
},
authorize?: false
)
role
end
@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do
test "raises error if system user has wrong role", %{system_user: system_user} do
# Create a non-admin role (using read_only as it's a valid permission set)
system_actor = SystemActor.get_system_actor()
{:ok, read_only_role} =
Authorization.create_role(%{
name: "Read Only Role",
description: "Read-only access",
permission_set_name: "read_only"
})
Authorization.create_role(
%{
name: "Read Only Role",
description: "Read-only access",
permission_set_name: "read_only"
},
actor: system_actor
)
system_actor = SystemActor.get_system_actor()

View file

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

View file

@ -11,7 +11,6 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
alias Mv.Membership.{CustomField, CustomFieldValue}
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
@ -20,47 +19,9 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
%{actor: system_actor}
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])}"
defp create_linked_member_for_user(user, _actor) do
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
# 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} =
Mv.Membership.create_member(
%{
@ -68,18 +29,20 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
actor: admin
)
user
|> Ash.Changeset.for_update(:update, %{})
|> 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
end
defp create_unlinked_member(actor) do
defp create_unlinked_member(_actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Mv.Membership.create_member(
%{
@ -87,25 +50,29 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
actor: admin
)
member
end
defp create_custom_field(actor) do
defp create_custom_field do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string
})
|> Ash.create(actor: actor)
|> Ash.create(actor: admin, domain: Mv.Membership)
field
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} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -113,22 +80,22 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
custom_field_id: custom_field_id,
value: %{"_union_type" => "string", "_union_value" => value}
})
|> Ash.create(actor: actor, domain: Mv.Membership)
|> Ash.create(actor: admin, domain: Mv.Membership)
cfv
end
describe "own_data permission set (Mitglied)" 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)
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 =
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} =
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", %{
user: user,
linked_member: linked_member,
actor: actor
actor: _actor
} do
# 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} =
CustomFieldValue
@ -257,15 +224,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" 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)
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 =
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} =
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
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)
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 =
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} =
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", %{
user: user,
unlinked_member: unlinked_member,
actor: actor
actor: _actor
} do
# normal_user cannot create CustomField; use actor (admin) to create it
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
{:ok, cfv} =
CustomFieldValue
@ -421,15 +388,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "admin permission set" 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)
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 =
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} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -457,7 +424,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
end
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} =
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

@ -0,0 +1,194 @@
defmodule Mv.Membership.MemberEmailValidationTest do
@moduledoc """
Tests for Member email-change permission validation.
When a member is linked to a user, only admins or the linked user may change
that member's email. Unlinked members and non-email updates are unaffected.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
system_actor = SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_linked_member_for_user(user, _actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
member
end
defp create_unlinked_member(_actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
member
end
describe "unlinked member" do
test "normal_user can update email of unlinked member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
unlinked_member = create_unlinked_member(actor)
new_email = "new#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
test "validation does not block when member has no linked user", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
unlinked_member = create_unlinked_member(actor)
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:ok, _} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
end
end
describe "linked member another user's member" do
test "normal_user cannot update email of another user's linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:error, %Ash.Error.Invalid{} = error} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user_b)
assert Enum.any?(error.errors, &(&1.field == :email)),
"expected an error for field :email, got: #{inspect(error.errors)}"
end
test "admin can update email of linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "linked member own member" do
test "own_data user can update email of their own linked member", %{actor: actor} do
own_data_user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(own_data_user, actor)
{:ok, own_data_user} =
Ash.get(Accounts.User, own_data_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, own_data_user} =
Ash.load(own_data_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "own_updated#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: own_data_user)
assert updated.email == new_email
end
test "normal_user with linked member can update email of that same member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
linked_member = create_linked_member_for_user(normal_user, actor)
{:ok, normal_user} =
Ash.get(Accounts.User, normal_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, normal_user} = Ash.load(normal_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "normal_own#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
end
describe "no-op / other fields" do
test "updating only other attributes on linked member as normal_user does not trigger validation error",
%{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
assert {:ok, updated} =
Membership.update_member(linked_member, %{first_name: "UpdatedName"},
actor: normal_user_b
)
assert updated.first_name == "UpdatedName"
assert updated.email == linked_member.email
end
test "updating email of linked member as admin succeeds", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "read_only" do
test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
linked_member = create_linked_member_for_user(read_only_user, actor)
{:ok, read_only_user} =
Ash.get(Accounts.User, read_only_user.id,
domain: Mv.Accounts,
load: [:role],
actor: actor
)
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{email: "changed@example.com"},
actor: read_only_user
)
end
end
end

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.Accounts
alias Mv.Authorization
require Ash.Query
@ -21,58 +20,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
%{actor: system_actor}
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
defp create_linked_member_for_user(user, actor) do
admin = create_admin_user(actor)
defp create_linked_member_for_user(user, _actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
# Create member
# NOTE: We need to ensure the member is actually persisted to the database
@ -105,8 +55,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member(actor) do
admin = create_admin_user(actor)
defp create_unlinked_member(_actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
@ -123,7 +73,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "own_data permission set (Mitglied)" 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)
unlinked_member = create_unlinked_member(actor)
@ -207,7 +157,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" 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)
unlinked_member = create_unlinked_member(actor)
@ -273,7 +223,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "normal_user permission set (Kassenwart)" 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)
unlinked_member = create_unlinked_member(actor)
@ -330,7 +280,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "admin permission set" 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)
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
# users can ALWAYS read their linked member, even if they had no read permission.
# This test verifies the special case works independently of permission sets.
user = create_user_with_permission_set("read_only", actor)
user = Mv.Fixtures.user_with_role_fixture("read_only")
linked_member = create_linked_member_for_user(user, actor)
# 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
# own_data has Member.read scope :linked, but the special case ensures
# users can ALWAYS read their linked member regardless of permission set.
user = create_user_with_permission_set("own_data", actor)
user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id
@ -437,7 +387,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
} do
# Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked.
user = create_user_with_permission_set("own_data", actor)
user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id
@ -453,4 +403,184 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert updated_member.first_name == "Updated"
end
end
describe "member user link - only admin may set or change user link" do
setup %{actor: actor} do
normal_user =
Mv.Fixtures.user_with_role_fixture("normal_user")
|> Mv.Authorization.Actor.ensure_loaded()
admin =
Mv.Fixtures.user_with_role_fixture("admin")
|> Mv.Authorization.Actor.ensure_loaded()
unlinked_member = create_unlinked_member(actor)
%{normal_user: normal_user, admin: admin, unlinked_member: unlinked_member}
end
test "normal_user can create member without :user argument", %{normal_user: normal_user} do
{:ok, member} =
Membership.create_member(
%{
first_name: "NoLink",
last_name: "Member",
email: "nolink#{System.unique_integer([:positive])}@example.com"
},
actor: normal_user
)
assert member.first_name == "NoLink"
# Member has_one :user (FK on User side); ensure no user is linked
{:ok, member} =
Ash.load(member, :user, domain: Mv.Membership, actor: normal_user)
assert is_nil(member.user)
end
test "normal_user cannot create member with :user argument (forbidden)", %{
normal_user: normal_user
} do
other_user =
Mv.Fixtures.user_with_role_fixture("read_only")
|> Mv.Authorization.Actor.ensure_loaded()
attrs = %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com",
user: %{id: other_user.id}
}
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member(attrs, actor: normal_user)
end
test "normal_user can update member without :user argument", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
{:ok, updated} =
Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"},
actor: normal_user
)
assert updated.first_name == "UpdatedByNormal"
end
test "normal_user cannot update member with :user argument (forbidden)", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
other_user =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}}
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(unlinked_member, params, actor: normal_user)
end
test "normal_user cannot update member with user: nil (unlink forbidden)", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
# Link member first (via admin), then normal_user tries to unlink via user: nil
admin =
Mv.Fixtures.user_with_role_fixture("admin") |> Mv.Authorization.Actor.ensure_loaded()
link_target =
Mv.Fixtures.user_with_role_fixture("own_data") |> Mv.Authorization.Actor.ensure_loaded()
{:ok, linked_member} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
# Passing user: nil explicitly tries to unlink; only admin may do that
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{user: nil}, actor: normal_user)
end
test "normal_user update linked member without :user keeps link", %{
normal_user: normal_user,
admin: admin,
unlinked_member: unlinked_member
} do
# Admin links member to a user
link_target =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
{:ok, linked_member} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
# normal_user updates only first_name (no :user) link must remain (on_missing: :ignore)
{:ok, updated} =
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: normal_user)
assert updated.first_name == "Updated"
{:ok, user} =
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
assert user.member_id == updated.id
end
test "admin can create member with :user argument", %{admin: admin} do
link_target =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
attrs = %{
first_name: "AdminLinked",
last_name: "Member",
email: "adminlinked#{System.unique_integer([:positive])}@example.com",
user: %{id: link_target.id}
}
{:ok, member} = Membership.create_member(attrs, actor: admin)
assert member.first_name == "AdminLinked"
{:ok, link_target} =
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
assert link_target.member_id == member.id
end
test "admin can update member with :user argument (link)", %{
admin: admin,
unlinked_member: unlinked_member
} do
link_target =
Mv.Fixtures.user_with_role_fixture("read_only")
|> Mv.Authorization.Actor.ensure_loaded()
{:ok, updated} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
assert updated.id == unlinked_member.id
{:ok, reloaded_user} =
Ash.get(Mv.Accounts.User, link_target.id,
domain: Mv.Accounts,
load: [:member],
actor: admin
)
assert reloaded_user.member_id == updated.id
end
end
end

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

@ -0,0 +1,49 @@
defmodule Mv.OidcRoleSyncConfigTest do
@moduledoc """
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
"""
use ExUnit.Case, async: false
alias Mv.OidcRoleSyncConfig
describe "oidc_admin_group_name/0" do
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
restore = put_config(admin_group_name: nil)
on_exit(restore)
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
end
test "returns configured admin group name when set" do
restore = put_config(admin_group_name: "mila-admin")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
end
end
describe "oidc_groups_claim/0" do
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
restore = put_config(groups_claim: nil)
on_exit(restore)
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
end
test "returns configured claim name when OIDC_GROUPS_CLAIM is set" do
restore = put_config(groups_claim: "ak_groups")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
end
end
defp put_config(opts) do
current = Application.get_env(:mv, :oidc_role_sync, [])
Application.put_env(:mv, :oidc_role_sync, Keyword.merge(current, opts))
fn ->
Application.put_env(:mv, :oidc_role_sync, current)
end
end
end

View file

@ -0,0 +1,181 @@
defmodule Mv.OidcRoleSyncTest do
@moduledoc """
Tests for OIDC group Admin/Mitglied role sync (apply_admin_role_from_user_info/2).
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Accounts.User
alias Mv.Authorization.Role
alias Mv.OidcRoleSync
require Ash.Query
setup do
ensure_roles_exist()
restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups")
on_exit(restore_config)
:ok
end
describe "apply_admin_role_from_user_info/2" do
test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do
restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups")
on_exit(restore)
email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user} = create_user_with_mitglied(email)
role_id_before = user.role_id
user_info = %{"groups" => ["mila-admin"]}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
{:ok, after_user} = get_user(user.id)
assert after_user.role_id == role_id_before
end
test "when user_info contains configured admin group: user gets Admin role" do
email = "sync-to-admin-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user} = create_user_with_mitglied(email)
user_info = %{"groups" => ["mila-admin"]}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
{:ok, after_user} = get_user(user.id)
assert after_user.role_id == admin_role_id()
end
test "when user_info does not contain admin group: user gets Mitglied role" do
email1 = "sync-to-mitglied-#{System.unique_integer([:positive])}@test.example.com"
email2 = "other-admin-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user} = create_user_with_admin(email1)
{:ok, _} = create_user_with_admin(email2)
user_info = %{"groups" => ["other-group"]}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
{:ok, after_user} = get_user(user.id)
assert after_user.role_id == mitglied_role_id()
end
test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do
restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups")
on_exit(restore)
email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user} = create_user_with_mitglied(email)
user_info = %{"ak_groups" => ["mila-admin"]}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
{:ok, after_user} = get_user(user.id)
assert after_user.role_id == admin_role_id()
end
test "user already Admin and user_info without admin group: downgrade to Mitglied" do
email1 = "sync-downgrade-#{System.unique_integer([:positive])}@test.example.com"
email2 = "sync-other-admin-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user1} = create_user_with_admin(email1)
{:ok, _user2} = create_user_with_admin(email2)
user_info = %{"groups" => []}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user1, user_info)
{:ok, after_user} = get_user(user1.id)
assert after_user.role_id == mitglied_role_id()
end
test "when user_info has no groups, groups are read from access_token JWT (e.g. Rauthy)" do
email = "sync-from-token-#{System.unique_integer([:positive])}@test.example.com"
{:ok, user} = create_user_with_mitglied(email)
user_info = %{"sub" => "oidc-123"}
# Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token)
payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"})
payload_b64 = Base.url_encode64(payload, padding: false)
header_b64 = Base.url_encode64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", padding: false)
sig_b64 = Base.url_encode64("sig", padding: false)
access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}"
oauth_tokens = %{"access_token" => access_token}
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
{:ok, after_user} = get_user(user.id)
assert after_user.role_id == admin_role_id()
end
end
# B3: Role sync after registration is implemented via after_action in register_with_rauthy.
# Full integration tests (create_register_with_rauthy + assert role) are skipped: when the
# nested Ash.update! runs inside the create's after_action, authorization may evaluate in
# the create context so set_role_from_oidc_sync bypass does not apply. Sync logic is covered
# by the apply_admin_role_from_user_info tests above. B4 sign-in sync will also use that.
defp ensure_roles_exist do
for {name, perm} <- [{"Admin", "admin"}, {"Mitglied", "own_data"}] do
case Role
|> Ash.Query.filter(name == ^name)
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
{:ok, nil} ->
Role
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
name: name,
description: name,
permission_set_name: perm,
is_system_role: name == "Mitglied"
})
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
_ ->
:ok
end
end
end
defp put_oidc_config(opts) do
current = Application.get_env(:mv, :oidc_role_sync, [])
merged = Keyword.merge(current, opts)
Application.put_env(:mv, :oidc_role_sync, merged)
fn ->
Application.put_env(:mv, :oidc_role_sync, current)
end
end
defp admin_role_id do
{:ok, role} = Role.get_admin_role()
role.id
end
defp mitglied_role_id do
{:ok, role} = Role.get_mitglied_role()
role.id
end
defp get_user(id) do
User
|> Ash.Query.filter(id == ^id)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
end
defp create_user_with_mitglied(email) do
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
get_user_by_email(email)
end
defp create_user_with_admin(email) do
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
{:ok, u} = get_user_by_email(email)
u
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_id()})
|> Ash.update!(authorize?: false)
get_user(u.id)
end
defp get_user_by_email(email) do
User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
end
end

222
test/mv/release_test.exs Normal file
View file

@ -0,0 +1,222 @@
defmodule Mv.ReleaseTest do
@moduledoc """
Tests for release tasks (e.g. seed_admin/0).
These tests verify that the admin user is created or updated from ENV
(ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE) in an idempotent way.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Accounts.User
alias Mv.Authorization.Role
require Ash.Query
setup do
ensure_admin_role_exists()
clear_admin_env()
:ok
end
describe "seed_admin/0" do
test "without ADMIN_EMAIL does nothing (idempotent), no user created" do
clear_admin_env()
user_count_before = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_before
end
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user does not exist: does not create user" do
System.delete_env("ADMIN_PASSWORD")
System.delete_env("ADMIN_PASSWORD_FILE")
email = "admin-no-password-#{System.unique_integer([:positive])}@test.example.com"
System.put_env("ADMIN_EMAIL", email)
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
user_count_before = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_before,
"seed_admin must not create any user when ADMIN_PASSWORD is unset (expected #{user_count_before}, got #{count_users()})"
end
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user exists: sets Admin role (OIDC-only bootstrap)" do
System.delete_env("ADMIN_PASSWORD")
System.delete_env("ADMIN_PASSWORD_FILE")
email = "existing-admin-#{System.unique_integer([:positive])}@test.example.com"
System.put_env("ADMIN_EMAIL", email)
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
{:ok, _user} = create_user_with_mitglied_role(email)
Mv.Release.seed_admin()
{:ok, updated} = get_user_by_email(email)
assert updated.role_id == admin_role_id()
end
test "with ADMIN_EMAIL and ADMIN_PASSWORD: creates user with Admin role and sets password" do
email = "new-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "SecurePassword123!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
Mv.Release.seed_admin()
assert user_exists?(email),
"seed_admin must create user when ADMIN_EMAIL and ADMIN_PASSWORD are set"
{:ok, user} = get_user_by_email(email)
assert user.role_id == admin_role_id()
assert user.hashed_password != nil
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
end
test "with ADMIN_EMAIL and ADMIN_PASSWORD, user already exists: assigns Admin role and updates password" do
email = "existing-to-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "NewSecurePassword456!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
{:ok, user} = create_user_with_mitglied_role(email)
assert user.role_id == mitglied_role_id()
old_hashed = user.hashed_password
Mv.Release.seed_admin()
{:ok, updated} = get_user_by_email(email)
assert updated.role_id == admin_role_id()
assert updated.hashed_password != nil
assert updated.hashed_password != old_hashed
assert AshAuthentication.BcryptProvider.valid?(password, updated.hashed_password)
end
test "with ADMIN_PASSWORD_FILE: reads password from file, same behavior as ADMIN_PASSWORD" do
email = "admin-file-#{System.unique_integer([:positive])}@test.example.com"
password = "FilePassword789!"
tmp =
Path.join(
System.tmp_dir!(),
"mv_admin_password_#{System.unique_integer([:positive])}.txt"
)
File.write!(tmp, password)
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD_FILE", tmp)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD_FILE")
File.rm(tmp)
end)
Mv.Release.seed_admin()
assert user_exists?(email), "seed_admin must create user when ADMIN_PASSWORD_FILE is set"
{:ok, user} = get_user_by_email(email)
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
end
test "called twice: idempotent (no duplicate user, same state)" do
email = "idempotent-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "IdempotentPassword123!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
Mv.Release.seed_admin()
{:ok, user_after_first} = get_user_by_email(email)
user_count_after_first = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_after_first
{:ok, user_after_second} = get_user_by_email(email)
assert user_after_second.id == user_after_first.id
assert user_after_second.role_id == admin_role_id()
end
end
defp clear_admin_env do
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
System.delete_env("ADMIN_PASSWORD_FILE")
end
defp ensure_admin_role_exists do
case Role
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
{:ok, nil} ->
Role
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin",
is_system_role: false
})
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
_ ->
:ok
end
end
defp admin_role_id do
{:ok, role} =
Role
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
role.id
end
defp mitglied_role_id do
{:ok, role} = Role.get_mitglied_role()
role.id
end
defp count_users do
User
|> Ash.read!(authorize?: false, domain: Mv.Accounts)
|> length()
end
defp user_exists?(email) do
case get_user_by_email(email) do
{:ok, _} -> true
{:error, _} -> false
end
end
defp get_user_by_email(email) do
User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
end
defp create_user_with_mitglied_role(email) do
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
get_user_by_email(email)
end
end

View file

@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
end
test "non-admin cannot manage roles" do
test "non-admin can read roles but cannot create/update/destroy" do
normal_user = %{
id: "normal-123",
role: %{permission_set_name: "normal_user"}
}
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
end

View file

@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
# =============================================================================
# Returns assigns for an authenticated user with all required attributes.
# User has admin role so can_access_page? returns true for all sidebar links.
defp authenticated_assigns(mobile \\ false) do
%{
current_user: %{id: "user-123", email: "test@example.com"},
current_user: %{
id: "user-123",
email: "test@example.com",
role: %{permission_set_name: "admin"}
},
club_name: "Test Club",
mobile: mobile
}
@ -144,7 +149,9 @@ defmodule MvWeb.Layouts.SidebarTest do
assert menu_item_count > 0, "Should have at least one top-level menu item"
# Check that nested menu groups exist
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group")
assert has_class?(html, "expanded-menu-group")
@ -193,7 +200,9 @@ defmodule MvWeb.Layouts.SidebarTest do
html = render_sidebar(authenticated_assigns())
# Check for nested menu structure
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
@ -521,7 +530,9 @@ defmodule MvWeb.Layouts.SidebarTest do
assert html =~ ~s(role="menuitem")
# Check that nested menus exist
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group")
# Footer section
@ -629,7 +640,9 @@ defmodule MvWeb.Layouts.SidebarTest do
html = render_sidebar(authenticated_assigns())
# expanded-menu-group structure present
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
@ -843,7 +856,9 @@ defmodule MvWeb.Layouts.SidebarTest do
# Expanded menu group should have correct structure
# (CSS handles hover effects, but we verify structure)
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group")
end

View file

@ -0,0 +1,120 @@
defmodule MvWeb.SidebarAuthorizationTest do
@moduledoc """
Tests for sidebar menu visibility based on user permissions (can_access_page?).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import MvWeb.Layouts.Sidebar
alias Mv.Fixtures
defp render_sidebar(assigns) do
render_component(&sidebar/1, assigns)
end
defp sidebar_assigns(current_user, opts \\ []) do
mobile = Keyword.get(opts, :mobile, false)
club_name = Keyword.get(opts, :club_name, "Test Club")
%{
current_user: current_user,
club_name: club_name,
mobile: mobile
}
end
describe "sidebar menu with admin user" do
test "shows Members, Fee Types and Administration with all subitems" do
user = Fixtures.user_with_role_fixture("admin")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/membership_fee_types")
assert html =~ ~s(data-testid="sidebar-administration")
assert html =~ ~s(href="/users")
assert html =~ ~s(href="/groups")
assert html =~ ~s(href="/admin/roles")
assert html =~ ~s(href="/membership_fee_settings")
assert html =~ ~s(href="/settings")
end
end
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
test "shows Members and Groups (from Administration)" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/groups")
end
test "does not show Fee Types, Users, Roles or Settings" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/admin/roles")
refute html =~ ~s(href="/settings")
end
end
describe "sidebar menu with normal_user (Kassenwart)" do
test "shows Members and Groups" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/groups")
end
test "does not show Fee Types, Users, Roles or Settings" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/admin/roles")
refute html =~ ~s(href="/settings")
end
end
describe "sidebar menu with own_data user (Mitglied)" do
test "does not show Members link (no /members page access)" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/members")
end
test "does not show Fee Types or Administration" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(data-testid="sidebar-administration")
end
end
describe "sidebar with nil current_user" do
test "does not render menu items (only header and footer when present)" do
html = render_sidebar(sidebar_assigns(nil))
refute html =~ ~s(role="menubar")
refute html =~ ~s(href="/members")
end
end
describe "sidebar with user without role" do
test "does not show any navigation links" do
user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/members")
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
end
end
end

View file

@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do
assert is_nil(new_user.hashed_password)
# Verify user can be found by oidc_id
{:ok, [found_user]} =
result =
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do
actor: actor
)
found_user =
case result do
{:ok, u} when is_struct(u) -> u
{:ok, [u]} -> u
_ -> flunk("Expected user, got: #{inspect(result)}")
end
assert found_user.id == new_user.id
end
end
@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do
assert linked_user.hashed_password == password_user.hashed_password
# Step 5: User can now sign in via OIDC
{:ok, [signed_in_user]} =
result =
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do
actor: actor
)
signed_in_user =
case result do
{:ok, u} when is_struct(u) -> u
{:ok, [u]} -> u
_ -> flunk("Expected user, got: #{inspect(result)}")
end
assert signed_in_user.id == password_user.id
assert signed_in_user.oidc_id == "oidc_link_888"
end
@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{}} ->
:ok

View file

@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do
# Test sign_in_with_rauthy action directly
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
result =
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do
actor: system_actor
)
found_user =
case result do
{:ok, u} when is_struct(u) -> u
{:ok, [u]} -> u
_ -> flunk("Expected user, got: #{inspect(result)}")
end
assert found_user.id == user.id
assert to_string(found_user.email) == "existing@example.com"
assert found_user.oidc_id == "existing_oidc_123"
@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
:ok
@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
result =
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: correct_user_info,
@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do
actor: system_actor
)
found_user =
case result do
{:ok, u} when is_struct(u) -> u
{:ok, [u]} -> u
_ -> flunk("Expected user, got: #{inspect(result)}")
end
assert found_user.id == user.id
# Try with wrong oidc_id but correct email
@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
# Either returns empty/nil OR authentication error - both mean "user not found"
case result do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
:ok
@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
# Either returns empty/nil OR authentication error - both mean "user not found"
case result do
{:ok, []} ->
:ok
{:ok, nil} ->
:ok
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
:ok

View file

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

View file

@ -0,0 +1,102 @@
defmodule MvWeb.MemberLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on Member LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
describe "Member Index - Vorstand (read_only)" do
@tag role: :read_only
test "sees member list but not New Member button", %{conn: conn} do
_member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "[data-testid=member-new]")
end
@tag role: :read_only
test "does not see Edit or Delete buttons in table", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Kassenwart (normal_user)" do
@tag role: :normal_user
test "sees New Member and Edit buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
end
@tag role: :normal_user
test "does not see Delete button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Admin" do
@tag role: :admin
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Mitglied (own_data)" do
@tag role: :member
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
assert to == "/users/#{user.id}"
end
end
describe "Member Show - Edit button visibility" do
@tag role: :admin
test "admin sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
assert has_element?(view, "[data-testid=member-edit]")
end
@tag role: :read_only
test "read_only does not see Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
refute has_element?(view, "[data-testid=member-edit]")
end
@tag role: :normal_user
test "normal_user sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
assert has_element?(view, "[data-testid=member-edit]")
end
end
end

View file

@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
end
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")
form_data = %{
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert to == "/membership_fee_types"
# Verify type was created
# Verify type was created (use actor so read is authorized)
type =
MembershipFeeType
|> 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.interval == :yearly
end
@ -140,7 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
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")})
{: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"})
|> render_submit()
# Amount should be updated
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
# Amount should be updated (use actor so read is authorized)
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")
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")})
{: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']")
|> render_click()
# Amount should remain unchanged
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
# Amount should remain unchanged (use actor so read is authorized)
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")
end

View file

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

View file

@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do
alias Mv.Authorization
alias Mv.Authorization.Role
# Helper to create a role
# Helper to create a role (authorize?: false for test data setup)
defp create_role(attrs \\ %{}) do
default_attrs = %{
name: "Test Role #{System.unique_integer([:positive])}",
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do
attrs = Map.merge(default_attrs, attrs)
case Authorization.create_role(attrs) do
case Authorization.create_role(attrs, authorize?: false) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin")) do
nil ->

View file

@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do
alias Mv.Authorization
alias Mv.Authorization.Role
# Helper to create a role
# Helper to create a role (authorize?: false for test data setup)
defp create_role(attrs \\ %{}) do
default_attrs = %{
name: "Test Role #{System.unique_integer([:positive])}",
@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do
attrs = Map.merge(default_attrs, attrs)
case Authorization.create_role(attrs) do
case Authorization.create_role(attrs, authorize?: false) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin")) do
nil ->
@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
end
test "updates role name", %{conn: conn, role: role} do
test "updates role name", %{conn: conn, role: role, actor: actor} do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
attrs = %{
@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do
assert_redirect(view, "/admin/roles/#{role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(role.id)
{:ok, updated_role} = Authorization.get_role(role.id, actor: actor)
assert updated_role.name == "Updated Role Name"
end
@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do
assert_redirect(view, "/admin/roles/#{system_role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(system_role.id)
{:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor)
assert updated_role.permission_set_name == "read_only"
end
end
@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do
end
@tag :slow
test "deletes non-system role", %{conn: conn} do
test "deletes non-system role", %{conn: conn, actor: actor} do
role = create_role()
{:ok, view, html} = live(conn, "/admin/roles")
@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do
# Verify deletion by checking database
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Authorization.get_role(role.id)
Authorization.get_role(role.id, actor: actor)
end
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do
assert render(view) =~ "System roles cannot be deleted"
# Role should still exist
{:ok, _role} = Authorization.get_role(system_role.id)
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
end
end

View file

@ -0,0 +1,81 @@
defmodule MvWeb.UserLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on User LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
describe "User Index - Admin" do
@tag role: :admin
test "sees New User, Edit and Delete buttons", %{conn: conn} do
user = Fixtures.user_with_role_fixture("admin")
{:ok, view, _html} = live(conn, "/users")
assert has_element?(view, "[data-testid=user-new]")
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
end
end
describe "User Index - Non-Admin is redirected" do
@tag role: :read_only
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :member
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :normal_user
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
end
describe "User Show - own profile" do
@tag role: :member
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
@tag role: :read_only
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
@tag role: :admin
test "admin sees Edit button on user show", %{conn: conn} do
user = Fixtures.user_with_role_fixture("read_only")
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
end
describe "User Show - other user (non-admin redirected)" do
@tag role: :member
test "member is redirected when accessing other user's profile", %{
conn: conn,
current_user: current_user
} do
other_user = Fixtures.user_with_role_fixture("admin")
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
assert to == "/users/#{current_user.id}"
end
end
end

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})
# Load cycles with membership_fee_type relationship
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
# Use fixed date in 2024 to ensure 2023 is last completed
# 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
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
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)
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
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})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
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})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
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})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
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})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
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})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
# 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)
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
defp create_cycle(member, fee_type, attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -73,7 +58,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "cycles table display" do
test "displays all cycles for member", %{conn: conn} do
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})
_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
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, %{
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"})
_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}")
@ -132,20 +117,30 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
assert html =~ "Yearly Type"
end
test "shows no type message when no type assigned", %{conn: conn} do
member = create_member(%{})
test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{
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
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
describe "status change actions" do
test "mark as paid works", %{conn: conn} do
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})
@ -176,7 +171,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
test "mark as suspended works", %{conn: conn} do
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})
@ -207,7 +202,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
test "mark as unpaid works", %{conn: conn} do
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})
@ -240,7 +235,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "cycle regeneration" do
test "manual regeneration button exists and can be clicked", %{conn: conn} do
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}")
@ -266,7 +261,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
describe "edge cases" do
test "handles members without membership fee type gracefully", %{conn: conn} do
# No fee type
member = create_member(%{})
member = Mv.Fixtures.member_fixture(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
@ -274,4 +269,120 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
assert html =~ member.first_name
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

View file

@ -742,6 +742,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
assert conn.status == 200
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
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}/show/edit")
@ -830,22 +842,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
assert redirected_to(conn) == "/users/#{user.id}"
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
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/admin/roles")

Some files were not shown because too many files have changed in this diff Show more