Compare commits
85 commits
feature/ex
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc02748cc6 | |||
| ad54b0c462 | |||
| 6ab0365a8c | |||
| ad42a53919 | |||
| c5f1fdce0a | |||
| d573a22769 | |||
| 58a5b086ad | |||
| d441009c8a | |||
| d37fc03a37 | |||
| 55fef5a993 | |||
| 99722dee26 | |||
| a6e35da0f7 | |||
| 50c8a0dc9a | |||
| e065b39ed4 | |||
| b177e41882 | |||
| 09a4b7c937 | |||
| 7a56a0920b | |||
| 59d94cf1c6 | |||
| 361331b76e | |||
| 3415faeb21 | |||
| 82b3182267 | |||
| 95472424b1 | |||
| 5194b20b5c | |||
| 543fded102 | |||
| 34e049ef32 | |||
| 54e419ed4c | |||
| 26fbafdd9d | |||
| 4d3a64c177 | |||
| 10f37a1246 | |||
| 40e75f4066 | |||
| f7ba98c36b | |||
|
|
6aadf4f93b | ||
| d13fbef890 | |||
| 083592489f | |||
| 24d130ffb5 | |||
| 503401f2e6 | |||
| d7c6d20483 | |||
| b6d1a27bc9 | |||
| 541c79e501 | |||
| c6082f2831 | |||
| 7eba21dc9c | |||
| c035d0f141 | |||
| 178f5a01c7 | |||
| 890a4d3752 | |||
| 67ce514ba0 | |||
| dbd0a57292 | |||
| 03d3a7eb1b | |||
| a2e1054c8d | |||
| 3a92398d54 | |||
| 085b6be769 | |||
| 182d34fe58 | |||
| e799f0271c | |||
| c4459ebb92 | |||
| 101fd39f18 | |||
| e3bea17827 | |||
| 8ec4a07103 | |||
| 5ed41555e9 | |||
| 5889683854 | |||
| 893f9453bd | |||
| 36b7031dca | |||
|
|
fa5afba6ba | ||
| 0c313824fb | |||
|
|
f45ae66f18 | ||
| c2bafe4acf | |||
| cbc9376b7b | |||
| ee6bfbacbb | |||
| a4b13cef49 | |||
| 286972964d | |||
| c36812bf3f | |||
| 2ddd22078d | |||
| 9e8910344e | |||
| 1426ef1d38 | |||
| f779fd61e0 | |||
| cc9e530d80 | |||
| 2f67c7099d | |||
| 5e361ba400 | |||
| 505e31653a | |||
| d3ad7c5013 | |||
| 131904f172 | |||
| 47b6a16177 | |||
| 60a4181255 | |||
| 4e6b7305b6 | |||
| 4ea31f0f37 | |||
| ad02f8914f | |||
| 3d46ba655f |
120 changed files with 6081 additions and 3431 deletions
|
|
@ -273,7 +273,7 @@ environment:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: renovate
|
- name: renovate
|
||||||
image: renovate/renovate:42.81
|
image: renovate/renovate:42.95
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
RENOVATE_TOKEN:
|
RENOVATE_TOKEN:
|
||||||
|
|
|
||||||
13
.env.example
13
.env.example
|
|
@ -11,9 +11,22 @@ PHX_HOST=localhost
|
||||||
# Recommended: Association settings
|
# Recommended: Association settings
|
||||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
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
|
# Optional: OIDC Configuration
|
||||||
# These have defaults in docker-compose.prod.yml, only override if needed
|
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||||
# OIDC_CLIENT_ID=mv
|
# OIDC_CLIENT_ID=mv
|
||||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ lib/
|
||||||
├── membership/ # Membership domain
|
├── membership/ # Membership domain
|
||||||
│ ├── membership.ex # Domain definition
|
│ ├── membership.ex # Domain definition
|
||||||
│ ├── member.ex # Member resource
|
│ ├── member.ex # Member resource
|
||||||
|
│ ├── custom_field.ex # Custom field (definition) resource
|
||||||
│ ├── custom_field_value.ex # Custom field value resource
|
│ ├── custom_field_value.ex # Custom field value resource
|
||||||
│ ├── custom_field.ex # CustomFieldValue type resource
|
|
||||||
│ ├── setting.ex # Global settings (singleton resource)
|
│ ├── setting.ex # Global settings (singleton resource)
|
||||||
│ ├── group.ex # Group resource
|
│ ├── group.ex # Group resource
|
||||||
│ ├── member_group.ex # MemberGroup join table resource
|
│ ├── member_group.ex # MemberGroup join table resource
|
||||||
|
|
@ -198,7 +198,8 @@ test/
|
||||||
├── seeds_test.exs # Database seed tests
|
├── seeds_test.exs # Database seed tests
|
||||||
└── support/ # Test helpers
|
└── support/ # Test helpers
|
||||||
├── conn_case.ex # Controller test helpers
|
├── conn_case.ex # Controller test helpers
|
||||||
└── data_case.ex # Data layer test helpers
|
├── data_case.ex # Data layer test helpers
|
||||||
|
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 Module Organization
|
### 1.2 Module Organization
|
||||||
|
|
@ -1339,7 +1340,8 @@ test/
|
||||||
│ └── components/
|
│ └── components/
|
||||||
└── support/ # Test helpers
|
└── support/ # Test helpers
|
||||||
├── conn_case.ex # Controller test setup
|
├── conn_case.ex # Controller test setup
|
||||||
└── data_case.ex # Database test setup
|
├── data_case.ex # Database test setup
|
||||||
|
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test File Naming:**
|
**Test File Naming:**
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ config :mv,
|
||||||
max_rows: 1000
|
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
|
# Configures the endpoint
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
url: [host: "localhost"],
|
url: [host: "localhost"],
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ if System.get_env("PHX_SERVER") do
|
||||||
config :mv, MvWeb.Endpoint, server: true
|
config :mv, MvWeb.Endpoint, server: true
|
||||||
end
|
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
|
if config_env() == :prod do
|
||||||
database_url = build_database_url.()
|
database_url = build_database_url.()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ services:
|
||||||
|
|
||||||
rauthy:
|
rauthy:
|
||||||
container_name: rauthy-dev
|
container_name: rauthy-dev
|
||||||
image: ghcr.io/sebadob/rauthy:0.33.4
|
image: ghcr.io/sebadob/rauthy:0.34.2
|
||||||
environment:
|
environment:
|
||||||
- LOCAL_TEST=true
|
- LOCAL_TEST=true
|
||||||
- SMTP_URL=mailcrab
|
- SMTP_URL=mailcrab
|
||||||
|
|
|
||||||
54
docs/admin-bootstrap-and-oidc-role-sync.md
Normal file
54
docs/admin-bootstrap-and-oidc-role-sync.md
Normal 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.
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
||||||
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
||||||
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
**Feature:** Groups Management
|
**Feature:** Groups Management
|
||||||
**Version:** 1.0
|
**Version:** 1.0
|
||||||
**Last Updated:** 2025-01-XX
|
**Last Updated:** 2025-01-XX
|
||||||
**Status:** Architecture Design - Ready for Implementation
|
**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -412,15 +412,17 @@ lib/
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented. Group and MemberGroup resource policies and PermissionSets are in place. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
|
||||||
|
|
||||||
### Permission Model (MVP)
|
### Permission Model (MVP)
|
||||||
|
|
||||||
**Resource:** `groups`
|
**Resource:** `Group` (and `MemberGroup`)
|
||||||
|
|
||||||
**Actions:**
|
**Actions:**
|
||||||
- `read` - View groups (all users with member read permission)
|
- `read` - View groups (all permission sets)
|
||||||
- `create` - Create groups (admin only)
|
- `create` - Create groups (normal_user and admin)
|
||||||
- `update` - Edit groups (admin only)
|
- `update` - Edit groups (normal_user and admin)
|
||||||
- `destroy` - Delete groups (admin only)
|
- `destroy` - Delete groups (normal_user and admin)
|
||||||
|
|
||||||
**Scopes:**
|
**Scopes:**
|
||||||
- `:all` - All groups (for all permission sets that have read access)
|
- `:all` - All groups (for all permission sets that have read access)
|
||||||
|
|
@ -442,7 +444,7 @@ lib/
|
||||||
**Own Data Permission Set:**
|
**Own Data Permission Set:**
|
||||||
- `read` action on `Group` resource with `:all` scope - granted
|
- `read` action on `Group` resource with `:all` scope - granted
|
||||||
|
|
||||||
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
|
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. normal_user and admin can manage (create/update/destroy) groups.
|
||||||
|
|
||||||
### Member-Group Association Permissions
|
### Member-Group Association Permissions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -334,20 +334,18 @@ lib/
|
||||||
|
|
||||||
### Permission System Integration
|
### Permission System Integration
|
||||||
|
|
||||||
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
|
||||||
|
|
||||||
**Required Permissions:**
|
**PermissionSets (lib/mv/authorization/permission_sets.ex):**
|
||||||
|
|
||||||
- `MembershipFeeType.create/update/destroy` - Admin only
|
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
|
||||||
- `MembershipFeeType.read` - Admin, Treasurer, Board
|
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
|
||||||
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
|
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor.
|
||||||
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
|
|
||||||
|
|
||||||
**Policy Patterns:**
|
**Resource Policies:**
|
||||||
|
|
||||||
- Use existing HasPermission check
|
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
|
||||||
- Leverage existing roles (Admin, Kassenwart)
|
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
|
||||||
- Member can read own cycles (linked via member_id)
|
|
||||||
|
|
||||||
### LiveView Integration
|
### LiveView Integration
|
||||||
|
|
||||||
|
|
@ -357,7 +355,7 @@ lib/
|
||||||
2. MembershipFeeCycle table component (member detail view)
|
2. MembershipFeeCycle table component (member detail view)
|
||||||
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
|
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
|
||||||
- Displays all cycles in a table with status management
|
- Displays all cycles in a table with status management
|
||||||
- Allows changing cycle status, editing amounts, and regenerating cycles
|
- Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
|
||||||
3. Settings form section (admin)
|
3. Settings form section (admin)
|
||||||
4. Member list column (membership fee status)
|
4. Member list column (membership fee status)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ Control CRUD operations on:
|
||||||
- CustomFieldValue (custom field values)
|
- CustomFieldValue (custom field values)
|
||||||
- CustomField (custom field definitions)
|
- CustomField (custom field definitions)
|
||||||
- Role (role management)
|
- Role (role management)
|
||||||
|
- Group (group definitions; read all, create/update/destroy normal_user and admin)
|
||||||
|
- MemberGroup (member–group associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
|
||||||
|
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
|
||||||
|
- MembershipFeeCycle (fee cycles; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
|
||||||
|
|
||||||
**4. Page-Level Permissions**
|
**4. Page-Level Permissions**
|
||||||
|
|
||||||
|
|
@ -105,6 +109,7 @@ Control access to LiveView pages:
|
||||||
- Show pages (detail views)
|
- Show pages (detail views)
|
||||||
- Form pages (create/edit)
|
- Form pages (create/edit)
|
||||||
- Admin pages
|
- Admin pages
|
||||||
|
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
|
||||||
|
|
||||||
**5. Granular Scopes**
|
**5. Granular Scopes**
|
||||||
|
|
||||||
|
|
@ -121,6 +126,8 @@ Three scope levels for permissions:
|
||||||
- **Linked Member Email:** Only admins can edit email of member linked to user
|
- **Linked Member Email:** Only admins can edit email of member linked to user
|
||||||
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
|
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
|
||||||
- **User-Member Linking:** Only admins can link/unlink users and members
|
- **User-Member Linking:** Only admins can link/unlink users and members
|
||||||
|
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
|
||||||
|
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
|
||||||
|
|
||||||
**7. UI Consistency**
|
**7. UI Consistency**
|
||||||
|
|
||||||
|
|
@ -684,6 +691,12 @@ Quick reference table showing what each permission set allows:
|
||||||
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||||
| **CustomField** (all) | R | R | R | R, C, U, D |
|
| **CustomField** (all) | R | R | R | R, C, U, D |
|
||||||
| **Role** (all) | - | - | - | R, C, U, D |
|
| **Role** (all) | - | - | - | R, C, U, D |
|
||||||
|
| **Group** (all) | R | R | R, C, U, D | R, C, U, D |
|
||||||
|
| **MemberGroup** (linked) | R | - | - | - |
|
||||||
|
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
|
||||||
|
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
|
||||||
|
| **MembershipFeeCycle** (linked) | R | - | - | - |
|
||||||
|
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||||
|
|
||||||
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
||||||
|
|
||||||
|
|
@ -1012,16 +1025,21 @@ defmodule Mv.Membership.Member do
|
||||||
authorize_if expr(id == ^actor(:member_id))
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# 2. GENERAL: Check permissions from role
|
# 2. READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||||||
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
|
policy action_type([:read, :destroy]) do
|
||||||
# - :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
|
|
||||||
description "Check permissions from user's role"
|
description "Check permissions from user's role"
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
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)
|
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||||
end
|
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 ✅
|
- **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 ✅
|
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
|
||||||
|
|
||||||
|
**User–member 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:**
|
**Permission Matrix:**
|
||||||
|
|
||||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||||
|
|
@ -1135,23 +1155,20 @@ end
|
||||||
|
|
||||||
**Location:** `lib/mv/authorization/role.ex`
|
**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
|
```elixir
|
||||||
defmodule Mv.Authorization.Role do
|
defmodule Mv.Authorization.Role do
|
||||||
use Ash.Resource, ...
|
use Ash.Resource,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# Only admin can manage roles
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
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
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
# DEFAULT: Forbid
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
|
||||||
forbid_if always()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prevent deletion of system roles
|
# Prevent deletion of system roles
|
||||||
|
|
@ -1188,13 +1205,43 @@ end
|
||||||
|
|
||||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||||
|--------|----------|----------|------------|-------------|-------|
|
|--------|----------|----------|------------|-------------|-------|
|
||||||
| Read | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||||
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||||
| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
*Cannot destroy if `is_system_role=true`
|
*Cannot destroy if `is_system_role=true`
|
||||||
|
|
||||||
|
### User Role Assignment (Admin-Only)
|
||||||
|
|
||||||
|
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
|
||||||
|
|
||||||
|
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
|
||||||
|
|
||||||
|
### Group Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership/group.ex`
|
||||||
|
|
||||||
|
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
|
||||||
|
|
||||||
|
### MemberGroup Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership/member_group.ex`
|
||||||
|
|
||||||
|
Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).
|
||||||
|
|
||||||
|
### MembershipFeeType Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership_fees/membership_fee_type.ex`
|
||||||
|
|
||||||
|
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
|
||||||
|
|
||||||
|
### MembershipFeeCycle Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
|
||||||
|
|
||||||
|
Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page Permission System
|
## Page Permission System
|
||||||
|
|
@ -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 cannot link themselves to an existing member
|
||||||
- A user CAN create a new member and be directly linked to it (self-service)
|
- 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 user–member 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
|
### Approach: Separate Ash Actions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,11 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
|
||||||
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
||||||
- ✅ Role database table and CRUD interface
|
- ✅ Role database table and CRUD interface
|
||||||
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
||||||
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
|
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
|
||||||
- ✅ Page-level permissions via Phoenix Plug
|
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
|
||||||
- ✅ UI authorization helpers for conditional rendering
|
- ✅ UI authorization helpers for conditional rendering
|
||||||
- ✅ Special case: Member email validation for linked users
|
- ✅ Special case: Member email validation for linked users
|
||||||
|
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
|
||||||
- ✅ Seed data for 5 roles
|
- ✅ Seed data for 5 roles
|
||||||
|
|
||||||
**Benefits of Hardcoded Approach:**
|
**Benefits of Hardcoded Approach:**
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ defmodule Mv.Accounts.User do
|
||||||
extensions: [AshAuthentication],
|
extensions: [AshAuthentication],
|
||||||
authorizers: [Ash.Policy.Authorizer]
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "users"
|
table "users"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -146,9 +149,10 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
update :update_user do
|
update :update_user do
|
||||||
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
||||||
# Only accept email directly - member_id is NOT in accept list
|
|
||||||
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
|
||||||
accept [:email]
|
# member_id is NOT in accept list - use argument :member for relationship management.
|
||||||
|
accept [:email, :role_id]
|
||||||
# Allow member to be passed as argument for relationship management
|
# Allow member to be passed as argument for relationship management
|
||||||
argument :member, :map, allow_nil?: true
|
argument :member, :map, allow_nil?: true
|
||||||
|
|
||||||
|
|
@ -183,6 +187,13 @@ defmodule Mv.Accounts.User do
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
end
|
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
|
# Admin action for direct password changes in admin panel
|
||||||
# Uses the official Ash Authentication HashPasswordChange with correct context
|
# Uses the official Ash Authentication HashPasswordChange with correct context
|
||||||
update :admin_set_password do
|
update :admin_set_password do
|
||||||
|
|
@ -247,6 +258,8 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
|
|
||||||
read :sign_in_with_rauthy do
|
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 :user_info, :map, allow_nil?: false
|
||||||
argument :oauth_tokens, :map, allow_nil?: false
|
argument :oauth_tokens, :map, allow_nil?: false
|
||||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
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)
|
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||||
# cannot be accessed via OIDC login without password verification.
|
# cannot be accessed via OIDC login without password verification.
|
||||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
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
|
end
|
||||||
|
|
||||||
create :register_with_rauthy do
|
create :register_with_rauthy do
|
||||||
|
|
@ -293,6 +327,18 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
# Sync user email to member when linking (User → Member)
|
# Sync user email to member when linking (User → Member)
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -319,6 +365,13 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||||
end
|
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)
|
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role and permission set"
|
description "Check permissions from user's role and permission set"
|
||||||
|
|
@ -387,6 +440,63 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
|
||||||
|
# Only block when the user is leaving admin (target role is not admin). Switching between
|
||||||
|
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
|
||||||
|
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
|
||||||
|
|
||||||
|
if is_nil(new_role_id) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
current_role_id = changeset.data.role_id
|
||||||
|
|
||||||
|
current_role =
|
||||||
|
Mv.Authorization.Role
|
||||||
|
|> Ash.get!(current_role_id, authorize?: false)
|
||||||
|
|
||||||
|
new_role =
|
||||||
|
Mv.Authorization.Role
|
||||||
|
|> Ash.get!(new_role_id, authorize?: false)
|
||||||
|
|
||||||
|
# Only block when current user is admin and target role is not admin (leaving admin)
|
||||||
|
if current_role.permission_set_name == "admin" and
|
||||||
|
new_role.permission_set_name != "admin" do
|
||||||
|
admin_role_ids =
|
||||||
|
Mv.Authorization.Role
|
||||||
|
|> Ash.Query.for_read(:read)
|
||||||
|
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|
||||||
|
|> Ash.read!(authorize?: false)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
# Count only non-system users with admin role (system user is for internal ops)
|
||||||
|
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
|
||||||
|
count =
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Query.for_read(:read)
|
||||||
|
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||||
|
|> Ash.Query.filter(expr(email != ^system_email))
|
||||||
|
|> Ash.count!(authorize?: false)
|
||||||
|
|
||||||
|
if count <= 1 do
|
||||||
|
{:error,
|
||||||
|
field: :role_id, message: "At least one user must keep the Admin role."}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on: [:update],
|
||||||
|
where: [action_is([:update_user, :set_role_from_oidc_sync])]
|
||||||
|
|
||||||
# Prevent modification of the system actor user (required for internal operations).
|
# Prevent modification of the system actor user (required for internal operations).
|
||||||
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, _context ->
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ defmodule Mv.Membership.Group do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
|
@ -63,6 +64,13 @@ defmodule Mv.Membership.Group do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
validate present(:name)
|
validate present(:name)
|
||||||
|
|
||||||
|
|
@ -136,7 +144,7 @@ defmodule Mv.Membership.Group do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Group
|
Mv.Membership.Group
|
||||||
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
||||||
|> maybe_exclude_id(exclude_id)
|
|> Helpers.query_exclude_id(exclude_id)
|
||||||
|
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
|
@ -155,7 +163,4 @@ defmodule Mv.Membership.Group do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ defmodule Mv.Membership.Member do
|
||||||
- Postal code format: exactly 5 digits (German format)
|
- Postal code format: exactly 5 digits (German format)
|
||||||
- Date validations: join_date not in future, exit_date after join_date
|
- Date validations: join_date not in future, exit_date after join_date
|
||||||
- Email uniqueness: prevents conflicts with unlinked users
|
- 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
|
## Full-Text Search
|
||||||
Members have a `search_vector` attribute (tsvector) that is automatically
|
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)
|
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
|
# 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,
|
change manage_relationship(:user, :user,
|
||||||
# Look up existing user and relate to it
|
|
||||||
on_lookup: :relate,
|
on_lookup: :relate,
|
||||||
# Error if user doesn't exist in database
|
|
||||||
on_no_match: :error,
|
on_no_match: :error,
|
||||||
# Error if user is already linked to another member (prevents "stealing")
|
|
||||||
on_match: :error,
|
on_match: :error,
|
||||||
# If no user provided, remove existing relationship (allows user removal)
|
on_missing: :ignore
|
||||||
on_missing: :unrelate
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync member email to user when email changes (Member → User)
|
# 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))
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: Check permissions from user's role
|
# READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||||||
# HasPermission handles update permissions correctly:
|
policy action_type([:read, :destroy]) do
|
||||||
# - :own_data → can update linked member (scope :linked)
|
description "Check permissions from user's role"
|
||||||
# - :read_only → cannot update any member (no update permission)
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
# - :normal_user → can update all members (scope :all)
|
end
|
||||||
# - :admin → can update all members (scope :all)
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||||||
description "Check permissions from user's role and permission set"
|
# 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
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -381,6 +388,9 @@ defmodule Mv.Membership.Member do
|
||||||
# Validates that member email is not already used by another (unlinked) user
|
# Validates that member email is not already used by another (unlinked) user
|
||||||
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
|
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
|
# Prevent linking to a user that already has a member
|
||||||
# This validation prevents "stealing" users from other members by checking
|
# This validation prevents "stealing" users from other members by checking
|
||||||
# if the target user is already linked to a different member
|
# if the target user is already linked to a different member
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -39,7 +39,8 @@ defmodule Mv.Membership.MemberGroup do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
@ -56,6 +57,26 @@ defmodule Mv.Membership.MemberGroup do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authorization: read uses bypass for :linked (own_data only) then HasPermission for :all;
|
||||||
|
# create/destroy use HasPermission (normal_user + admin only).
|
||||||
|
# Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission.
|
||||||
|
policies do
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "own_data: read only member_groups where member_id == actor.member_id"
|
||||||
|
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action_type(:read) do
|
||||||
|
description "Check read permission from role (read_only/normal_user/admin :all)"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action_type([:create, :destroy]) do
|
||||||
|
description "Check create/destroy from role (normal_user + admin only)"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
validate present(:member_id)
|
validate present(:member_id)
|
||||||
validate present(:group_id)
|
validate present(:group_id)
|
||||||
|
|
@ -118,7 +139,7 @@ defmodule Mv.Membership.MemberGroup do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.MemberGroup
|
Mv.Membership.MemberGroup
|
||||||
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|
||||||
|> maybe_exclude_id(exclude_id)
|
|> Helpers.query_exclude_id(exclude_id)
|
||||||
|
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
|
@ -135,7 +156,4 @@ defmodule Mv.Membership.MemberGroup do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -155,12 +155,15 @@ defmodule Mv.Membership.Setting do
|
||||||
on: [:create, :update]
|
on: [:create, :update]
|
||||||
|
|
||||||
# Validate default_membership_fee_type_id exists if set
|
# Validate default_membership_fee_type_id exists if set
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, context ->
|
||||||
fee_type_id =
|
fee_type_id =
|
||||||
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||||
|
|
||||||
if fee_type_id do
|
if fee_type_id do
|
||||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
|
# Check existence only; action is already restricted by policy (e.g. admin).
|
||||||
|
opts = [domain: Mv.MembershipFees, authorize?: false]
|
||||||
|
|
||||||
|
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, context) do
|
||||||
# Only calculate if membership_fee_start_date is not already set
|
# Only calculate if membership_fee_start_date is not already set
|
||||||
if has_start_date?(changeset) do
|
if has_start_date?(changeset) do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
calculate_and_set_start_date(changeset)
|
calculate_and_set_start_date(changeset, context)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -56,10 +56,13 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp calculate_and_set_start_date(changeset) do
|
defp calculate_and_set_start_date(changeset, context) do
|
||||||
|
actor = Map.get(context || %{}, :actor)
|
||||||
|
opts = if actor, do: [actor: actor], else: []
|
||||||
|
|
||||||
with {:ok, join_date} <- get_join_date(changeset),
|
with {:ok, join_date} <- get_join_date(changeset),
|
||||||
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
||||||
{:ok, interval} <- get_interval(membership_fee_type_id),
|
{:ok, interval} <- get_interval(membership_fee_type_id, opts),
|
||||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
||||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
||||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
||||||
|
|
@ -118,8 +121,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_interval(membership_fee_type_id) do
|
defp get_interval(membership_fee_type_id, opts) do
|
||||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
|
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
|
||||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||||
{:error, _} -> {:error, :membership_fee_type_not_found}
|
{:error, _} -> {:error, :membership_fee_type_not_found}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, context) do
|
||||||
if changing_membership_fee_type?(changeset) do
|
if changing_membership_fee_type?(changeset) do
|
||||||
validate_interval_match(changeset)
|
validate_interval_match(changeset, context)
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
|
|
@ -33,9 +33,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate that the new type has the same interval as the current type
|
# Validate that the new type has the same interval as the current type
|
||||||
defp validate_interval_match(changeset) do
|
defp validate_interval_match(changeset, context) do
|
||||||
current_type_id = get_current_type_id(changeset)
|
current_type_id = get_current_type_id(changeset)
|
||||||
new_type_id = get_new_type_id(changeset)
|
new_type_id = get_new_type_id(changeset)
|
||||||
|
actor = Map.get(context || %{}, :actor)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
# If no current type, allow any change (first assignment)
|
# If no current type, allow any change (first assignment)
|
||||||
|
|
@ -48,13 +49,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
|
|
||||||
# Both types exist - validate intervals match
|
# Both types exist - validate intervals match
|
||||||
true ->
|
true ->
|
||||||
validate_intervals_match(changeset, current_type_id, new_type_id)
|
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validates that intervals match when both types exist
|
# Validates that intervals match when both types exist
|
||||||
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
|
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
|
||||||
case get_intervals(current_type_id, new_type_id) do
|
case get_intervals(current_type_id, new_type_id, actor) do
|
||||||
{:ok, current_interval, new_interval} ->
|
{:ok, current_interval, new_interval} ->
|
||||||
if current_interval == new_interval do
|
if current_interval == new_interval do
|
||||||
changeset
|
changeset
|
||||||
|
|
@ -85,11 +86,16 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get intervals for both types
|
# Get intervals for both types (actor required for authorization when resource has policies)
|
||||||
defp get_intervals(current_type_id, new_type_id) do
|
defp get_intervals(current_type_id, new_type_id, actor) do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
|
opts = if actor, do: [actor: actor], else: []
|
||||||
|
|
||||||
|
case {
|
||||||
|
Ash.get(MembershipFeeType, current_type_id, opts),
|
||||||
|
Ash.get(MembershipFeeType, new_type_id, opts)
|
||||||
|
} do
|
||||||
{{:ok, current_type}, {:ok, new_type}} ->
|
{{:ok, current_type}, {:ok, new_type}} ->
|
||||||
{:ok, current_type.interval, new_type.interval}
|
{:ok, current_type.interval, new_type.interval}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.MembershipFees,
|
domain: Mv.MembershipFees,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "membership_fee_cycles"
|
table "membership_fee_cycles"
|
||||||
|
|
@ -83,6 +84,19 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
|
||||||
|
policies do
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "own_data: read only cycles where member_id == actor.member_id"
|
||||||
|
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
uuid_v7_primary_key :id
|
uuid_v7_primary_key :id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.MembershipFees,
|
domain: Mv.MembershipFees,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "membership_fee_types"
|
table "membership_fee_types"
|
||||||
|
|
@ -61,6 +62,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from role (all can read, only admin can create/update/destroy)"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
# Prevent interval changes after creation
|
# Prevent interval changes after creation
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, _context ->
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.filter(email == ^to_string(email))
|
|> Ash.Query.filter(email == ^to_string(email))
|
||||||
|> maybe_exclude_id(exclude_member_id)
|
|> Mv.Helpers.query_exclude_id(exclude_member_id)
|
||||||
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = SystemActor.get_system_actor()
|
||||||
opts = Helpers.ash_actor_opts(system_actor)
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
@ -101,7 +101,4 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule Mv.Authorization.Actor do
|
defmodule Mv.Authorization.Actor do
|
||||||
@moduledoc """
|
@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
|
## Actor Invariant
|
||||||
|
|
||||||
|
|
@ -27,8 +28,11 @@ defmodule Mv.Authorization.Actor do
|
||||||
assign(socket, :current_user, user)
|
assign(socket, :current_user, user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# In tests
|
# Check if actor is admin (policy checks, validations)
|
||||||
user = Actor.ensure_loaded(user)
|
if Actor.admin?(actor), do: ...
|
||||||
|
|
||||||
|
# Get permission set name (string or nil)
|
||||||
|
ps_name = Actor.permission_set_name(actor)
|
||||||
|
|
||||||
## Security Note
|
## Security Note
|
||||||
|
|
||||||
|
|
@ -47,6 +51,8 @@ defmodule Mv.Authorization.Actor do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Ensures the actor (User) has their `:role` relationship loaded.
|
Ensures the actor (User) has their `:role` relationship loaded.
|
||||||
|
|
||||||
|
|
@ -96,4 +102,45 @@ defmodule Mv.Authorization.Actor do
|
||||||
actor
|
actor
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
||||||
@moduledoc """
|
@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.
|
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
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(_opts), do: "actor has admin permission set"
|
def describe(_opts), do: "actor has admin permission set"
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def match?(nil, _context, _opts), do: false
|
def match?(actor, _context, _opts), do: Actor.admin?(actor)
|
||||||
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
44
lib/mv/authorization/checks/actor_permission_set_is.ex
Normal file
44
lib/mv/authorization/checks/actor_permission_set_is.ex
Normal 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
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check: forbids setting or changing the member–user 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 member–user 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
|
||||||
|
|
@ -50,6 +50,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
- **:linked** - Filters based on resource type:
|
- **:linked** - Filters based on resource type:
|
||||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||||
|
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id → Member.id → User.member_id)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|
@ -131,26 +132,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
resource_name
|
resource_name
|
||||||
) do
|
) do
|
||||||
:authorized ->
|
:authorized ->
|
||||||
# For :all scope, authorize directly
|
|
||||||
{:ok, true}
|
{:ok, true}
|
||||||
|
|
||||||
{:filter, filter_expr} ->
|
{:filter, filter_expr} ->
|
||||||
# For :own/:linked scope:
|
strict_check_filter_scope(record, filter_expr, actor, resource_name)
|
||||||
# - 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
|
|
||||||
|
|
||||||
false ->
|
false ->
|
||||||
{:ok, false}
|
{:ok, false}
|
||||||
|
|
@ -174,6 +159,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def auto_filter(actor, authorizer, _opts) do
|
def auto_filter(actor, authorizer, _opts) do
|
||||||
resource = authorizer.resource
|
resource = authorizer.resource
|
||||||
|
|
@ -278,36 +272,28 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
# For :own scope with User resource: id == actor.id
|
# For :own scope with User resource: id == actor.id
|
||||||
# For :linked scope with Member resource: id == actor.member_id
|
# For :linked scope with Member resource: id == actor.member_id
|
||||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||||
case {resource_name, record} do
|
result =
|
||||||
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
case {resource_name, record} do
|
||||||
# Check if this user's ID matches the actor's ID (scope :own)
|
# Scope :own
|
||||||
if user_id == actor.id do
|
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||||
{:ok, true}
|
user_id == actor.id
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
# Scope :linked
|
||||||
# Check if this member's ID matches the actor's member_id
|
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||||
if member_id == actor.member_id do
|
member_id == actor.member_id
|
||||||
{:ok, true}
|
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||||
# Check if this CFV's member_id matches the actor's member_id
|
cfv_member_id == actor.member_id
|
||||||
if cfv_member_id == actor.member_id do
|
|
||||||
{:ok, true}
|
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) ->
|
||||||
# For other cases or when record is not available, return :unknown
|
mg_member_id == actor.member_id
|
||||||
# This will cause Ash to use auto_filter instead
|
|
||||||
{:ok, :unknown}
|
_ ->
|
||||||
end
|
:unknown
|
||||||
|
end
|
||||||
|
|
||||||
|
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
|
||||||
|
out
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
||||||
|
|
@ -347,24 +333,20 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
defp apply_scope(:linked, actor, resource_name) do
|
defp apply_scope(:linked, actor, resource_name) do
|
||||||
case resource_name do
|
case resource_name do
|
||||||
"Member" ->
|
"Member" ->
|
||||||
# User.member_id → Member.id (inverse relationship)
|
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
|
||||||
# Filter: member.id == actor.member_id
|
linked_filter_by_member_id(actor, :id)
|
||||||
# If actor has no member_id, return no results (use false or impossible condition)
|
|
||||||
if is_nil(actor.member_id) do
|
|
||||||
{:filter, expr(false)}
|
|
||||||
else
|
|
||||||
{:filter, expr(id == ^actor.member_id)}
|
|
||||||
end
|
|
||||||
|
|
||||||
"CustomFieldValue" ->
|
"CustomFieldValue" ->
|
||||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||||
# Filter: custom_field_value.member_id == actor.member_id
|
linked_filter_by_member_id(actor, :member_id)
|
||||||
# If actor has no member_id, return no results
|
|
||||||
if is_nil(actor.member_id) do
|
"MemberGroup" ->
|
||||||
{:filter, expr(false)}
|
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
|
||||||
else
|
linked_filter_by_member_id(actor, :member_id)
|
||||||
{:filter, expr(member_id == ^actor.member_id)}
|
|
||||||
end
|
"MembershipFeeCycle" ->
|
||||||
|
# MembershipFeeCycle.member_id → Member.id → User.member_id (own linked member's cycles)
|
||||||
|
linked_filter_by_member_id(actor, :member_id)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Fallback for other resources
|
# Fallback for other resources
|
||||||
|
|
@ -372,6 +354,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns {:filter, expr(false)} if actor has no member_id; otherwise {:filter, expr(field == ^actor.member_id)}.
|
||||||
|
# Used for :linked scope on Member (field :id), CustomFieldValue and MemberGroup (field :member_id).
|
||||||
|
defp linked_filter_by_member_id(actor, _field) when is_nil(actor.member_id) do
|
||||||
|
{:filter, expr(false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp linked_filter_by_member_id(actor, :id), do: {:filter, expr(id == ^actor.member_id)}
|
||||||
|
|
||||||
|
defp linked_filter_by_member_id(actor, :member_id),
|
||||||
|
do: {:filter, expr(member_id == ^actor.member_id)}
|
||||||
|
|
||||||
# Log authorization failures for debugging (lazy evaluation)
|
# Log authorization failures for debugging (lazy evaluation)
|
||||||
defp log_auth_failure(actor, resource, action, reason) do
|
defp log_auth_failure(actor, resource, action, reason) do
|
||||||
Logger.debug(fn ->
|
Logger.debug(fn ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
18
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal file
18
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal 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
|
||||||
|
|
@ -58,6 +58,28 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
pages: [String.t()]
|
pages: [String.t()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# DRY helpers for shared resource permission lists (used in own_data, read_only, normal_user, admin)
|
||||||
|
defp perm(resource, action, scope),
|
||||||
|
do: %{resource: resource, action: action, scope: scope, granted: true}
|
||||||
|
|
||||||
|
# All four CRUD actions for a resource with scope :all (used for admin)
|
||||||
|
defp perm_all(resource),
|
||||||
|
do: [
|
||||||
|
perm(resource, :read, :all),
|
||||||
|
perm(resource, :create, :all),
|
||||||
|
perm(resource, :update, :all),
|
||||||
|
perm(resource, :destroy, :all)
|
||||||
|
]
|
||||||
|
|
||||||
|
# User: read/update own credentials only (all non-admin sets allow password changes)
|
||||||
|
defp user_own_credentials, do: [perm("User", :read, :own), perm("User", :update, :own)]
|
||||||
|
|
||||||
|
defp group_read_all, do: [perm("Group", :read, :all)]
|
||||||
|
defp custom_field_read_all, do: [perm("CustomField", :read, :all)]
|
||||||
|
defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)]
|
||||||
|
defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)]
|
||||||
|
defp role_read_all, do: [perm("Role", :read, :all)]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of all valid permission set names.
|
Returns the list of all valid permission set names.
|
||||||
|
|
||||||
|
|
@ -94,29 +116,22 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:own_data) do
|
def get_permissions(:own_data) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :linked),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("Member", :update, :linked),
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
perm("CustomFieldValue", :read, :linked),
|
||||||
|
perm("CustomFieldValue", :update, :linked),
|
||||||
# Member: Can read/update linked member
|
perm("CustomFieldValue", :create, :linked),
|
||||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
perm("CustomFieldValue", :destroy, :linked)
|
||||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
|
group_read_all() ++
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
[perm("MemberGroup", :read, :linked)] ++
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
membership_fee_type_read_all() ++
|
||||||
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
|
[perm("MembershipFeeCycle", :read, :linked)] ++
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
role_read_all(),
|
||||||
|
|
||||||
# CustomField: Can read all (needed for forms)
|
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Group: Can read all (needed for viewing groups)
|
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
|
||||||
],
|
|
||||||
pages: [
|
pages: [
|
||||||
# No "/" - Mitglied must not see member index at root (same content as /members).
|
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||||
# Own profile (sidebar links to /users/:id) and own user edit
|
# Own profile (sidebar links to /users/:id) and own user edit
|
||||||
|
|
@ -133,34 +148,26 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:read_only) do
|
def get_permissions(:read_only) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :all),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("CustomFieldValue", :read, :all)
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# Member: Can read all members, no modifications
|
group_read_all() ++
|
||||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
[perm("MemberGroup", :read, :all)] ++
|
||||||
|
membership_fee_type_read_all() ++
|
||||||
# CustomFieldValue: Can read all custom field values
|
membership_fee_cycle_read_all() ++
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
role_read_all(),
|
||||||
|
|
||||||
# CustomField: Can read all
|
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Group: Can read all
|
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
|
||||||
],
|
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
"/users/:id",
|
"/users/:id",
|
||||||
"/users/:id/edit",
|
"/users/:id/edit",
|
||||||
"/users/:id/show/edit",
|
"/users/:id/show/edit",
|
||||||
# Member list and CSV export
|
# Member list
|
||||||
"/members",
|
"/members",
|
||||||
"/members/export.csv",
|
|
||||||
# Member detail
|
# Member detail
|
||||||
"/members/:id",
|
"/members/:id",
|
||||||
# Custom field values overview
|
# Custom field values overview
|
||||||
|
|
@ -177,31 +184,38 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:normal_user) do
|
def get_permissions(:normal_user) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :all),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("Member", :create, :all),
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
perm("Member", :update, :all),
|
||||||
|
# destroy intentionally omitted for safety
|
||||||
# Member: Full CRUD except destroy (safety)
|
perm("CustomFieldValue", :read, :all),
|
||||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
perm("CustomFieldValue", :create, :all),
|
||||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
perm("CustomFieldValue", :update, :all),
|
||||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
perm("CustomFieldValue", :destroy, :all)
|
||||||
# Note: destroy intentionally omitted for safety
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# CustomFieldValue: Full CRUD
|
[
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
perm("Group", :read, :all),
|
||||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
perm("Group", :create, :all),
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
perm("Group", :update, :all),
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
perm("Group", :destroy, :all)
|
||||||
|
] ++
|
||||||
# CustomField: Read only (admin manages definitions)
|
[
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
perm("MemberGroup", :read, :all),
|
||||||
|
perm("MemberGroup", :create, :all),
|
||||||
# Group: Can read all
|
perm("MemberGroup", :destroy, :all)
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
] ++
|
||||||
],
|
membership_fee_type_read_all() ++
|
||||||
|
[
|
||||||
|
perm("MembershipFeeCycle", :read, :all),
|
||||||
|
perm("MembershipFeeCycle", :create, :all),
|
||||||
|
perm("MembershipFeeCycle", :update, :all),
|
||||||
|
perm("MembershipFeeCycle", :destroy, :all)
|
||||||
|
] ++
|
||||||
|
role_read_all(),
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
|
|
@ -209,7 +223,6 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
"/users/:id/edit",
|
"/users/:id/edit",
|
||||||
"/users/:id/show/edit",
|
"/users/:id/show/edit",
|
||||||
"/members",
|
"/members",
|
||||||
"/members/export.csv",
|
|
||||||
# Create member
|
# Create member
|
||||||
"/members/new",
|
"/members/new",
|
||||||
"/members/:id",
|
"/members/:id",
|
||||||
|
|
@ -223,52 +236,39 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
"/custom_field_values/:id/edit",
|
"/custom_field_values/:id/edit",
|
||||||
# Groups overview
|
# Groups overview
|
||||||
"/groups",
|
"/groups",
|
||||||
|
# Create group
|
||||||
|
"/groups/new",
|
||||||
# Group detail
|
# Group detail
|
||||||
"/groups/:slug"
|
"/groups/:slug",
|
||||||
|
# Edit group
|
||||||
|
"/groups/:slug/edit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_permissions(:admin) do
|
def get_permissions(:admin) do
|
||||||
|
# MemberGroup has no :update action in the domain; use read/create/destroy only
|
||||||
|
member_group_perms = [
|
||||||
|
perm("MemberGroup", :read, :all),
|
||||||
|
perm("MemberGroup", :create, :all),
|
||||||
|
perm("MemberGroup", :destroy, :all)
|
||||||
|
]
|
||||||
|
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Full management including other users
|
perm_all("User") ++
|
||||||
%{resource: "User", action: :read, scope: :all, granted: true},
|
perm_all("Member") ++
|
||||||
%{resource: "User", action: :create, scope: :all, granted: true},
|
perm_all("CustomFieldValue") ++
|
||||||
%{resource: "User", action: :update, scope: :all, granted: true},
|
perm_all("CustomField") ++
|
||||||
%{resource: "User", action: :destroy, scope: :all, granted: true},
|
perm_all("Role") ++
|
||||||
|
perm_all("Group") ++
|
||||||
# Member: Full CRUD
|
member_group_perms ++
|
||||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
perm_all("MembershipFeeType") ++
|
||||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
perm_all("MembershipFeeCycle"),
|
||||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
|
||||||
%{resource: "Member", action: :destroy, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# CustomFieldValue: Full CRUD
|
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# CustomField: Full CRUD (admin manages custom field definitions)
|
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomField", action: :create, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomField", action: :update, scope: :all, granted: true},
|
|
||||||
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Role: Full CRUD (admin manages roles)
|
|
||||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
|
||||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
|
||||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
|
||||||
%{resource: "Role", action: :destroy, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Group: Full CRUD (admin manages groups)
|
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true},
|
|
||||||
%{resource: "Group", action: :create, scope: :all, granted: true},
|
|
||||||
%{resource: "Group", action: :update, scope: :all, granted: true},
|
|
||||||
%{resource: "Group", action: :destroy, scope: :all, granted: true}
|
|
||||||
],
|
|
||||||
pages: [
|
pages: [
|
||||||
|
# Explicit admin-only pages (for clarity and future restrictions)
|
||||||
|
"/settings",
|
||||||
|
"/membership_fee_settings",
|
||||||
# Wildcard: Admin can access all pages
|
# Wildcard: Admin can access all pages
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ defmodule Mv.Authorization.Role do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Authorization,
|
domain: Mv.Authorization,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "roles"
|
table "roles"
|
||||||
|
|
@ -86,6 +87,13 @@ defmodule Mv.Authorization.Role do
|
||||||
end
|
end
|
||||||
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
|
validations do
|
||||||
validate one_of(
|
validate one_of(
|
||||||
:permission_set_name,
|
:permission_set_name,
|
||||||
|
|
@ -173,4 +181,18 @@ defmodule Mv.Authorization.Role do
|
||||||
|> Ash.Query.filter(name == "Mitglied")
|
|> Ash.Query.filter(name == "Mitglied")
|
||||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
Modified changeset with email synchronization applied, or original changeset
|
Modified changeset with email synchronization applied, or original changeset
|
||||||
if recursion detected.
|
if recursion detected.
|
||||||
"""
|
"""
|
||||||
|
# Ash 3.12+ calls this to decide whether to run the change in certain contexts.
|
||||||
|
@impl true
|
||||||
|
def has_change?, do: true
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def change(changeset, _opts, context) do
|
def change(changeset, _opts, context) do
|
||||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||||
|
|
@ -40,26 +44,29 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
defp sync_email(changeset) do
|
defp sync_email(changeset) do
|
||||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||||
result = callback.(cs)
|
result = callback.(cs)
|
||||||
|
apply_sync(result)
|
||||||
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
|
|
||||||
end)
|
end)
|
||||||
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
|
# Retrieves user and member - works for both resource types
|
||||||
# Uses system actor via Loader functions
|
# Uses system actor via Loader functions
|
||||||
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ defmodule Mv.EmailSync.Loader do
|
||||||
Helper functions for loading linked records in email synchronization.
|
Helper functions for loading linked records in email synchronization.
|
||||||
Centralizes the logic for retrieving related User/Member entities.
|
Centralizes the logic for retrieving related User/Member entities.
|
||||||
|
|
||||||
## Authorization
|
## Authorization-independent link checks
|
||||||
|
|
||||||
This module runs systemically and uses the system actor for all operations.
|
All functions use the **system actor** for the load. Link existence
|
||||||
This ensures that email synchronization always works, regardless of user permissions.
|
(linked vs not linked) is therefore determined **independently of the
|
||||||
|
current request actor**. This is required so that validations (e.g.
|
||||||
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
|
`EmailChangePermission`, `EmailNotUsedByOtherUser`) can correctly decide
|
||||||
user permission checks, as email sync is a mandatory side effect.
|
"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
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ defmodule Mv.Helpers do
|
||||||
Provides utilities that are not specific to a single domain or layer.
|
Provides utilities that are not specific to a single domain or layer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Converts an actor to Ash options list for authorization.
|
Converts an actor to Ash options list for authorization.
|
||||||
Returns empty list if actor is nil.
|
Returns empty list if actor is nil.
|
||||||
|
|
@ -24,4 +26,22 @@ defmodule Mv.Helpers do
|
||||||
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||||
def ash_actor_opts(nil), do: []
|
def ash_actor_opts(nil), do: []
|
||||||
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
|
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the query unchanged if `exclude_id` is nil; otherwise adds a filter `id != ^exclude_id`.
|
||||||
|
|
||||||
|
Used in uniqueness validations that must exclude the current record (e.g. name uniqueness
|
||||||
|
on update, duplicate association checks). Call with the record's primary key to exclude it
|
||||||
|
from the result set.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
query
|
||||||
|
|> Ash.Query.filter(name == ^name)
|
||||||
|
|> Mv.Helpers.query_exclude_id(current_id)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec query_exclude_id(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
|
||||||
|
def query_exclude_id(query, nil), do: query
|
||||||
|
def query_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
defmodule Mv.Membership.CustomFieldValueFormatter do
|
|
||||||
@moduledoc """
|
|
||||||
Neutral formatter for custom field values (e.g. CSV export).
|
|
||||||
|
|
||||||
Same logic as the member overview Formatter but without Gettext or web helpers,
|
|
||||||
so it can be used from the Membership context. For boolean: "Yes"/"No";
|
|
||||||
for date: European format (dd.mm.yyyy).
|
|
||||||
"""
|
|
||||||
@doc """
|
|
||||||
Formats a custom field value for plain text (e.g. CSV).
|
|
||||||
|
|
||||||
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
|
|
||||||
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
|
|
||||||
"""
|
|
||||||
def format_custom_field_value(nil, _custom_field), do: ""
|
|
||||||
|
|
||||||
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
|
||||||
format_value_by_type(value, type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
|
||||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
|
||||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
|
||||||
format_value_by_type(val, type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_custom_field_value(value, custom_field) do
|
|
||||||
format_value_by_type(value, custom_field.value_type, custom_field)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :string, _), do: to_string(value)
|
|
||||||
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
|
||||||
if String.trim(value) == "", do: "", else: value
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :email, _), do: to_string(value)
|
|
||||||
defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes"
|
|
||||||
defp format_value_by_type(value, :boolean, _) when value == false, do: "No"
|
|
||||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
|
||||||
|
|
||||||
defp format_value_by_type(%Date{} = date, :date, _) do
|
|
||||||
Calendar.strftime(date, "%d.%m.%Y")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
|
||||||
case Date.from_iso8601(value) do
|
|
||||||
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
|
|
||||||
_ -> value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_value_by_type(value, _type, _), do: to_string(value)
|
|
||||||
end
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -8,6 +8,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
This allows creating members with the same email as unlinked users.
|
This allows creating members with the same email as unlinked users.
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
|
|
||||||
|
alias Mv.EmailSync.Loader
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
@ -32,7 +34,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
def validate(changeset, _opts, _context) do
|
def validate(changeset, _opts, _context) do
|
||||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||||
|
|
||||||
linked_user_id = get_linked_user_id(changeset.data)
|
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)
|
is_linked? = not is_nil(linked_user_id)
|
||||||
|
|
||||||
# Only validate if member is already linked AND email is changing
|
# Only validate if member is already linked AND email is changing
|
||||||
|
|
@ -53,7 +56,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
query =
|
query =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email == ^email)
|
|> Ash.Query.filter(email == ^email)
|
||||||
|> maybe_exclude_id(exclude_user_id)
|
|> Mv.Helpers.query_exclude_id(exclude_user_id)
|
||||||
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = SystemActor.get_system_actor()
|
||||||
opts = Helpers.ash_actor_opts(system_actor)
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
@ -73,19 +76,4 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
defmodule Mv.Membership.MemberExport do
|
|
||||||
@moduledoc """
|
|
||||||
Builds member list and column specs for CSV export.
|
|
||||||
|
|
||||||
Used by `MvWeb.MemberExportController`. Does not perform translations;
|
|
||||||
the controller applies headers (e.g. via `MemberFields.label` / gettext)
|
|
||||||
and sends the download.
|
|
||||||
"""
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.Membership.MemberExportSort
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
|
||||||
["membership_fee_status", "payment_status"]
|
|
||||||
@computed_export_fields ["membership_fee_status", "payment_status"]
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
||||||
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Fetches members and column specs for export.
|
|
||||||
|
|
||||||
- `actor` - Ash actor (e.g. current user)
|
|
||||||
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
|
|
||||||
|
|
||||||
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
|
|
||||||
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
|
|
||||||
the controller adds `:header` and optional computed columns to members before CSV export.
|
|
||||||
"""
|
|
||||||
@spec fetch(struct(), map()) ::
|
|
||||||
{:ok, [struct()], [map()]} | {:error, :forbidden}
|
|
||||||
def fetch(actor, parsed) do
|
|
||||||
custom_field_ids_union =
|
|
||||||
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
|
|
||||||
|
|
||||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
|
|
||||||
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
|
|
||||||
column_specs = build_column_specs(parsed, custom_fields_by_id)
|
|
||||||
{:ok, members, column_specs}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
|
||||||
query =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.select([:id, :name, :value_type])
|
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
|
||||||
{:ok, custom_fields} ->
|
|
||||||
by_id =
|
|
||||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
|
||||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
|
||||||
nil -> acc
|
|
||||||
cf -> Map.put(acc, id, cf)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, by_id}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_column_specs(parsed, custom_fields_by_id) do
|
|
||||||
member_specs =
|
|
||||||
Enum.map(parsed.member_fields, fn f ->
|
|
||||||
if f in parsed.selectable_member_fields do
|
|
||||||
%{kind: :member_field, key: f}
|
|
||||||
else
|
|
||||||
%{kind: :computed, key: String.to_existing_atom(f)}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
custom_specs =
|
|
||||||
parsed.custom_field_ids
|
|
||||||
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
|
|
||||||
|
|
||||||
member_specs ++ custom_specs
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_members(actor, parsed, custom_fields_by_id) do
|
|
||||||
select_fields =
|
|
||||||
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
|
||||||
|
|
||||||
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
|
|
||||||
|
|
||||||
need_cycles =
|
|
||||||
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
|
|
||||||
parsed.computed_fields != []
|
|
||||||
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.new()
|
|
||||||
|> Ash.Query.select(select_fields)
|
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|
||||||
|
|
||||||
query =
|
|
||||||
if parsed.selected_ids != [] do
|
|
||||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
|
||||||
else
|
|
||||||
query
|
|
||||||
|> apply_search(parsed.query)
|
|
||||||
|> then(fn q ->
|
|
||||||
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
|
||||||
q
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
|
||||||
{:ok, members} ->
|
|
||||||
members =
|
|
||||||
if parsed.selected_ids == [] do
|
|
||||||
members
|
|
||||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
|
||||||
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
|
||||||
parsed.boolean_filters || %{},
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
|
|
||||||
members =
|
|
||||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
|
||||||
sort_members_by_custom_field(
|
|
||||||
members,
|
|
||||||
parsed.sort_field,
|
|
||||||
parsed.sort_order,
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, members}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, []), do: query
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
|
||||||
cfv_query =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
|
||||||
|
|
||||||
Ash.Query.load(query, custom_field_values: cfv_query)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_search(query, nil), do: query
|
|
||||||
defp apply_search(query, ""), do: query
|
|
||||||
|
|
||||||
defp apply_search(query, q) when is_binary(q) do
|
|
||||||
if String.trim(q) != "" do
|
|
||||||
Member.fuzzy_search(query, %{query: q})
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_sort(query, nil, _order), do: {query, false}
|
|
||||||
defp maybe_sort(query, _field, nil), do: {query, false}
|
|
||||||
|
|
||||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
|
||||||
if custom_field_sort?(field) do
|
|
||||||
{query, true}
|
|
||||||
else
|
|
||||||
field_atom = String.to_existing_atom(field)
|
|
||||||
|
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
|
||||||
else
|
|
||||||
{query, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {query, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sort_after_load?(field) when is_binary(field),
|
|
||||||
do: String.starts_with?(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
defp sort_after_load?(_), do: false
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
|
||||||
do: []
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
|
||||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
|
||||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
|
||||||
if is_nil(custom_field), do: members
|
|
||||||
|
|
||||||
key_fn = fn member ->
|
|
||||||
cfv = find_cfv(member, custom_field)
|
|
||||||
raw = if cfv, do: cfv.value, else: nil
|
|
||||||
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
|
|
||||||
end
|
|
||||||
|
|
||||||
members
|
|
||||||
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|
|
||||||
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|
|
||||||
|> Enum.map(fn {m, _} -> m end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp find_cfv(member, custom_field) do
|
|
||||||
(member.custom_field_values || [])
|
|
||||||
|> Enum.find(fn cfv ->
|
|
||||||
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
|
|
||||||
(Map.get(cfv, :custom_field) &&
|
|
||||||
to_string(cfv.custom_field.id) == to_string(custom_field.id))
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
|
||||||
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
|
||||||
|
|
||||||
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
|
||||||
@doc """
|
|
||||||
Parses and validates export params (from JSON payload).
|
|
||||||
|
|
||||||
Returns a map with :selected_ids, :member_fields, :selectable_member_fields,
|
|
||||||
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
|
|
||||||
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
|
||||||
"""
|
|
||||||
@spec parse_params(map()) :: map()
|
|
||||||
def parse_params(params) do
|
|
||||||
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
|
||||||
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
|
||||||
|
|
||||||
%{
|
|
||||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
|
||||||
member_fields: member_fields,
|
|
||||||
selectable_member_fields: selectable_member_fields,
|
|
||||||
computed_fields: computed_fields,
|
|
||||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
|
||||||
query: extract_string(params, "query"),
|
|
||||||
sort_field: extract_string(params, "sort_field"),
|
|
||||||
sort_order: extract_sort_order(params),
|
|
||||||
show_current_cycle: extract_boolean(params, "show_current_cycle"),
|
|
||||||
cycle_status_filter: extract_cycle_status_filter(params),
|
|
||||||
boolean_filters: extract_boolean_filters(params)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp split_member_fields(member_fields) do
|
|
||||||
selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
|
|
||||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
|
||||||
{selectable, computed}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_boolean(params, key) do
|
|
||||||
case Map.get(params, key) do
|
|
||||||
true -> true
|
|
||||||
"true" -> true
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_cycle_status_filter(params) do
|
|
||||||
case Map.get(params, "cycle_status_filter") do
|
|
||||||
"paid" -> :paid
|
|
||||||
"unpaid" -> :unpaid
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_boolean_filters(params) do
|
|
||||||
case Map.get(params, "boolean_filters") do
|
|
||||||
map when is_map(map) ->
|
|
||||||
map
|
|
||||||
|> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end)
|
|
||||||
|> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end)
|
|
||||||
|> Enum.into(%{})
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_list(params, key) do
|
|
||||||
case Map.get(params, key) do
|
|
||||||
list when is_list(list) -> list
|
|
||||||
_ -> []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_string(params, key) do
|
|
||||||
case Map.get(params, key) do
|
|
||||||
s when is_binary(s) -> s
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_order(params) do
|
|
||||||
case Map.get(params, "sort_order") do
|
|
||||||
"asc" -> "asc"
|
|
||||||
"desc" -> "desc"
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_allowed_member_fields(field_list) do
|
|
||||||
allowlist = MapSet.new(@member_fields_allowlist)
|
|
||||||
|
|
||||||
field_list
|
|
||||||
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_valid_uuids(id_list) when is_list(id_list) do
|
|
||||||
id_list
|
|
||||||
|> Enum.filter(fn id ->
|
|
||||||
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
|
|
||||||
end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
defmodule Mv.Membership.MemberExportSort do
|
|
||||||
@moduledoc """
|
|
||||||
Type-stable sort keys for CSV export custom-field sorting.
|
|
||||||
|
|
||||||
Used only by `MvWeb.MemberExportController` when sorting members by a custom field
|
|
||||||
after load. Nil values sort last in ascending order and first in descending order.
|
|
||||||
String and email comparison is case-insensitive.
|
|
||||||
"""
|
|
||||||
@doc """
|
|
||||||
Returns a comparable sort key for (value_type, value).
|
|
||||||
|
|
||||||
- Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first.
|
|
||||||
- date: chronological (ISO8601 string).
|
|
||||||
- boolean: false < true (0 < 1).
|
|
||||||
- integer: numerical order.
|
|
||||||
- string / email: case-insensitive (downcased).
|
|
||||||
|
|
||||||
Handles Ash.Union in value; value_type is the custom field's value_type atom.
|
|
||||||
"""
|
|
||||||
@spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) ::
|
|
||||||
{0 | 1, term()}
|
|
||||||
def custom_field_sort_key(_value_type, nil), do: {1, nil}
|
|
||||||
|
|
||||||
def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do
|
|
||||||
custom_field_sort_key(value_type, value)
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)}
|
|
||||||
def custom_field_sort_key(:boolean, true), do: {0, 1}
|
|
||||||
def custom_field_sort_key(:boolean, false), do: {0, 0}
|
|
||||||
def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v}
|
|
||||||
def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)}
|
|
||||||
def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)}
|
|
||||||
def custom_field_sort_key(_value_type, v), do: {0, to_string(v)}
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns true if key_a should sort before key_b for the given order.
|
|
||||||
|
|
||||||
"asc" -> nil last; "desc" -> nil first. No reverse of list needed.
|
|
||||||
"""
|
|
||||||
@spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean()
|
|
||||||
def key_lt(key_a, key_b, "asc"), do: key_a < key_b
|
|
||||||
def key_lt(key_a, key_b, "desc"), do: key_b < key_a
|
|
||||||
end
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
defmodule Mv.Membership.MembersCSV do
|
|
||||||
@moduledoc """
|
|
||||||
Exports members to CSV (RFC 4180) as iodata.
|
|
||||||
|
|
||||||
Uses a column-based API: `export(members, columns)` where each column has
|
|
||||||
`header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed),
|
|
||||||
and `key` (member attribute name, custom_field id, or computed key). Custom field columns
|
|
||||||
include a `custom_field` struct for value formatting. Domain code does not use Gettext;
|
|
||||||
headers and computed values come from the caller (e.g. controller).
|
|
||||||
"""
|
|
||||||
alias Mv.Membership.CustomFieldValueFormatter
|
|
||||||
alias NimbleCSV.RFC4180
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Exports a list of members to CSV iodata.
|
|
||||||
|
|
||||||
- `members` - List of member structs or maps (with optional `custom_field_values` loaded)
|
|
||||||
- `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}`
|
|
||||||
For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller).
|
|
||||||
|
|
||||||
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
|
||||||
RFC 4180 escaping and formula-injection safe_cell are applied.
|
|
||||||
"""
|
|
||||||
@spec export([struct() | map()], [map()]) :: iodata()
|
|
||||||
def export(members, columns) when is_list(members) do
|
|
||||||
header = build_header(columns)
|
|
||||||
rows = Enum.map(members, fn member -> build_row(member, columns) end)
|
|
||||||
RFC4180.dump_to_iodata([header | rows])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_header(columns) do
|
|
||||||
columns
|
|
||||||
|> Enum.map(fn col -> col.header end)
|
|
||||||
|> Enum.map(&safe_cell/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_row(member, columns) do
|
|
||||||
columns
|
|
||||||
|> Enum.map(fn col -> cell_value(member, col) end)
|
|
||||||
|> Enum.map(&safe_cell/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cell_value(member, %{kind: :member_field, key: key}) do
|
|
||||||
key_atom = key_to_atom(key)
|
|
||||||
value = Map.get(member, key_atom)
|
|
||||||
format_member_value(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}) do
|
|
||||||
cfv = get_cfv_by_id(member, id)
|
|
||||||
|
|
||||||
if cfv,
|
|
||||||
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf),
|
|
||||||
else: ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cell_value(member, %{kind: :computed, key: key}) do
|
|
||||||
value = Map.get(member, key_to_atom(key))
|
|
||||||
if is_binary(value), do: value, else: ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp key_to_atom(k) when is_atom(k), do: k
|
|
||||||
|
|
||||||
defp key_to_atom(k) when is_binary(k) do
|
|
||||||
try do
|
|
||||||
String.to_existing_atom(k)
|
|
||||||
rescue
|
|
||||||
ArgumentError -> k
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_cfv_by_id(member, id) do
|
|
||||||
values =
|
|
||||||
case Map.get(member, :custom_field_values) do
|
|
||||||
v when is_list(v) -> v
|
|
||||||
_ -> []
|
|
||||||
end
|
|
||||||
|
|
||||||
id_str = to_string(id)
|
|
||||||
|
|
||||||
Enum.find(values, fn cfv ->
|
|
||||||
to_string(cfv.custom_field_id) == id_str or
|
|
||||||
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
@spec safe_cell(String.t()) :: String.t()
|
|
||||||
def safe_cell(s) when is_binary(s) do
|
|
||||||
if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_member_value(nil), do: ""
|
|
||||||
defp format_member_value(true), do: "true"
|
|
||||||
defp format_member_value(false), do: "false"
|
|
||||||
defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
|
|
||||||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
|
||||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
|
||||||
defp format_member_value(value), do: to_string(value)
|
|
||||||
end
|
|
||||||
148
lib/mv/oidc_role_sync.ex
Normal file
148
lib/mv/oidc_role_sync.ex
Normal 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
|
||||||
24
lib/mv/oidc_role_sync_config.ex
Normal file
24
lib/mv/oidc_role_sync_config.ex
Normal 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
|
||||||
|
|
@ -2,9 +2,22 @@ defmodule Mv.Release do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Used for executing DB release tasks when run in production without Mix
|
Used for executing DB release tasks when run in production without Mix
|
||||||
installed.
|
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
|
@app :mv
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
||||||
|
|
@ -18,6 +31,158 @@ defmodule Mv.Release do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
end
|
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
|
defp repos do
|
||||||
Application.fetch_env!(@app, :ecto_repos)
|
Application.fetch_env!(@app, :ecto_repos)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,18 @@ defmodule MvWeb.Authorization do
|
||||||
@doc """
|
@doc """
|
||||||
Checks if user can access a specific page.
|
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
|
## Examples
|
||||||
|
|
||||||
iex> admin = %{role: %{permission_set_name: "admin"}}
|
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||||||
iex> can_access_page?(admin, "/admin/roles")
|
iex> can_access_page?(admin, "/admin/roles")
|
||||||
true
|
true
|
||||||
|
|
||||||
|
iex> can_access_page?(nil, "/members")
|
||||||
|
false
|
||||||
|
|
||||||
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
||||||
iex> can_access_page?(mitglied, "/members")
|
iex> can_access_page?(mitglied, "/members")
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
<.button navigate={~p"/"}>Home</.button>
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
<.button disabled={true}>Disabled</.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 :variant, :string, values: ~w(primary)
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
slot :inner_block, required: true
|
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"}
|
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||||
|
|
||||||
|
|
@ -178,8 +179,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={@open}
|
aria-expanded={@open}
|
||||||
aria-controls={@id}
|
aria-controls={@id}
|
||||||
aria-label={@button_label}
|
class="btn"
|
||||||
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
|
|
||||||
phx-click="toggle_dropdown"
|
phx-click="toggle_dropdown"
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
data-testid="dropdown-button"
|
data-testid="dropdown-button"
|
||||||
|
|
@ -233,12 +233,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||||
aria-label={item.label}
|
|
||||||
aria-checked={
|
aria-checked={
|
||||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||||
}
|
}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||||
phx-click="select_item"
|
phx-click="select_item"
|
||||||
phx-keydown="select_item"
|
phx-keydown="select_item"
|
||||||
phx-key="Enter"
|
phx-key="Enter"
|
||||||
|
|
@ -249,7 +248,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Map.get(@selected, item.value, true)}
|
checked={Map.get(@selected, item.value, true)}
|
||||||
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
class="checkbox checkbox-sm checkbox-primary"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
@ -546,6 +545,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
attr :class, :string
|
attr :class, :string
|
||||||
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
|
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
|
||||||
|
|
||||||
|
attr :sort_field, :any,
|
||||||
|
doc: "optional; when equal to table sort_field, aria-sort is set on this th"
|
||||||
end
|
end
|
||||||
|
|
||||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
@ -561,7 +563,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
<th
|
||||||
|
:for={col <- @col}
|
||||||
|
class={Map.get(col, :class)}
|
||||||
|
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||||
|
>
|
||||||
|
{col[:label]}
|
||||||
|
</th>
|
||||||
<th :for={dyn_col <- @dynamic_cols}>
|
<th :for={dyn_col <- @dynamic_cols}>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
|
@ -647,6 +655,16 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||||
|
col_sort = Map.get(col, :sort_field)
|
||||||
|
|
||||||
|
if not is_nil(col_sort) and col_sort == sort_field and sort_order in [:asc, :desc] do
|
||||||
|
if sort_order == :asc, do: "ascending", else: "descending"
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a data list.
|
Renders a data list.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :html
|
use MvWeb, :html
|
||||||
|
|
||||||
|
alias MvWeb.PagePaths
|
||||||
|
|
||||||
attr :current_user, :map, default: nil, doc: "The current user"
|
attr :current_user, :map, default: nil, doc: "The current user"
|
||||||
attr :club_name, :string, required: true, doc: "The name of the club"
|
attr :club_name, :string, required: true, doc: "The name of the club"
|
||||||
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
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
|
defp sidebar_menu(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<ul class="menu flex-1 w-full p-2" role="menubar">
|
<ul class="menu flex-1 w-full p-2" role="menubar">
|
||||||
<.menu_item
|
<%= if can_access_page?(@current_user, PagePaths.members()) do %>
|
||||||
href={~p"/members"}
|
<.menu_item
|
||||||
icon="hero-users"
|
href={~p"/members"}
|
||||||
label={gettext("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")}
|
|
||||||
/>
|
/>
|
||||||
<.menu_subitem href={~p"/admin/import-export"} label={gettext("Import/Export")} />
|
<% end %>
|
||||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
|
||||||
</.menu_group>
|
<%= 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>
|
</ul>
|
||||||
"""
|
"""
|
||||||
end
|
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 :href, :string, required: true, doc: "Navigation path"
|
||||||
attr :icon, :string, required: true, doc: "Heroicon name"
|
attr :icon, :string, required: true, doc: "Heroicon name"
|
||||||
attr :label, :string, required: true, doc: "Menu item label"
|
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 :icon, :string, required: true, doc: "Heroicon name for the menu group"
|
||||||
attr :label, :string, required: true, doc: "Menu group label"
|
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"
|
slot :inner_block, required: true, doc: "Submenu items"
|
||||||
|
|
||||||
defp menu_group(assigns) do
|
defp menu_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<!-- Expanded Mode: Always open div structure -->
|
<!-- Expanded Mode: Always open div structure -->
|
||||||
<li role="none" class="expanded-menu-group">
|
<li role="none" class="expanded-menu-group" data-testid={@testid}>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3"
|
class="flex items-center gap-3"
|
||||||
role="group"
|
role="group"
|
||||||
|
|
@ -139,7 +165,7 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<!-- Collapsed Mode: Dropdown -->
|
<!-- Collapsed Mode: Dropdown -->
|
||||||
<div class="collapsed-menu-group dropdown dropdown-right">
|
<div class="collapsed-menu-group dropdown dropdown-right" data-testid={@testid}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.TableComponents do
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="sort"
|
phx-click="sort"
|
||||||
phx-value-field={@field}
|
phx-value-field={@field}
|
||||||
aria-sort={aria_sort(@sort_field, @sort_order, @field)}
|
|
||||||
class="flex items-center gap-1 hover:underline focus:outline-none"
|
class="flex items-center gap-1 hover:underline focus:outline-none"
|
||||||
>
|
>
|
||||||
<span>{@label}</span>
|
<span>{@label}</span>
|
||||||
|
|
@ -33,12 +32,4 @@ defmodule MvWeb.TableComponents do
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp aria_sort(current_field, current_order, this_field) do
|
|
||||||
cond do
|
|
||||||
current_field != this_field -> "none"
|
|
||||||
current_order == :asc -> "ascending"
|
|
||||||
true -> "descending"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,433 +0,0 @@
|
||||||
defmodule MvWeb.MemberExportController do
|
|
||||||
@moduledoc """
|
|
||||||
Controller for CSV export of members.
|
|
||||||
|
|
||||||
POST /members/export.csv with form param "payload" (JSON string).
|
|
||||||
Same permission and actor context as the member overview; 403 if unauthorized.
|
|
||||||
"""
|
|
||||||
use MvWeb, :controller
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.Membership.MembersCSV
|
|
||||||
|
|
||||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
||||||
|
|
||||||
def export(conn, params) do
|
|
||||||
actor = current_actor(conn)
|
|
||||||
if is_nil(actor), do: return_forbidden(conn)
|
|
||||||
|
|
||||||
case params["payload"] do
|
|
||||||
nil ->
|
|
||||||
conn
|
|
||||||
|> put_status(400)
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> json(%{error: "payload required"})
|
|
||||||
|
|
||||||
payload when is_binary(payload) ->
|
|
||||||
case Jason.decode(payload) do
|
|
||||||
{:ok, decoded} when is_map(decoded) ->
|
|
||||||
parsed = parse_and_validate(decoded)
|
|
||||||
run_export(conn, actor, parsed)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
conn
|
|
||||||
|> put_status(400)
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> json(%{error: "invalid JSON"})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_actor(conn) do
|
|
||||||
conn.assigns[:current_user]
|
|
||||||
|> Actor.ensure_loaded()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp return_forbidden(conn) do
|
|
||||||
conn
|
|
||||||
|> put_status(403)
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> json(%{error: "Forbidden"})
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_and_validate(params) do
|
|
||||||
%{
|
|
||||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
|
||||||
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
|
|
||||||
computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
|
|
||||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
|
||||||
query: extract_string(params, "query"),
|
|
||||||
sort_field: extract_string(params, "sort_field"),
|
|
||||||
sort_order: extract_sort_order(params)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_existing_atoms(list) when is_list(list) do
|
|
||||||
list
|
|
||||||
|> Enum.filter(&is_binary/1)
|
|
||||||
|> Enum.filter(fn name ->
|
|
||||||
try do
|
|
||||||
_ = String.to_existing_atom(name)
|
|
||||||
true
|
|
||||||
rescue
|
|
||||||
ArgumentError -> false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_list(params, key) do
|
|
||||||
case Map.get(params, key) do
|
|
||||||
list when is_list(list) -> list
|
|
||||||
_ -> []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_string(params, key) do
|
|
||||||
case Map.get(params, key) do
|
|
||||||
s when is_binary(s) -> s
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_order(params) do
|
|
||||||
case Map.get(params, "sort_order") do
|
|
||||||
"asc" -> "asc"
|
|
||||||
"desc" -> "desc"
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_allowed_member_fields(field_list) do
|
|
||||||
allowlist = MapSet.new(@member_fields_allowlist)
|
|
||||||
|
|
||||||
field_list
|
|
||||||
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_valid_uuids(id_list) when is_list(id_list) do
|
|
||||||
id_list
|
|
||||||
|> Enum.filter(fn id ->
|
|
||||||
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
|
|
||||||
end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_export(conn, actor, parsed) do
|
|
||||||
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
|
|
||||||
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
|
|
||||||
parsed =
|
|
||||||
parsed
|
|
||||||
|> ensure_sort_custom_field_loaded()
|
|
||||||
|
|
||||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
|
|
||||||
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
|
|
||||||
columns = build_columns(conn, parsed, custom_fields_by_id)
|
|
||||||
csv_iodata = MembersCSV.export(members, columns)
|
|
||||||
filename = "members-#{Date.utc_today()}.csv"
|
|
||||||
|
|
||||||
send_download(
|
|
||||||
conn,
|
|
||||||
{:binary, IO.iodata_to_binary(csv_iodata)},
|
|
||||||
filename: filename,
|
|
||||||
content_type: "text/csv; charset=utf-8"
|
|
||||||
)
|
|
||||||
else
|
|
||||||
{:error, :forbidden} ->
|
|
||||||
return_forbidden(conn)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
|
|
||||||
case extract_sort_custom_field_id(sort_field) do
|
|
||||||
nil ->
|
|
||||||
parsed
|
|
||||||
|
|
||||||
id ->
|
|
||||||
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_custom_field_id(field) when is_binary(field) do
|
|
||||||
if String.starts_with?(field, @custom_field_prefix) do
|
|
||||||
String.trim_leading(field, @custom_field_prefix)
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_custom_field_id(_), do: nil
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
|
||||||
query =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.select([:id, :name, :value_type])
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.read(actor: actor)
|
|
||||||
|> handle_custom_fields_read_result(custom_field_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
|
|
||||||
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
|
||||||
{:ok, by_id}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
|
||||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
|
||||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
|
||||||
nil -> acc
|
|
||||||
cf -> Map.put(acc, id, cf)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
|
|
||||||
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
|
|
||||||
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.new()
|
|
||||||
|> Ash.Query.select(select_fields)
|
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
|
||||||
|
|
||||||
query =
|
|
||||||
if parsed.selected_ids != [] do
|
|
||||||
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
|
|
||||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
|
||||||
else
|
|
||||||
query
|
|
||||||
|> apply_search_export(parsed.query)
|
|
||||||
end
|
|
||||||
|
|
||||||
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
|
|
||||||
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
|
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
|
||||||
{:ok, members} ->
|
|
||||||
members =
|
|
||||||
if sort_after_load do
|
|
||||||
sort_members_by_custom_field_export(
|
|
||||||
members,
|
|
||||||
parsed.sort_field,
|
|
||||||
parsed.sort_order,
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, members}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, []), do: query
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
|
||||||
cfv_query =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
|
||||||
|
|
||||||
Ash.Query.load(query, custom_field_values: cfv_query)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_search_export(query, nil), do: query
|
|
||||||
defp apply_search_export(query, ""), do: query
|
|
||||||
|
|
||||||
defp apply_search_export(query, q) when is_binary(q) do
|
|
||||||
if String.trim(q) != "" do
|
|
||||||
Member.fuzzy_search(query, %{query: q})
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_sort_export(query, nil, _order), do: {query, false}
|
|
||||||
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
|
||||||
|
|
||||||
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
|
||||||
cond do
|
|
||||||
custom_field_sort?(field) ->
|
|
||||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
|
||||||
{query, true}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
field_atom = String.to_existing_atom(field)
|
|
||||||
|
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
|
||||||
else
|
|
||||||
{query, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {query, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Custom field sorting (match member table behavior)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
|
|
||||||
when members == [],
|
|
||||||
do: []
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
|
||||||
when is_binary(field) do
|
|
||||||
order = order || "asc"
|
|
||||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
custom_field =
|
|
||||||
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
|
||||||
|
|
||||||
if is_nil(custom_field) do
|
|
||||||
members
|
|
||||||
else
|
|
||||||
# Match table:
|
|
||||||
# 1) values first, empty last
|
|
||||||
# 2) sort only values
|
|
||||||
# 3) for desc, reverse only the values-part
|
|
||||||
{with_values, without_values} =
|
|
||||||
Enum.split_with(members, fn member ->
|
|
||||||
has_non_empty_custom_field_value?(member, custom_field)
|
|
||||||
end)
|
|
||||||
|
|
||||||
sorted_with_values =
|
|
||||||
Enum.sort_by(with_values, fn member ->
|
|
||||||
member
|
|
||||||
|> find_cfv(custom_field)
|
|
||||||
|> case do
|
|
||||||
nil -> nil
|
|
||||||
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
sorted_with_values =
|
|
||||||
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
|
|
||||||
|
|
||||||
sorted_with_values ++ without_values
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp has_non_empty_custom_field_value?(member, custom_field) do
|
|
||||||
case find_cfv(member, custom_field) do
|
|
||||||
nil ->
|
|
||||||
false
|
|
||||||
|
|
||||||
cfv ->
|
|
||||||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
|
||||||
not empty_value?(extracted, custom_field.value_type)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp empty_value?(nil, _type), do: true
|
|
||||||
|
|
||||||
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
|
|
||||||
String.trim(value) == ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp empty_value?(_value, _type), do: false
|
|
||||||
|
|
||||||
defp find_cfv(member, custom_field) do
|
|
||||||
(member.custom_field_values || [])
|
|
||||||
|> Enum.find(fn cfv ->
|
|
||||||
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
|
|
||||||
(Map.get(cfv, :custom_field) &&
|
|
||||||
to_string(cfv.custom_field.id) == to_string(custom_field.id))
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_columns(conn, parsed, custom_fields_by_id) do
|
|
||||||
member_cols =
|
|
||||||
Enum.map(parsed.member_fields, fn field ->
|
|
||||||
%{
|
|
||||||
header: member_field_header(conn, field),
|
|
||||||
kind: :member_field,
|
|
||||||
key: field
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
computed_cols =
|
|
||||||
Enum.map(parsed.computed_fields, fn key ->
|
|
||||||
%{
|
|
||||||
header: computed_field_header(conn, key),
|
|
||||||
kind: :computed,
|
|
||||||
key: key
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
custom_cols =
|
|
||||||
parsed.custom_field_ids
|
|
||||||
|> Enum.map(fn id ->
|
|
||||||
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
|
|
||||||
|
|
||||||
if cf do
|
|
||||||
%{
|
|
||||||
header: custom_field_header(conn, cf),
|
|
||||||
kind: :custom_field,
|
|
||||||
key: to_string(id),
|
|
||||||
custom_field: cf
|
|
||||||
}
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
member_cols ++ computed_cols ++ custom_cols
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
|
|
||||||
defp member_field_header(_conn, field) when is_binary(field) do
|
|
||||||
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
|
|
||||||
humanize_field(field)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp computed_field_header(_conn, key) when is_binary(key) do
|
|
||||||
# TODO: display-name helper für computed fields verwenden
|
|
||||||
humanize_field(key)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_header(_conn, cf) do
|
|
||||||
# Custom fields: meist ist cf.name bereits der Display Name
|
|
||||||
cf.name
|
|
||||||
end
|
|
||||||
|
|
||||||
defp humanize_field(str) do
|
|
||||||
str
|
|
||||||
|> String.replace("_", " ")
|
|
||||||
|> String.capitalize()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
|
|
||||||
do: extract_sort_value(value, type)
|
|
||||||
|
|
||||||
defp extract_sort_value(nil, _), do: nil
|
|
||||||
defp extract_sort_value(value, :string) when is_binary(value), do: value
|
|
||||||
defp extract_sort_value(value, :integer) when is_integer(value), do: value
|
|
||||||
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
|
|
||||||
defp extract_sort_value(%Date{} = d, :date), do: d
|
|
||||||
defp extract_sort_value(value, :email) when is_binary(value), do: value
|
|
||||||
defp extract_sort_value(value, _), do: to_string(value)
|
|
||||||
end
|
|
||||||
58
lib/mv_web/helpers/user_helpers.ex
Normal file
58
lib/mv_web/helpers/user_helpers.ex
Normal 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
|
||||||
|
|
@ -41,29 +41,24 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
# RENDER
|
# RENDER
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
|
|
||||||
@payment_status_value "payment_status"
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
all_fields = assigns.all_fields || []
|
all_fields = assigns.all_fields || []
|
||||||
custom_fields = assigns.custom_fields || []
|
custom_fields = assigns.custom_fields || []
|
||||||
|
|
||||||
all_items =
|
all_items =
|
||||||
(Enum.map(extract_member_field_keys(all_fields), fn field ->
|
Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||||
%{
|
%{
|
||||||
value: field_to_string(field),
|
value: field_to_string(field),
|
||||||
label: format_field_label(field)
|
label: format_field_label(field)
|
||||||
}
|
}
|
||||||
end) ++
|
end) ++
|
||||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||||
%{
|
%{
|
||||||
value: field,
|
value: field,
|
||||||
label: format_custom_field_label(field, custom_fields)
|
label: format_custom_field_label(field, custom_fields)
|
||||||
}
|
}
|
||||||
end))
|
end)
|
||||||
|> Enum.reject(fn item -> item.value == @payment_status_value end)
|
|
||||||
|> Enum.uniq_by(fn item -> item.value end)
|
|
||||||
|
|
||||||
assigns = assign(assigns, :all_items, all_items)
|
assigns = assign(assigns, :all_items, all_items)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -642,48 +642,24 @@ defmodule MvWeb.ImportExportLive do
|
||||||
# Start async task to process chunk in production
|
# Start async task to process chunk in production
|
||||||
# Use start_child for fire-and-forget: no monitor, no Task messages
|
# Use start_child for fire-and-forget: no monitor, no Task messages
|
||||||
# We only use our own send/2 messages for communication
|
# We only use our own send/2 messages for communication
|
||||||
Task.Supervisor.start_child(
|
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
|
||||||
Mv.TaskSupervisor,
|
# Set locale in task process for translations
|
||||||
build_chunk_processing_task(
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
|
process_chunk_with_error_handling(
|
||||||
chunk,
|
chunk,
|
||||||
import_state.column_map,
|
import_state.column_map,
|
||||||
import_state.custom_field_map,
|
import_state.custom_field_map,
|
||||||
opts,
|
opts,
|
||||||
live_view_pid,
|
live_view_pid,
|
||||||
idx,
|
idx
|
||||||
locale
|
|
||||||
)
|
)
|
||||||
)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Builds the task function for processing a chunk asynchronously.
|
|
||||||
defp build_chunk_processing_task(
|
|
||||||
chunk,
|
|
||||||
column_map,
|
|
||||||
custom_field_map,
|
|
||||||
opts,
|
|
||||||
live_view_pid,
|
|
||||||
idx,
|
|
||||||
locale
|
|
||||||
) do
|
|
||||||
fn ->
|
|
||||||
# Set locale in task process for translations
|
|
||||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
|
||||||
|
|
||||||
process_chunk_with_error_handling(
|
|
||||||
chunk,
|
|
||||||
column_map,
|
|
||||||
custom_field_map,
|
|
||||||
opts,
|
|
||||||
live_view_pid,
|
|
||||||
idx
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handles chunk processing result from async task and schedules the next chunk.
|
# Handles chunk processing result from async task and schedules the next chunk.
|
||||||
@spec handle_chunk_result(
|
@spec handle_chunk_result(
|
||||||
Phoenix.LiveView.Socket.t(),
|
Phoenix.LiveView.Socket.t(),
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
phx-change="validate"
|
phx-change="validate"
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
>
|
>
|
||||||
<option value="">{gettext("None")}</option>
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
<%= for fee_type <- @available_fee_types do %>
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
<option
|
<option
|
||||||
value={fee_type.id}
|
value={fee_type.id}
|
||||||
|
|
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</option>
|
</option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @interval_warning do %>
|
<%= if @interval_warning do %>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,20 +2,6 @@
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Members")}
|
{gettext("Members")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
|
|
||||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
|
||||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-secondary gap-2"
|
|
||||||
aria-label={gettext("Export members to CSV")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-arrow-down-tray" />
|
|
||||||
{gettext("Export to CSV")} ({if @selected_count == 0,
|
|
||||||
do: gettext("all"),
|
|
||||||
else: @selected_count})
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<.button
|
<.button
|
||||||
class="secondary"
|
class="secondary"
|
||||||
id="copy-emails-btn"
|
id="copy-emails-btn"
|
||||||
|
|
@ -37,9 +23,11 @@
|
||||||
<.icon name="hero-envelope" />
|
<.icon name="hero-envelope" />
|
||||||
{gettext("Open in email program")}
|
{gettext("Open in email program")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button variant="primary" navigate={~p"/members/new"}>
|
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
||||||
</.button>
|
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
|
@ -98,6 +86,7 @@
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
|
row_id={fn member -> "row-#{member.id}" end}
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
|
|
@ -293,7 +282,6 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
|
||||||
label={gettext("Membership Fee Status")}
|
label={gettext("Membership Fee Status")}
|
||||||
>
|
>
|
||||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
||||||
|
|
@ -312,16 +300,23 @@
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<.link
|
<%= if can?(@current_user, :destroy, member) do %>
|
||||||
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
<.link
|
||||||
data-confirm={gettext("Are you sure?")}
|
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
||||||
>
|
data-confirm={gettext("Are you sure?")}
|
||||||
{gettext("Delete")}
|
data-testid="member-delete"
|
||||||
</.link>
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -18,25 +18,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
1. User-specific selection (from URL/Session/Cookie)
|
1. User-specific selection (from URL/Session/Cookie)
|
||||||
2. Global settings (from database)
|
2. Global settings (from database)
|
||||||
3. Default (all fields visible)
|
3. Default (all fields visible)
|
||||||
|
|
||||||
## Pseudo Member Fields
|
|
||||||
|
|
||||||
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
|
|
||||||
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
|
||||||
@pseudo_member_fields [:membership_fee_status]
|
|
||||||
|
|
||||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
|
||||||
@export_only_alias :payment_status
|
|
||||||
|
|
||||||
defp overview_member_fields do
|
|
||||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets all available fields for selection.
|
Gets all available fields for selection.
|
||||||
|
|
||||||
|
|
@ -54,10 +39,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
"""
|
"""
|
||||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||||
def get_all_available_fields(custom_fields) do
|
def get_all_available_fields(custom_fields) do
|
||||||
member_fields =
|
member_fields = Mv.Constants.member_fields()
|
||||||
overview_member_fields()
|
|
||||||
|> Enum.reject(fn field -> field == @export_only_alias end)
|
|
||||||
|
|
||||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||||
|
|
||||||
member_fields ++ custom_field_names
|
member_fields ++ custom_field_names
|
||||||
|
|
@ -133,7 +115,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
field_selection
|
field_selection
|
||||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_visible_fields(_), do: []
|
def get_visible_fields(_), do: []
|
||||||
|
|
@ -151,7 +132,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
"""
|
"""
|
||||||
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||||
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||||
member_fields = overview_member_fields()
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
field_selection
|
field_selection
|
||||||
|> Enum.filter(fn {field_string, visible} ->
|
|> Enum.filter(fn {field_string, visible} ->
|
||||||
|
|
@ -159,61 +140,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
visible && field_atom in member_fields
|
visible && field_atom in member_fields
|
||||||
end)
|
end)
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_visible_member_fields(_), do: []
|
def get_visible_member_fields(_), do: []
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the list of computed (UI-only) member field atoms.
|
|
||||||
|
|
||||||
These fields are not in the database; they must not be used for Ash query
|
|
||||||
select/sort. Use this to filter sort options and validate sort_field.
|
|
||||||
"""
|
|
||||||
@spec computed_member_fields() :: [atom()]
|
|
||||||
def computed_member_fields, do: @pseudo_member_fields
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
|
|
||||||
|
|
||||||
Use for query select/sort. Not for rendering column visibility (use
|
|
||||||
`get_visible_member_fields/1` for that).
|
|
||||||
"""
|
|
||||||
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
|
|
||||||
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
|
|
||||||
db_fields = MapSet.new(Mv.Constants.member_fields())
|
|
||||||
|
|
||||||
field_selection
|
|
||||||
|> Enum.filter(fn {field_string, visible} ->
|
|
||||||
field_atom = to_field_identifier(field_string)
|
|
||||||
visible && field_atom in db_fields
|
|
||||||
end)
|
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_visible_member_fields_db(_), do: []
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
|
|
||||||
|
|
||||||
Use for rendering; do not use for query select or sort.
|
|
||||||
"""
|
|
||||||
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
|
||||||
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
|
||||||
computed_set = MapSet.new(@pseudo_member_fields)
|
|
||||||
|
|
||||||
field_selection
|
|
||||||
|> Enum.filter(fn {field_string, visible} ->
|
|
||||||
field_atom = to_field_identifier(field_string)
|
|
||||||
visible && field_atom in computed_set
|
|
||||||
end)
|
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_visible_member_fields_computed(_), do: []
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets visible custom fields from field selection.
|
Gets visible custom fields from field selection.
|
||||||
|
|
||||||
|
|
@ -246,23 +176,19 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
Map.merge(member_visibility, custom_field_visibility)
|
Map.merge(member_visibility, custom_field_visibility)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
# Gets member field visibility from settings
|
||||||
defp get_member_field_visibility_from_settings(settings) do
|
defp get_member_field_visibility_from_settings(settings) do
|
||||||
visibility_config =
|
visibility_config =
|
||||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||||
|
|
||||||
domain_fields = Mv.Constants.member_fields()
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
domain_map =
|
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
field_string = Atom.to_string(field)
|
||||||
field_string = Atom.to_string(field)
|
# exit_date defaults to false (hidden), all other fields default to true
|
||||||
default_visibility = if field == :exit_date, do: false, else: true
|
default_visibility = if field == :exit_date, do: false, else: true
|
||||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
|
||||||
|
|
||||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
|
||||||
Map.put(acc, Atom.to_string(field), true)
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -277,20 +203,16 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
|
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||||
# Maps export-only alias to canonical UI key so only one option controls the column.
|
|
||||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||||
field_string
|
field_string
|
||||||
else
|
else
|
||||||
atom =
|
try do
|
||||||
try do
|
String.to_existing_atom(field_string)
|
||||||
String.to_existing_atom(field_string)
|
rescue
|
||||||
rescue
|
ArgumentError -> field_string
|
||||||
ArgumentError -> field_string
|
end
|
||||||
end
|
|
||||||
|
|
||||||
if atom == @export_only_alias, do: :membership_fee_status, else: atom
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
{gettext("Edit Member")}
|
<.button
|
||||||
</.button>
|
variant="primary"
|
||||||
|
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
||||||
|
data-testid="member-edit"
|
||||||
|
>
|
||||||
|
{gettext("Edit Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Tab Navigation --%>
|
<%!-- Tab Navigation --%>
|
||||||
|
|
@ -119,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Linked User --%>
|
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||||
<div>
|
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||||
<.data_field label={gettext("Linked User")}>
|
a user is linked but not visible. --%>
|
||||||
<%= if @member.user do %>
|
<%= if can_access_page?(@current_user, "/users") do %>
|
||||||
<.link
|
<div>
|
||||||
navigate={~p"/users/#{@member.user}"}
|
<.data_field label={gettext("Linked User")}>
|
||||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
<%= if @member.user do %>
|
||||||
>
|
<.link
|
||||||
<.icon name="hero-user" class="size-4" />
|
navigate={~p"/users/#{@member.user}"}
|
||||||
{@member.user.email}
|
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||||
</.link>
|
>
|
||||||
<% else %>
|
<.icon name="hero-user" class="size-4" />
|
||||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
{@member.user.email}
|
||||||
<% end %>
|
</.link>
|
||||||
</.data_field>
|
<% else %>
|
||||||
</div>
|
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||||
|
<% end %>
|
||||||
|
</.data_field>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%!-- Notes --%>
|
<%!-- Notes --%>
|
||||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||||
|
|
@ -281,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||||
|
@impl true
|
||||||
|
def handle_info({:put_flash, type, message}, socket) do
|
||||||
|
{:noreply, put_flash(socket, type, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
|
||||||
|
@impl true
|
||||||
|
def handle_info({:member_updated, updated_member}, socket) do
|
||||||
|
member =
|
||||||
|
updated_member
|
||||||
|
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|
||||||
|
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :member, member)}
|
||||||
|
end
|
||||||
|
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit Member")
|
defp page_title(:edit), do: gettext("Edit Member")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
|
|
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Action Buttons --%>
|
<%!-- Action Buttons (only when user has permission) --%>
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<.button
|
<.button
|
||||||
|
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||||
phx-click="regenerate_cycles"
|
phx-click="regenerate_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
||||||
|
|
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={Enum.any?(@cycles)}
|
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||||
phx-click="delete_all_cycles"
|
phx-click="delete_all_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
class="btn btn-sm btn-error btn-outline"
|
||||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
{gettext("Delete All Cycles")}
|
{gettext("Delete All Cycles")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={@member.membership_fee_type}
|
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||||
phx-click="open_create_cycle_modal"
|
phx-click="open_create_cycle_modal"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
|
|
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Amount")}>
|
<:col :let={cycle} label={gettext("Amount")}>
|
||||||
<span
|
<%= if @can_update_cycle do %>
|
||||||
class="font-mono cursor-pointer hover:text-primary"
|
<span
|
||||||
phx-click="edit_cycle_amount"
|
class="font-mono cursor-pointer hover:text-primary"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="edit_cycle_amount"
|
||||||
phx-target={@myself}
|
phx-value-cycle_id={cycle.id}
|
||||||
title={gettext("Click to edit amount")}
|
phx-target={@myself}
|
||||||
>
|
title={gettext("Click to edit amount")}
|
||||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
>
|
||||||
</span>
|
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
|
||||||
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Status")}>
|
<:col :let={cycle} label={gettext("Status")}>
|
||||||
|
|
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
<:action :let={cycle}>
|
<:action :let={cycle}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<%= if @can_update_cycle do %>
|
||||||
:if={cycle.status != :paid}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :paid}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="paid"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="paid"
|
||||||
class="btn btn-sm btn-success"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as paid")}
|
class="btn btn-sm btn-success"
|
||||||
>
|
title={gettext("Mark as paid")}
|
||||||
<.icon name="hero-check-circle" class="size-4" />
|
>
|
||||||
{gettext("Paid")}
|
<.icon name="hero-check-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Paid")}
|
||||||
<button
|
</button>
|
||||||
:if={cycle.status != :suspended}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :suspended}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="suspended"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="suspended"
|
||||||
class="btn btn-sm btn-outline btn-warning"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as suspended")}
|
class="btn btn-sm btn-outline btn-warning"
|
||||||
>
|
title={gettext("Mark as suspended")}
|
||||||
<.icon name="hero-pause-circle" class="size-4" />
|
>
|
||||||
{gettext("Suspended")}
|
<.icon name="hero-pause-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Suspended")}
|
||||||
<button
|
</button>
|
||||||
:if={cycle.status != :unpaid}
|
<button
|
||||||
type="button"
|
:if={cycle.status != :unpaid}
|
||||||
phx-click="mark_cycle_status"
|
type="button"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-click="mark_cycle_status"
|
||||||
phx-value-status="unpaid"
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-value-status="unpaid"
|
||||||
class="btn btn-sm btn-error"
|
phx-target={@myself}
|
||||||
title={gettext("Mark as unpaid")}
|
class="btn btn-sm btn-error"
|
||||||
>
|
title={gettext("Mark as unpaid")}
|
||||||
<.icon name="hero-x-circle" class="size-4" />
|
>
|
||||||
{gettext("Unpaid")}
|
<.icon name="hero-x-circle" class="size-4" />
|
||||||
</button>
|
{gettext("Unpaid")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<% end %>
|
||||||
phx-click="delete_cycle"
|
<%= if @can_destroy_cycle do %>
|
||||||
phx-value-cycle_id={cycle.id}
|
<button
|
||||||
phx-target={@myself}
|
type="button"
|
||||||
class="btn btn-sm btn-error btn-outline"
|
phx-click="delete_cycle"
|
||||||
title={gettext("Delete cycle")}
|
phx-value-cycle_id={cycle.id}
|
||||||
>
|
phx-target={@myself}
|
||||||
<.icon name="hero-trash" class="size-4" />
|
class="btn btn-sm btn-error btn-outline"
|
||||||
{gettext("Delete")}
|
title={gettext("Delete cycle")}
|
||||||
</button>
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete")}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
# Get available fee types (filtered to same interval if member has a type)
|
# Get available fee types (filtered to same interval if member has a type)
|
||||||
available_fee_types = get_available_fee_types(member, actor)
|
available_fee_types = get_available_fee_types(member, actor)
|
||||||
|
|
||||||
|
# Permission flags for cycle actions (so read_only does not see create/update/destroy UI)
|
||||||
|
can_create_cycle = can?(actor, :create, MembershipFeeCycle)
|
||||||
|
can_destroy_cycle = can?(actor, :destroy, MembershipFeeCycle)
|
||||||
|
can_update_cycle = can?(actor, :update, MembershipFeeCycle)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:cycles, cycles)
|
||||||
|> assign(:available_fee_types, available_fee_types)
|
|> assign(:available_fee_types, available_fee_types)
|
||||||
|
|> assign(:can_create_cycle, can_create_cycle)
|
||||||
|
|> assign(:can_destroy_cycle, can_destroy_cycle)
|
||||||
|
|> assign(:can_update_cycle, can_update_cycle)
|
||||||
|> assign_new(:interval_warning, fn -> nil end)
|
|> assign_new(:interval_warning, fn -> nil end)
|
||||||
|> assign_new(:editing_cycle, fn -> nil end)
|
|> assign_new(:editing_cycle, fn -> nil end)
|
||||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||||
|
|
@ -439,7 +457,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:cycles, [])
|
|> assign(:cycles, [])
|
||||||
|> assign(
|
|> assign(
|
||||||
:available_fee_types,
|
:available_fee_types,
|
||||||
get_available_fee_types(updated_member, current_actor(socket))
|
get_available_fee_types(updated_member, actor)
|
||||||
)
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||||
|
|
@ -470,13 +488,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
if interval_warning do
|
if interval_warning do
|
||||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||||
else
|
else
|
||||||
actor = current_actor(socket)
|
|
||||||
|
|
||||||
case update_member_fee_type(member, fee_type_id, actor) do
|
case update_member_fee_type(member, fee_type_id, actor) do
|
||||||
{:ok, updated_member} ->
|
{:ok, updated_member} ->
|
||||||
# Reload member with cycles
|
# Reload member with cycles
|
||||||
actor = current_actor(socket)
|
|
||||||
|
|
||||||
updated_member =
|
updated_member =
|
||||||
updated_member
|
updated_member
|
||||||
|> Ash.load!(
|
|> Ash.load!(
|
||||||
|
|
@ -502,7 +516,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:cycles, cycles)
|
||||||
|> assign(
|
|> assign(
|
||||||
:available_fee_types,
|
:available_fee_types,
|
||||||
get_available_fee_types(updated_member, current_actor(socket))
|
get_available_fee_types(updated_member, actor)
|
||||||
)
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||||
|
|
@ -554,17 +568,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("regenerate_cycles", _params, socket) do
|
def handle_event("regenerate_cycles", _params, socket) do
|
||||||
|
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
# SECURITY: Only admins can manually regenerate cycles via UI
|
if can?(actor, :create, MembershipFeeCycle) do
|
||||||
# Cycle generation itself uses system actor, but UI access should be restricted
|
|
||||||
if actor.role && actor.role.permission_set_name == "admin" do
|
|
||||||
socket = assign(socket, :regenerating, true)
|
socket = assign(socket, :regenerating, true)
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
|
|
||||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||||
{:ok, _new_cycles, _notifications} ->
|
{:ok, _new_cycles, _notifications} ->
|
||||||
# Reload member with cycles
|
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
updated_member =
|
updated_member =
|
||||||
|
|
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
else
|
else
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|
|> assign(:regenerating, false)
|
||||||
|
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -722,61 +735,31 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
|
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
|
||||||
expected = String.downcase(gettext("Yes"))
|
expected = String.downcase(gettext("Yes"))
|
||||||
|
|
||||||
if confirmation != expected do
|
if confirmation == expected do
|
||||||
|
member = socket.assigns.member
|
||||||
|
actor = current_actor(socket)
|
||||||
|
cycles = socket.assigns.cycles
|
||||||
|
|
||||||
|
reset_modal = fn s ->
|
||||||
|
s
|
||||||
|
|> assign(:deleting_all_cycles, false)
|
||||||
|
|> assign(:delete_all_confirmation, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
if can?(actor, :destroy, MembershipFeeCycle) do
|
||||||
|
do_delete_all_cycles(socket, member, actor, cycles, reset_modal)
|
||||||
|
else
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> reset_modal.()
|
||||||
|
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
|
||||||
|
end
|
||||||
|
else
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:deleting_all_cycles, false)
|
|> assign(:deleting_all_cycles, false)
|
||||||
|> assign(:delete_all_confirmation, "")
|
|> assign(:delete_all_confirmation, "")
|
||||||
|> put_flash(:error, gettext("Confirmation text does not match"))}
|
|> put_flash(:error, gettext("Confirmation text does not match"))}
|
||||||
else
|
|
||||||
member = socket.assigns.member
|
|
||||||
|
|
||||||
# Delete all cycles atomically using Ecto query
|
|
||||||
import Ecto.Query
|
|
||||||
|
|
||||||
deleted_count =
|
|
||||||
Mv.Repo.delete_all(
|
|
||||||
from c in Mv.MembershipFees.MembershipFeeCycle,
|
|
||||||
where: c.member_id == ^member.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if deleted_count > 0 do
|
|
||||||
# Reload member to get updated cycles
|
|
||||||
actor = current_actor(socket)
|
|
||||||
|
|
||||||
updated_member =
|
|
||||||
member
|
|
||||||
|> Ash.load!(
|
|
||||||
[
|
|
||||||
:membership_fee_type,
|
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
|
||||||
],
|
|
||||||
actor: actor
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_cycles =
|
|
||||||
Enum.sort_by(
|
|
||||||
updated_member.membership_fee_cycles || [],
|
|
||||||
& &1.cycle_start,
|
|
||||||
{:desc, Date}
|
|
||||||
)
|
|
||||||
|
|
||||||
send(self(), {:member_updated, updated_member})
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:member, updated_member)
|
|
||||||
|> assign(:cycles, updated_cycles)
|
|
||||||
|> assign(:deleting_all_cycles, false)
|
|
||||||
|> assign(:delete_all_confirmation, "")
|
|
||||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
|
||||||
else
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:deleting_all_cycles, false)
|
|
||||||
|> assign(:delete_all_confirmation, "")
|
|
||||||
|> put_flash(:info, gettext("No cycles to delete"))}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -895,6 +878,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
|
defp do_delete_all_cycles(socket, member, actor, cycles, reset_modal) do
|
||||||
|
result =
|
||||||
|
Enum.reduce_while(cycles, {:ok, 0}, fn cycle, {:ok, count} ->
|
||||||
|
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
|
||||||
|
:ok -> {:cont, {:ok, count + 1}}
|
||||||
|
{:ok, _} -> {:cont, {:ok, count + 1}}
|
||||||
|
{:error, error} -> {:halt, {:error, error}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, deleted_count} when deleted_count > 0 ->
|
||||||
|
updated_member =
|
||||||
|
member
|
||||||
|
|> Ash.load!(
|
||||||
|
[:membership_fee_type, membership_fee_cycles: [:membership_fee_type]],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_cycles =
|
||||||
|
Enum.sort_by(
|
||||||
|
updated_member.membership_fee_cycles || [],
|
||||||
|
& &1.cycle_start,
|
||||||
|
{:desc, Date}
|
||||||
|
)
|
||||||
|
|
||||||
|
send(self(), {:member_updated, updated_member})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:member, updated_member)
|
||||||
|
|> assign(:cycles, updated_cycles)
|
||||||
|
|> reset_modal.()
|
||||||
|
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> reset_modal.()
|
||||||
|
|> put_flash(:info, gettext("No cycles to delete"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> reset_modal.()
|
||||||
|
|> put_flash(:error, format_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp get_available_fee_types(member, actor) do
|
defp get_available_fee_types(member, actor) do
|
||||||
all_types =
|
all_types =
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|
|
@ -940,6 +972,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_error(%Ash.Error.Forbidden{}) do
|
||||||
|
gettext("You are not allowed to perform this action.")
|
||||||
|
end
|
||||||
|
|
||||||
defp format_error(error) when is_binary(error), do: error
|
defp format_error(error) when is_binary(error), do: error
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
defp format_error(_error), do: gettext("An error occurred")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,20 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
{:ok, settings} = Membership.get_settings()
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
membership_fee_types =
|
membership_fee_types =
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -200,10 +200,12 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
membership_fee_type =
|
membership_fee_type =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
page_title =
|
page_title =
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
require Jason
|
require Jason
|
||||||
|
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
||||||
|
|
@ -49,6 +51,18 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
|
<%= if @user && @can_assign_role do %>
|
||||||
|
<div class="mt-4">
|
||||||
|
<.input
|
||||||
|
field={@form[:role_id]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Role")}
|
||||||
|
options={Enum.map(@roles, &{&1.name, &1.id})}
|
||||||
|
prompt={gettext("Select role...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- Password Section -->
|
<!-- Password Section -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|
@ -67,6 +81,18 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
<%= if @show_password_fields do %>
|
<%= if @show_password_fields do %>
|
||||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||||
|
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
||||||
|
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
||||||
|
<p class="text-sm font-semibold text-red-800">
|
||||||
|
{gettext("SSO / OIDC user")}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-red-700">
|
||||||
|
{gettext(
|
||||||
|
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:password]}
|
field={@form[:password]}
|
||||||
label={gettext("Password")}
|
label={gettext("Password")}
|
||||||
|
|
@ -300,6 +326,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||||
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||||
|
# Only admins can assign user roles (Role update permission).
|
||||||
|
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
|
||||||
|
roles = if can_assign_role, do: load_roles(actor), else: []
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -307,6 +336,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(user: user)
|
|> assign(user: user)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||||
|
|> assign(:can_assign_role, can_assign_role)
|
||||||
|
|> assign(:roles, roles)
|
||||||
|> assign(:show_password_fields, false)
|
|> assign(:show_password_fields, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|
@ -357,7 +388,10 @@ defmodule MvWeb.UserLive.Form do
|
||||||
def handle_event("save", %{"user" => user_params}, socket) do
|
def handle_event("save", %{"user" => user_params}, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
# First save the user without member changes
|
# Include current member in params when not linking/unlinking so update_user's
|
||||||
|
# manage_relationship(on_missing: :unrelate) does not accidentally unlink.
|
||||||
|
user_params = params_with_member_if_unchanged(socket, user_params)
|
||||||
|
|
||||||
case submit_form(socket.assigns.form, user_params, actor) do
|
case submit_form(socket.assigns.form, user_params, actor) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
handle_member_linking(socket, user, actor)
|
handle_member_linking(socket, user, actor)
|
||||||
|
|
@ -529,6 +563,20 @@ defmodule MvWeb.UserLive.Form do
|
||||||
defp get_action_name(:update), do: gettext("updated")
|
defp get_action_name(:update), do: gettext("updated")
|
||||||
defp get_action_name(other), do: to_string(other)
|
defp get_action_name(other), do: to_string(other)
|
||||||
|
|
||||||
|
# When user has a linked member and we are not linking/unlinking, include current member in params
|
||||||
|
# so update_user's manage_relationship(on_missing: :unrelate) does not unlink the member.
|
||||||
|
defp params_with_member_if_unchanged(socket, params) do
|
||||||
|
user = socket.assigns.user
|
||||||
|
linking = socket.assigns.selected_member_id
|
||||||
|
unlinking = socket.assigns[:unlink_member]
|
||||||
|
|
||||||
|
if user && user.member_id && !linking && !unlinking do
|
||||||
|
Map.put(params, "member", %{"id" => user.member_id})
|
||||||
|
else
|
||||||
|
params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_member_link_error(socket, error) do
|
defp handle_member_link_error(socket, error) do
|
||||||
error_message = extract_error_message(error)
|
error_message = extract_error_message(error)
|
||||||
|
|
||||||
|
|
@ -572,7 +620,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
assigns: %{
|
assigns: %{
|
||||||
user: user,
|
user: user,
|
||||||
show_password_fields: show_password_fields,
|
show_password_fields: show_password_fields,
|
||||||
can_manage_member_linking: can_manage_member_linking
|
can_manage_member_linking: can_manage_member_linking,
|
||||||
|
can_assign_role: can_assign_role
|
||||||
}
|
}
|
||||||
} = socket
|
} = socket
|
||||||
) do
|
) do
|
||||||
|
|
@ -580,16 +629,25 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
|
# For existing users: admin uses update_user (email + member + role_id); non-admin uses update (email only).
|
||||||
# Password change uses admin_set_password for both.
|
# Password change uses admin_set_password for both.
|
||||||
action =
|
action =
|
||||||
cond do
|
cond do
|
||||||
show_password_fields -> :admin_set_password
|
show_password_fields -> :admin_set_password
|
||||||
can_manage_member_linking -> :update_user
|
can_manage_member_linking or can_assign_role -> :update_user
|
||||||
true -> :update
|
true -> :update
|
||||||
end
|
end
|
||||||
|
|
||||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
form =
|
||||||
|
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||||
|
|
||||||
|
# Ensure role_id is always included on submit when role dropdown is shown (AshPhoenix.Form
|
||||||
|
# only submits keys in touched_forms; marking as touched avoids role change being dropped).
|
||||||
|
if can_assign_role and action == :update_user do
|
||||||
|
AshPhoenix.Form.touch(form, [:role_id])
|
||||||
|
else
|
||||||
|
form
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# For new users, use password registration if password fields are shown
|
# For new users, use password registration if password fields are shown
|
||||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||||
|
|
@ -668,6 +726,14 @@ defmodule MvWeb.UserLive.Form do
|
||||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
||||||
|
defp load_roles(actor) do
|
||||||
|
case Authorization.list_roles(actor: actor) do
|
||||||
|
{:ok, roles} -> roles
|
||||||
|
{:error, _} -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Extract user-friendly error message from Ash.Error
|
# Extract user-friendly error message from Ash.Error
|
||||||
@spec extract_error_message(any()) :: String.t()
|
@spec extract_error_message(any()) :: String.t()
|
||||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
users =
|
users =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|
||||||
|> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor)
|
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||||
|
|
||||||
sorted = Enum.sort_by(users, & &1.email)
|
sorted = Enum.sort_by(users, & &1.email)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,22 @@
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Users")}
|
{gettext("Listing Users")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button variant="primary" navigate={~p"/users/new"}>
|
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||||
<.icon name="hero-plus" /> {gettext("New User")}
|
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||||
</.button>
|
<.icon name="hero-plus" /> {gettext("New User")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.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
|
<:col
|
||||||
:let={user}
|
:let={user}
|
||||||
label={
|
label={
|
||||||
|
|
@ -38,6 +47,7 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={user}
|
:let={user}
|
||||||
|
sort_field={:email}
|
||||||
label={
|
label={
|
||||||
sort_button(%{
|
sort_button(%{
|
||||||
field: :email,
|
field: :email,
|
||||||
|
|
@ -49,11 +59,28 @@
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col :let={user} label={gettext("Role")}>
|
||||||
|
{user.role.name}
|
||||||
|
</:col>
|
||||||
<:col :let={user} label={gettext("Linked Member")}>
|
<:col :let={user} label={gettext("Linked Member")}>
|
||||||
<%= if user.member do %>
|
<%= if user.member do %>
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-base-content/50">{gettext("No member linked")}</span>
|
<span class="text-base-content/70">{gettext("No member linked")}</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
|
<:col :let={user} label={gettext("Password")}>
|
||||||
|
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
|
||||||
|
<span>{gettext("Enabled")}</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/70">—</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
|
<:col :let={user} label={gettext("OIDC")}>
|
||||||
|
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
|
||||||
|
<span>{gettext("Linked")}</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/70">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
|
|
@ -62,16 +89,23 @@
|
||||||
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<:action :let={user}>
|
<:action :let={user}>
|
||||||
<.link
|
<%= if can?(@current_user, :destroy, user) do %>
|
||||||
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
<.link
|
||||||
data-confirm={gettext("Are you sure?")}
|
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
||||||
>
|
data-confirm={gettext("Are you sure?")}
|
||||||
{gettext("Delete")}
|
data-testid="user-delete"
|
||||||
</.link>
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -41,16 +41,30 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<.icon name="hero-arrow-left" />
|
<.icon name="hero-arrow-left" />
|
||||||
<span class="sr-only">{gettext("Back to users list")}</span>
|
<span class="sr-only">{gettext("Back to users list")}</span>
|
||||||
</.button>
|
</.button>
|
||||||
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
<.button
|
||||||
</.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>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||||
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||||
<:item title={gettext("Password Authentication")}>
|
<:item title={gettext("Password Authentication")}>
|
||||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
||||||
|
do: gettext("Enabled"),
|
||||||
|
else: gettext("Not enabled")}
|
||||||
|
</:item>
|
||||||
|
<:item title={gettext("OIDC")}>
|
||||||
|
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
||||||
|
do: gettext("Linked"),
|
||||||
|
else: gettext("Not linked")}
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("Linked Member")}>
|
<:item title={gettext("Linked Member")}>
|
||||||
<%= if @user.member do %>
|
<%= if @user.member do %>
|
||||||
|
|
@ -73,7 +87,9 @@ defmodule MvWeb.UserLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
|
||||||
|
user =
|
||||||
|
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||||
|
|
||||||
if Mv.Helpers.SystemActor.system_user?(user) do
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
|
||||||
42
lib/mv_web/page_paths.ex
Normal file
42
lib/mv_web/page_paths.ex
Normal 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
|
||||||
|
|
@ -91,7 +91,6 @@ defmodule MvWeb.Router do
|
||||||
# Import/Export (Admin only)
|
# Import/Export (Admin only)
|
||||||
live "/admin/import-export", ImportExportLive
|
live "/admin/import-export", ImportExportLive
|
||||||
|
|
||||||
post "/members/export.csv", MemberExportController, :export
|
|
||||||
post "/set_locale", LocaleController, :set_locale
|
post "/set_locale", LocaleController, :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:house_number), do: gettext("House Number")
|
def label(:house_number), do: gettext("House Number")
|
||||||
def label(:postal_code), do: gettext("Postal Code")
|
def label(:postal_code), do: gettext("Postal Code")
|
||||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
def label(field) do
|
def label(field) do
|
||||||
|
|
|
||||||
40
mix.lock
40
mix.lock
|
|
@ -1,22 +1,22 @@
|
||||||
%{
|
%{
|
||||||
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
|
"ash": {:hex, :ash, "3.14.1", "22e0ac5dfd4c7d502bd103f0b4380defd66d7c6c83b3a4f54af7045f13da00d7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "776a5963790d5af79855ddca1718a037d06b49063a6b97fae9110050b3d5127d"},
|
||||||
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
|
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
|
||||||
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
|
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
|
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
|
||||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
|
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
|
||||||
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
|
"ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"},
|
||||||
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
|
"ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"},
|
||||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||||
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
|
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
||||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||||
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
|
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
|
||||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
|
|
@ -28,21 +28,21 @@
|
||||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
|
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
|
||||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||||
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
"live_debugger": {:hex, :live_debugger, "0.5.1", "7302a4fda1920ba541b456c2d7a97acc3c7f9d7b938b5435927883b709c968a2", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "797fdca7cc60d7588c6e285b0d7ea73f2dce8b123bac43eae70271fa519bb907"},
|
||||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
|
@ -57,26 +57,26 @@
|
||||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
|
||||||
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
|
||||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||||
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
||||||
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
|
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
|
||||||
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
"spitfire": {:hex, :spitfire, "0.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"},
|
||||||
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
|
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
|
||||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@ msgstr "Beschreibung"
|
||||||
msgid "Edit User"
|
msgid "Edit User"
|
||||||
msgstr "Benutzer*in bearbeiten"
|
msgstr "Benutzer*in bearbeiten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
|
|
@ -471,6 +472,7 @@ msgid "Include both letters and numbers"
|
||||||
msgstr "Buchstaben und Zahlen verwenden"
|
msgstr "Buchstaben und Zahlen verwenden"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Passwort"
|
msgstr "Passwort"
|
||||||
|
|
@ -958,7 +960,6 @@ msgid "Last name"
|
||||||
msgstr "Nachname"
|
msgstr "Nachname"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "None"
|
msgid "None"
|
||||||
msgstr "Keine"
|
msgstr "Keine"
|
||||||
|
|
@ -1670,6 +1671,9 @@ msgstr "Profil"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Rolle"
|
msgstr "Rolle"
|
||||||
|
|
@ -1965,11 +1969,6 @@ msgstr "Bezahlstatus"
|
||||||
msgid "Reset"
|
msgid "Reset"
|
||||||
msgstr "Zurücksetzen"
|
msgstr "Zurücksetzen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Only administrators can regenerate cycles"
|
|
||||||
msgstr "Nur Administrator*innen können Zyklen regenerieren"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2307,7 +2306,7 @@ msgstr "Import/Export"
|
||||||
#: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to access this page."
|
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
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -2319,37 +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."
|
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."
|
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/member_live/index.html.heex
|
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Export members to CSV"
|
msgid "Only administrators or the linked user can change the email for members linked to users"
|
||||||
msgstr "Mitglieder importieren (CSV)"
|
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/member_live/index.html.heex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Export to CSV"
|
msgid "You are not allowed to perform this action."
|
||||||
msgstr "Als CSV exportieren"
|
msgstr "Du hast keine Berechtigung, diese Aktion auszuführen."
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "all"
|
msgid "Select a membership fee type"
|
||||||
msgstr "alle"
|
msgstr "Mitgliedsbeitragstyp auswählen"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#~ msgid "Custom Fields in CSV Import"
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgstr "Benutzerdefinierte Felder"
|
msgid "Linked"
|
||||||
|
msgstr "Verknüpft"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
#: 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
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Failed to prepare CSV import: %{error}"
|
#~ msgid "Only administrators can regenerate cycles"
|
||||||
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
|
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"
|
||||||
|
|
||||||
#~ #: 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"
|
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ msgstr ""
|
||||||
msgid "Edit User"
|
msgid "Edit User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
|
|
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -959,7 +961,6 @@ msgid "Last name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "None"
|
msgid "None"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1671,6 +1672,9 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1966,11 +1970,6 @@ msgstr ""
|
||||||
msgid "Reset"
|
msgid "Reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Only administrators can regenerate cycles"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2320,17 +2319,49 @@ 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."
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Export members to CSV"
|
msgid "Only administrators or the linked user can change the email for members linked to users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Export to CSV"
|
msgid "Select role..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "all"
|
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 ""
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ msgstr ""
|
||||||
msgid "Edit User"
|
msgid "Edit User"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
|
|
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -959,7 +961,6 @@ msgid "Last name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "None"
|
msgid "None"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1671,6 +1672,9 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1966,11 +1970,6 @@ msgstr ""
|
||||||
msgid "Reset"
|
msgid "Reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Only administrators can regenerate cycles"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2320,37 +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."
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Export members to CSV"
|
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/user_live/form.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Select role..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Export to CSV"
|
msgid "You are not allowed to perform this action."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "all"
|
msgid "OIDC"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Custom Fields in CSV Import"
|
msgid "Not linked"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
#: 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
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Failed to prepare CSV import: %{error}"
|
#~ msgid "Only administrators can regenerate cycles"
|
||||||
#~ 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"
|
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
# Create example membership fee types
|
# Create example membership fee types (no admin user yet; skip authorization for bootstrap)
|
||||||
for fee_type_attrs <- [
|
for fee_type_attrs <- [
|
||||||
%{
|
%{
|
||||||
name: "Standard (Jährlich)",
|
name: "Standard (Jährlich)",
|
||||||
|
|
@ -39,7 +39,12 @@ for fee_type_attrs <- [
|
||||||
] do
|
] do
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|
||||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_name)
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_name,
|
||||||
|
authorize?: false,
|
||||||
|
domain: Mv.MembershipFees
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
for attrs <- [
|
for attrs <- [
|
||||||
|
|
@ -127,8 +132,15 @@ for attrs <- [
|
||||||
)
|
)
|
||||||
end
|
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"
|
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)
|
# 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
|
# 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."
|
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||||
end
|
end
|
||||||
|
|
||||||
# Assign admin role to user with ADMIN_EMAIL (if user exists)
|
# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE).
|
||||||
# This handles both existing users (e.g., from OIDC) and newly created users
|
# Reduces duplication and exercises the same path as production entrypoint.
|
||||||
case Accounts.User
|
Mv.Release.seed_admin()
|
||||||
|> 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
|
|
||||||
|
|
||||||
# Load admin user with role for use as actor in member operations
|
# Load admin user with role for use as actor in member operations
|
||||||
# This ensures all member operations have proper authorization
|
# 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})")
|
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load all membership fee types for assignment
|
# Load all membership fee types for assignment (admin actor for authorization)
|
||||||
# Sort by name to ensure deterministic order
|
# Sort by name to ensure deterministic order
|
||||||
all_fee_types =
|
all_fee_types =
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
||||||
|> Enum.to_list()
|
|> Enum.to_list()
|
||||||
|
|
||||||
# Create sample members for testing - use upsert to prevent duplicates
|
# Create sample members for testing - use upsert to prevent duplicates
|
||||||
|
|
@ -452,7 +434,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Create additional users for user-member linking examples
|
# Create additional users for user-member linking examples (no password by default)
|
||||||
|
# Only admin gets a password (admin_set_password when created); all other users have no password.
|
||||||
additional_users = [
|
additional_users = [
|
||||||
%{email: "hans.mueller@example.de"},
|
%{email: "hans.mueller@example.de"},
|
||||||
%{email: "greta.schmidt@example.de"},
|
%{email: "greta.schmidt@example.de"},
|
||||||
|
|
@ -462,15 +445,12 @@ additional_users = [
|
||||||
|
|
||||||
created_users =
|
created_users =
|
||||||
Enum.map(additional_users, fn user_attrs ->
|
Enum.map(additional_users, fn user_attrs ->
|
||||||
# Use admin user as actor for additional user creation (not bootstrap)
|
|
||||||
user =
|
user =
|
||||||
Accounts.create_user!(user_attrs,
|
Accounts.create_user!(user_attrs,
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email,
|
upsert_identity: :unique_email,
|
||||||
actor: admin_user_with_role
|
actor: admin_user_with_role
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|
|
||||||
|> Ash.update!(actor: admin_user_with_role)
|
|
||||||
|
|
||||||
# Reload user to ensure all fields (including member_id) are loaded
|
# Reload user to ensure all fields (including member_id) are loaded
|
||||||
Accounts.User
|
Accounts.User
|
||||||
|
|
@ -744,7 +724,14 @@ IO.puts("📝 Created sample data:")
|
||||||
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
||||||
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
|
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(" - 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(" - Sample members: Hans, Greta, Friedrich")
|
||||||
|
|
||||||
IO.puts(
|
IO.puts(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ set -e
|
||||||
echo "==> Running database migrations..."
|
echo "==> Running database migrations..."
|
||||||
/app/bin/migrate
|
/app/bin/migrate
|
||||||
|
|
||||||
|
echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..."
|
||||||
|
/app/bin/mv eval "Mv.Release.seed_admin()"
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec /app/bin/server
|
exec /app/bin/server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
case result 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]} ->
|
{:ok, [found_user]} ->
|
||||||
assert found_user.id == user.id
|
assert found_user.id == user.id
|
||||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||||
|
|
@ -125,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
flunk("User should be found by oidc_id")
|
flunk("User should be found by oidc_id")
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
flunk("User should be found by oidc_id")
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
flunk("Unexpected error: #{inspect(error)}")
|
flunk("Unexpected error: #{inspect(error)}")
|
||||||
end
|
end
|
||||||
|
|
@ -219,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -260,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
|
|
||||||
# Create a valid fee type
|
# Create a valid fee type
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Setting a valid fee type should work
|
# Setting a valid fee type should work
|
||||||
{:ok, updated} =
|
{:ok, updated} =
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(
|
||||||
default_membership_fee_type_id: fee_type.id
|
:update_membership_fee_settings,
|
||||||
})
|
%{
|
||||||
|
default_membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|> Ash.update(actor: actor)
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated.default_membership_fee_type_id == fee_type.id
|
assert updated.default_membership_fee_type_id == fee_type.id
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert %{errors: errors} = changeset
|
assert %{errors: errors} = changeset
|
||||||
|
|
@ -90,7 +90,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert %{errors: errors} = changeset
|
assert %{errors: errors} = changeset
|
||||||
|
|
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
||||||
assert error.message =~ "yearly"
|
assert error.message =~ "yearly"
|
||||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||||
|
|
||||||
refute changeset.valid?,
|
refute changeset.valid?,
|
||||||
"Should prevent change from #{interval1} to #{interval2}"
|
"Should prevent change from #{interval1} to #{interval2}"
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||||
assert updated.status == :unpaid
|
assert updated.status == :unpaid
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
|
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||||
assert updated.status == :unpaid
|
assert updated.status == :unpaid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(
|
||||||
default_membership_fee_type_id: fee_type.id
|
:update_membership_fee_settings,
|
||||||
})
|
%{
|
||||||
|
default_membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|> Ash.update!(actor: actor)
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Try to delete
|
# Try to delete
|
||||||
|
|
@ -176,9 +180,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(
|
||||||
default_membership_fee_type_id: fee_type.id
|
:update_membership_fee_settings,
|
||||||
})
|
%{
|
||||||
|
default_membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|> Ash.update!(actor: actor)
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Create a member without explicitly setting membership_fee_type_id
|
# Create a member without explicitly setting membership_fee_type_id
|
||||||
|
|
|
||||||
|
|
@ -209,9 +209,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(
|
||||||
default_membership_fee_type_id: fee_type.id
|
:update_membership_fee_settings,
|
||||||
})
|
%{
|
||||||
|
default_membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|> Ash.update!(actor: actor)
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Try to delete
|
# Try to delete
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Authorization
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
@ -19,59 +18,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
%{actor: system_actor}
|
%{actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a role with a specific permission set
|
|
||||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
|
||||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
case Authorization.create_role(
|
|
||||||
%{
|
|
||||||
name: role_name,
|
|
||||||
description: "Test role for #{permission_set_name}",
|
|
||||||
permission_set_name: permission_set_name
|
|
||||||
},
|
|
||||||
actor: actor
|
|
||||||
) do
|
|
||||||
{:ok, role} -> role
|
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a user with a specific permission set
|
|
||||||
# Returns user with role preloaded (required for authorization)
|
|
||||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
|
||||||
# Create role with permission set
|
|
||||||
role = create_role_with_permission_set(permission_set_name, actor)
|
|
||||||
|
|
||||||
# Create user
|
|
||||||
{:ok, user} =
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Assign role to user
|
|
||||||
{:ok, user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|
||||||
|> Ash.update(actor: actor)
|
|
||||||
|
|
||||||
# Reload user with role preloaded (critical for authorization!)
|
|
||||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
|
||||||
user_with_role
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create another user (for testing access to other users)
|
|
||||||
defp create_other_user(actor) do
|
|
||||||
create_user_with_permission_set("own_data", actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Shared test setup for permission sets with scope :own access
|
# Shared test setup for permission sets with scope :own access
|
||||||
defp setup_user_with_own_access(permission_set, actor) do
|
defp setup_user_with_own_access(permission_set, actor) do
|
||||||
user = create_user_with_permission_set(permission_set, actor)
|
user = Mv.Fixtures.user_with_role_fixture(permission_set)
|
||||||
other_user = create_other_user(actor)
|
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
|
|
||||||
# Reload user to ensure role is preloaded
|
# Reload user to ensure role is preloaded
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
|
|
@ -80,217 +30,101 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
%{user: user, other_user: other_user}
|
%{user: user, other_user: other_user}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "own_data permission set (Mitglied)" do
|
# Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User)
|
||||||
setup %{actor: actor} do
|
describe "non-admin permission sets (own_data, read_only, normal_user)" do
|
||||||
setup_user_with_own_access("own_data", actor)
|
setup %{actor: actor} = context do
|
||||||
|
permission_set = context[:permission_set] || "own_data"
|
||||||
|
setup_user_with_own_access(permission_set, actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
for permission_set <- ["own_data", "read_only", "normal_user"] do
|
||||||
{:ok, fetched_user} =
|
@tag permission_set: permission_set
|
||||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
test "can read own user record (#{permission_set})", %{user: user} do
|
||||||
|
{:ok, fetched_user} =
|
||||||
|
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
assert fetched_user.id == user.id
|
assert fetched_user.id == user.id
|
||||||
end
|
|
||||||
|
|
||||||
test "can update own email", %{user: user} do
|
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
|
||||||
|
|
||||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
|
||||||
{:ok, updated_user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
|
||||||
|> Ash.update(actor: user)
|
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
|
||||||
user: user,
|
|
||||||
other_user: other_user
|
|
||||||
} do
|
|
||||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
|
||||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
|
||||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
|
||||||
assert_raise Ash.Error.Invalid, fn ->
|
|
||||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
@tag permission_set: permission_set
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
test "can update own email (#{permission_set})", %{user: user} do
|
||||||
other_user
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
|
||||||
|> Ash.update!(actor: user)
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "list users returns only own user", %{user: user} do
|
@tag permission_set: permission_set
|
||||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
test "cannot read other users - not found due to auto_filter (#{permission_set})", %{
|
||||||
|
user: user,
|
||||||
# Should only return the own user (scope :own filters)
|
other_user: other_user
|
||||||
assert length(users) == 1
|
} do
|
||||||
assert hd(users).id == user.id
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
end
|
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
test "cannot create user (returns forbidden)", %{user: user} do
|
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|
||||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: user)
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
@tag permission_set: permission_set
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
test "cannot update other users - forbidden (#{permission_set})", %{
|
||||||
Ash.destroy!(user, actor: user)
|
user: user,
|
||||||
|
other_user: other_user
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
@tag permission_set: permission_set
|
||||||
setup %{actor: actor} do
|
test "list users returns only own user (#{permission_set})", %{user: user} do
|
||||||
setup_user_with_own_access("read_only", actor)
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
end
|
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
assert length(users) == 1
|
||||||
{:ok, fetched_user} =
|
assert hd(users).id == user.id
|
||||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
|
||||||
|
|
||||||
assert fetched_user.id == user.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update own email", %{user: user} do
|
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
|
||||||
|
|
||||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
|
||||||
{:ok, updated_user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
|
||||||
|> Ash.update(actor: user)
|
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
|
||||||
user: user,
|
|
||||||
other_user: other_user
|
|
||||||
} do
|
|
||||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
|
||||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
|
||||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
|
||||||
assert_raise Ash.Error.Invalid, fn ->
|
|
||||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
@tag permission_set: permission_set
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
test "cannot create user - forbidden (#{permission_set})", %{user: user} do
|
||||||
other_user
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
Accounts.User
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "list users returns only own user", %{user: user} do
|
@tag permission_set: permission_set
|
||||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
# Should only return the own user (scope :own filters)
|
Ash.destroy!(user, actor: user)
|
||||||
assert length(users) == 1
|
end
|
||||||
assert hd(users).id == user.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot create user (returns forbidden)", %{user: user} do
|
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|
||||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: user)
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
@tag permission_set: permission_set
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
test "cannot change role via update_user - forbidden (#{permission_set})", %{
|
||||||
Ash.destroy!(user, actor: user)
|
user: user,
|
||||||
end
|
other_user: other_user
|
||||||
end
|
} do
|
||||||
end
|
other_role = Mv.Fixtures.role_fixture("read_only")
|
||||||
|
|
||||||
describe "normal_user permission set (Kassenwart)" do
|
assert {:error, %Ash.Error.Forbidden{}} =
|
||||||
setup %{actor: actor} do
|
other_user
|
||||||
setup_user_with_own_access("normal_user", actor)
|
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
|
||||||
end
|
|> Ash.update(actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
|
||||||
{:ok, fetched_user} =
|
|
||||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
|
||||||
|
|
||||||
assert fetched_user.id == user.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update own email", %{user: user} do
|
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
|
||||||
|
|
||||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
|
||||||
{:ok, updated_user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
|
||||||
|> Ash.update(actor: user)
|
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
|
||||||
user: user,
|
|
||||||
other_user: other_user
|
|
||||||
} do
|
|
||||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
|
||||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
|
||||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
|
||||||
assert_raise Ash.Error.Invalid, fn ->
|
|
||||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
|
||||||
other_user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
|
||||||
|> Ash.update!(actor: user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "list users returns only own user", %{user: user} do
|
|
||||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
|
||||||
|
|
||||||
# Should only return the own user (scope :own filters)
|
|
||||||
assert length(users) == 1
|
|
||||||
assert hd(users).id == user.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot create user (returns forbidden)", %{user: user} do
|
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|
||||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
|
||||||
Ash.destroy!(user, actor: user)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin permission set" do
|
describe "admin permission set" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("admin", actor)
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
other_user = create_other_user(actor)
|
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
|
|
||||||
# Reload user to ensure role is preloaded
|
# Reload user to ensure role is preloaded
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
|
|
@ -343,6 +177,88 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
# Verify user is deleted
|
# Verify user is deleted
|
||||||
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin can assign role to another user via update_user", %{
|
||||||
|
other_user: other_user
|
||||||
|
} do
|
||||||
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||||
|
|
||||||
|
{:ok, updated} =
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||||
|
|> Ash.update(actor: admin)
|
||||||
|
|
||||||
|
assert updated.role_id == normal_user_role.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "admin role assignment and last-admin validation" do
|
||||||
|
test "two admins: one can change own role to normal_user (other remains admin)", %{
|
||||||
|
actor: _actor
|
||||||
|
} do
|
||||||
|
_admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||||
|
|
||||||
|
admin_a = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
_admin_b = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, updated} =
|
||||||
|
admin_a
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||||
|
|> Ash.update(actor: admin_a)
|
||||||
|
|
||||||
|
assert updated.role_id == normal_user_role.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "single admin: changing own role to normal_user returns validation error", %{
|
||||||
|
actor: _actor
|
||||||
|
} do
|
||||||
|
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||||
|
single_admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
|
single_admin
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||||
|
|> Ash.update(actor: single_admin)
|
||||||
|
|
||||||
|
error_messages =
|
||||||
|
Enum.flat_map(errors, fn
|
||||||
|
%Ash.Error.Changes.InvalidAttribute{message: msg} when is_binary(msg) -> [msg]
|
||||||
|
%{message: msg} when is_binary(msg) -> [msg]
|
||||||
|
_ -> []
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert Enum.any?(error_messages, fn msg ->
|
||||||
|
msg =~ "least one user must keep the Admin role" or msg =~ "Admin role"
|
||||||
|
end),
|
||||||
|
"Expected last-admin validation message, got: #{inspect(error_messages)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can switch to another admin role (two roles with permission_set_name admin)", %{
|
||||||
|
actor: _actor
|
||||||
|
} do
|
||||||
|
# Two distinct roles both with permission_set_name "admin" (e.g. "Admin" and "Superadmin")
|
||||||
|
admin_role_a = Mv.Fixtures.role_fixture("admin")
|
||||||
|
admin_role_b = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
# Ensure user has role_a so we can switch to role_b
|
||||||
|
{:ok, admin_user} =
|
||||||
|
admin_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_a.id})
|
||||||
|
|> Ash.update(actor: admin_user)
|
||||||
|
|
||||||
|
assert admin_user.role_id == admin_role_a.id
|
||||||
|
|
||||||
|
# Switching to another admin role must be allowed (no last-admin error)
|
||||||
|
{:ok, updated} =
|
||||||
|
admin_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_b.id})
|
||||||
|
|> Ash.update(actor: admin_user)
|
||||||
|
|
||||||
|
assert updated.role_id == admin_role_b.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "AshAuthentication bypass" do
|
describe "AshAuthentication bypass" do
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,281 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
|
|
||||||
assert "*" in permissions.pages
|
assert "*" in permissions.pages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin pages include explicit /settings and /membership_fee_settings" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
assert "/settings" in permissions.pages
|
||||||
|
assert "/membership_fee_settings" in permissions.pages
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MemberGroup resource" do
|
||||||
|
test "own_data has MemberGroup read with scope :linked only" do
|
||||||
|
permissions = PermissionSets.get_permissions(:own_data)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :linked
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create == nil || mg_create.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only has MemberGroup read with scope :all, no create/destroy" do
|
||||||
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create == nil || mg_create.granted == false
|
||||||
|
assert mg_destroy == nil || mg_destroy.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user has MemberGroup read/create/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create != nil
|
||||||
|
assert mg_create.scope == :all
|
||||||
|
assert mg_create.granted == true
|
||||||
|
assert mg_destroy != nil
|
||||||
|
assert mg_destroy.scope == :all
|
||||||
|
assert mg_destroy.granted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin has MemberGroup read/create/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create != nil
|
||||||
|
assert mg_create.granted == true
|
||||||
|
assert mg_destroy != nil
|
||||||
|
assert mg_destroy.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MembershipFeeType resource" do
|
||||||
|
test "all permission sets have MembershipFeeType read with scope :all" do
|
||||||
|
for set <- PermissionSets.all_permission_sets() do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mft_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_read != nil, "Permission set #{set} should have MembershipFeeType read"
|
||||||
|
assert mft_read.scope == :all
|
||||||
|
assert mft_read.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "only admin has MembershipFeeType create/update/destroy" do
|
||||||
|
for set <- [:own_data, :read_only, :normal_user] do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mft_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_create == nil || mft_create.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType create"
|
||||||
|
|
||||||
|
assert mft_update == nil || mft_update.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType update"
|
||||||
|
|
||||||
|
assert mft_destroy == nil || mft_destroy.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType destroy"
|
||||||
|
end
|
||||||
|
|
||||||
|
admin_permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mft_create =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_update =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_destroy =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_create != nil
|
||||||
|
assert mft_create.scope == :all
|
||||||
|
assert mft_create.granted == true
|
||||||
|
assert mft_update != nil
|
||||||
|
assert mft_update.granted == true
|
||||||
|
assert mft_destroy != nil
|
||||||
|
assert mft_destroy.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MembershipFeeCycle resource" do
|
||||||
|
test "all permission sets have MembershipFeeCycle read; own_data uses :linked, others :all" do
|
||||||
|
for set <- PermissionSets.all_permission_sets() do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
||||||
|
assert mfc_read.granted == true
|
||||||
|
|
||||||
|
expected_scope = if set == :own_data, do: :linked, else: :all
|
||||||
|
|
||||||
|
assert mfc_read.scope == expected_scope,
|
||||||
|
"Permission set #{set} should have MembershipFeeCycle read scope #{expected_scope}, got #{mfc_read.scope}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only has MembershipFeeCycle read only, no update" do
|
||||||
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_update == nil || mfc_update.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil && mfc_read.granted == true
|
||||||
|
assert mfc_create != nil && mfc_create.scope == :all && mfc_create.granted == true
|
||||||
|
assert mfc_update != nil && mfc_update.granted == true
|
||||||
|
assert mfc_destroy != nil && mfc_destroy.scope == :all && mfc_destroy.granted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil
|
||||||
|
assert mfc_read.granted == true
|
||||||
|
assert mfc_create != nil
|
||||||
|
assert mfc_create.granted == true
|
||||||
|
assert mfc_update != nil
|
||||||
|
assert mfc_update.granted == true
|
||||||
|
assert mfc_destroy != nil
|
||||||
|
assert mfc_destroy.granted == true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "valid_permission_set?/1" do
|
describe "valid_permission_set?/1" do
|
||||||
|
|
|
||||||
226
test/mv/authorization/role_policies_test.exs
Normal file
226
test/mv/authorization/role_policies_test.exs
Normal 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
|
||||||
|
|
@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "permission_set_name validation" do
|
describe "permission_set_name validation" do
|
||||||
test "accepts valid permission set names" do
|
test "accepts valid permission set names", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Test Role",
|
name: "Test Role",
|
||||||
permission_set_name: "own_data"
|
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"
|
assert role.permission_set_name == "own_data"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid permission set names" do
|
test "rejects invalid permission set names", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Test Role",
|
name: "Test Role",
|
||||||
permission_set_name: "invalid_set"
|
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"
|
assert error_message(errors, :permission_set_name) =~ "must be one of"
|
||||||
end
|
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"]
|
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
|
||||||
|
|
||||||
for permission_set <- valid_sets do
|
for permission_set <- valid_sets do
|
||||||
|
|
@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
permission_set_name: permission_set
|
permission_set_name: permission_set
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:ok, _role} = Authorization.create_role(attrs)
|
assert {:ok, _role} = Authorization.create_role(attrs, actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
{:ok, system_role} = Ash.create(changeset, actor: actor)
|
{:ok, system_role} = Ash.create(changeset, actor: actor)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
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)
|
message = error_message(errors, :is_system_role)
|
||||||
assert message =~ "Cannot delete system role"
|
assert message =~ "Cannot delete system role"
|
||||||
end
|
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
|
# is_system_role defaults to false, so regular create works
|
||||||
{:ok, regular_role} =
|
{:ok, regular_role} =
|
||||||
Authorization.create_role(%{
|
Authorization.create_role(
|
||||||
name: "Regular Role",
|
%{name: "Regular Role", permission_set_name: "read_only"},
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "name uniqueness" do
|
describe "name uniqueness" do
|
||||||
test "enforces unique role names" do
|
test "enforces unique role names", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Unique Role",
|
name: "Unique Role",
|
||||||
permission_set_name: "own_data"
|
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"
|
assert error_message(errors, :name) =~ "has already been taken"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
|
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
|
||||||
end
|
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
|
defp ensure_admin_role do
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles(authorize?: false) do
|
||||||
{:ok, roles} ->
|
{:ok, roles} ->
|
||||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||||
nil ->
|
nil ->
|
||||||
{:ok, role} =
|
{:ok, role} =
|
||||||
Authorization.create_role(%{
|
Authorization.create_role(
|
||||||
name: "Admin",
|
%{
|
||||||
description: "Administrator with full access",
|
name: "Admin",
|
||||||
permission_set_name: "admin"
|
description: "Administrator with full access",
|
||||||
})
|
permission_set_name: "admin"
|
||||||
|
},
|
||||||
|
authorize?: false
|
||||||
|
)
|
||||||
|
|
||||||
role
|
role
|
||||||
|
|
||||||
|
|
@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
{:ok, role} =
|
{:ok, role} =
|
||||||
Authorization.create_role(%{
|
Authorization.create_role(
|
||||||
name: "Admin",
|
%{
|
||||||
description: "Administrator with full access",
|
name: "Admin",
|
||||||
permission_set_name: "admin"
|
description: "Administrator with full access",
|
||||||
})
|
permission_set_name: "admin"
|
||||||
|
},
|
||||||
|
authorize?: false
|
||||||
|
)
|
||||||
|
|
||||||
role
|
role
|
||||||
end
|
end
|
||||||
|
|
@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
test "raises error if system user has wrong role", %{system_user: system_user} 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)
|
# 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} =
|
{:ok, read_only_role} =
|
||||||
Authorization.create_role(%{
|
Authorization.create_role(
|
||||||
name: "Read Only Role",
|
%{
|
||||||
description: "Read-only access",
|
name: "Read Only Role",
|
||||||
permission_set_name: "read_only"
|
description: "Read-only access",
|
||||||
})
|
permission_set_name: "read_only"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,67 +8,30 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Authorization
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{actor: system_actor}
|
%{actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
defp create_custom_field do
|
||||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
case Authorization.create_role(
|
|
||||||
%{
|
|
||||||
name: role_name,
|
|
||||||
description: "Test role for #{permission_set_name}",
|
|
||||||
permission_set_name: permission_set_name
|
|
||||||
},
|
|
||||||
actor: actor
|
|
||||||
) do
|
|
||||||
{:ok, role} -> role
|
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
|
||||||
role = create_role_with_permission_set(permission_set_name, actor)
|
|
||||||
|
|
||||||
{:ok, user} =
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
{:ok, user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|
||||||
|> Ash.update(actor: actor)
|
|
||||||
|
|
||||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
|
||||||
user_with_role
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_custom_field(actor) do
|
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test_field_#{System.unique_integer([:positive])}",
|
name: "test_field_#{System.unique_integer([:positive])}",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||||
|
|
||||||
field
|
field
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "read access (all roles)" do
|
describe "read access (all roles)" do
|
||||||
test "user with own_data can read all custom fields", %{actor: actor} do
|
test "user with own_data can read all custom fields", %{actor: _actor} do
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
user = create_user_with_permission_set("own_data", actor)
|
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
|
|
||||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||||
ids = Enum.map(fields, & &1.id)
|
ids = Enum.map(fields, & &1.id)
|
||||||
|
|
@ -78,9 +41,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
assert fetched.id == custom_field.id
|
assert fetched.id == custom_field.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user with read_only can read all custom fields", %{actor: actor} do
|
test "user with read_only can read all custom fields", %{actor: _actor} do
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
user = create_user_with_permission_set("read_only", actor)
|
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
|
|
||||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||||
ids = Enum.map(fields, & &1.id)
|
ids = Enum.map(fields, & &1.id)
|
||||||
|
|
@ -90,9 +53,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
assert fetched.id == custom_field.id
|
assert fetched.id == custom_field.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user with normal_user can read all custom fields", %{actor: actor} do
|
test "user with normal_user can read all custom fields", %{actor: _actor} do
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
user = create_user_with_permission_set("normal_user", actor)
|
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
|
||||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||||
ids = Enum.map(fields, & &1.id)
|
ids = Enum.map(fields, & &1.id)
|
||||||
|
|
@ -102,9 +65,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
assert fetched.id == custom_field.id
|
assert fetched.id == custom_field.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user with admin can read all custom fields", %{actor: actor} do
|
test "user with admin can read all custom fields", %{actor: _actor} do
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
user = create_user_with_permission_set("admin", actor)
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||||
ids = Enum.map(fields, & &1.id)
|
ids = Enum.map(fields, & &1.id)
|
||||||
|
|
@ -116,9 +79,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "write access - non-admin cannot create/update/destroy" do
|
describe "write access - non-admin cannot create/update/destroy" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: _actor} do
|
||||||
user = create_user_with_permission_set("normal_user", actor)
|
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
%{user: user, custom_field: custom_field}
|
%{user: user, custom_field: custom_field}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -152,9 +115,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "write access - admin can create/update/destroy" do
|
describe "write access - admin can create/update/destroy" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: _actor} do
|
||||||
user = create_user_with_permission_set("admin", actor)
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
%{user: user, custom_field: custom_field}
|
%{user: user, custom_field: custom_field}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Authorization
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
@ -20,47 +19,9 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
%{actor: system_actor}
|
%{actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a role with a specific permission set
|
defp create_linked_member_for_user(user, _actor) do
|
||||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
case Authorization.create_role(
|
|
||||||
%{
|
|
||||||
name: role_name,
|
|
||||||
description: "Test role for #{permission_set_name}",
|
|
||||||
permission_set_name: permission_set_name
|
|
||||||
},
|
|
||||||
actor: actor
|
|
||||||
) do
|
|
||||||
{:ok, role} -> role
|
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a user with a specific permission set
|
|
||||||
# Returns user with role preloaded (required for authorization)
|
|
||||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
|
||||||
role = create_role_with_permission_set(permission_set_name, actor)
|
|
||||||
|
|
||||||
{:ok, user} =
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
{:ok, user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|
||||||
|> Ash.update(actor: actor)
|
|
||||||
|
|
||||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
|
||||||
user_with_role
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_linked_member_for_user(user, actor) do
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
|
|
@ -68,18 +29,20 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||||
},
|
},
|
||||||
actor: actor
|
actor: admin
|
||||||
)
|
)
|
||||||
|
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|
||||||
|> Ash.update(actor: actor, domain: Mv.Accounts, return_notifications?: false)
|
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
|
||||||
|
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_unlinked_member(actor) do
|
defp create_unlinked_member(_actor) do
|
||||||
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
|
|
@ -87,25 +50,29 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||||
},
|
},
|
||||||
actor: actor
|
actor: admin
|
||||||
)
|
)
|
||||||
|
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_custom_field(actor) do
|
defp create_custom_field do
|
||||||
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test_field_#{System.unique_integer([:positive])}",
|
name: "test_field_#{System.unique_integer([:positive])}",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: actor)
|
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||||
|
|
||||||
field
|
field
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_custom_field_value(member_id, custom_field_id, value, actor) do
|
defp create_custom_field_value(member_id, custom_field_id, value) do
|
||||||
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, cfv} =
|
{:ok, cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -113,22 +80,22 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
custom_field_id: custom_field_id,
|
custom_field_id: custom_field_id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => value}
|
value: %{"_union_type" => "string", "_union_value" => value}
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||||
|
|
||||||
cfv
|
cfv
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "own_data permission set (Mitglied)" do
|
describe "own_data permission set (Mitglied)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("own_data", actor)
|
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||||
|
|
||||||
cfv_unlinked =
|
cfv_unlinked =
|
||||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
@ -177,10 +144,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
test "can create custom field value for linked member", %{
|
test "can create custom field value for linked member", %{
|
||||||
user: user,
|
user: user,
|
||||||
linked_member: linked_member,
|
linked_member: linked_member,
|
||||||
actor: actor
|
actor: _actor
|
||||||
} do
|
} do
|
||||||
# Create a second custom field via admin (own_data cannot create CustomField)
|
# Create a second custom field via admin (own_data cannot create CustomField)
|
||||||
custom_field2 = create_custom_field(actor)
|
custom_field2 = create_custom_field()
|
||||||
|
|
||||||
{:ok, cfv} =
|
{:ok, cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -257,15 +224,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
|
|
||||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("read_only", actor)
|
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||||
|
|
||||||
cfv_unlinked =
|
cfv_unlinked =
|
||||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
@ -340,15 +307,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
|
|
||||||
describe "normal_user permission set (Kassenwart)" do
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("normal_user", actor)
|
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||||
|
|
||||||
cfv_unlinked =
|
cfv_unlinked =
|
||||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
@ -379,10 +346,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
test "can create custom field value", %{
|
test "can create custom field value", %{
|
||||||
user: user,
|
user: user,
|
||||||
unlinked_member: unlinked_member,
|
unlinked_member: unlinked_member,
|
||||||
actor: actor
|
actor: _actor
|
||||||
} do
|
} do
|
||||||
# normal_user cannot create CustomField; use actor (admin) to create it
|
# normal_user cannot create CustomField; use actor (admin) to create it
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
{:ok, cfv} =
|
{:ok, cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -421,15 +388,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
|
|
||||||
describe "admin permission set" do
|
describe "admin permission set" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("admin", actor)
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
custom_field = create_custom_field(actor)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||||
|
|
||||||
cfv_unlinked =
|
cfv_unlinked =
|
||||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
@ -457,7 +424,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
|
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
|
||||||
custom_field = create_custom_field(user)
|
custom_field = create_custom_field()
|
||||||
|
|
||||||
{:ok, cfv} =
|
{:ok, cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
|
||||||
140
test/mv/membership/group_policies_test.exs
Normal file
140
test/mv/membership/group_policies_test.exs
Normal 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
|
||||||
194
test/mv/membership/member_email_validation_test.exs
Normal file
194
test/mv/membership/member_email_validation_test.exs
Normal 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
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
defmodule Mv.Membership.MemberExportSortTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.MemberExportSort
|
|
||||||
|
|
||||||
describe "custom_field_sort_key/2" do
|
|
||||||
test "nil has rank 1 (sorts last in asc, first in desc)" do
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:string, nil) == {1, nil}
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:date, nil) == {1, nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date: chronological key (ISO8601 string)" do
|
|
||||||
earlier = ~D[2023-01-15]
|
|
||||||
later = ~D[2024-06-01]
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:date, earlier) == {0, "2023-01-15"}
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:date, later) == {0, "2024-06-01"}
|
|
||||||
assert {0, "2023-01-15"} < {0, "2024-06-01"}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "date + nil: nil sorts after any date in asc" do
|
|
||||||
key_date = MemberExportSort.custom_field_sort_key(:date, ~D[2024-01-01])
|
|
||||||
key_nil = MemberExportSort.custom_field_sort_key(:date, nil)
|
|
||||||
assert key_date == {0, "2024-01-01"}
|
|
||||||
assert key_nil == {1, nil}
|
|
||||||
assert key_date < key_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "boolean: false < true" do
|
|
||||||
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
|
|
||||||
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
|
|
||||||
assert key_f == {0, 0}
|
|
||||||
assert key_t == {0, 1}
|
|
||||||
assert key_f < key_t
|
|
||||||
end
|
|
||||||
|
|
||||||
test "boolean + nil: nil sorts after false and true in asc" do
|
|
||||||
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
|
|
||||||
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
|
|
||||||
key_nil = MemberExportSort.custom_field_sort_key(:boolean, nil)
|
|
||||||
assert key_f < key_nil and key_t < key_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "integer: numerical key" do
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:integer, 10) == {0, 10}
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:integer, -5) == {0, -5}
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:integer, 0) == {0, 0}
|
|
||||||
assert {0, -5} < {0, 0} and {0, 0} < {0, 10}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "string: case-insensitive key (downcased)" do
|
|
||||||
key_a = MemberExportSort.custom_field_sort_key(:string, "Anna")
|
|
||||||
key_b = MemberExportSort.custom_field_sort_key(:string, "bert")
|
|
||||||
assert key_a == {0, "anna"}
|
|
||||||
assert key_b == {0, "bert"}
|
|
||||||
assert key_a < key_b
|
|
||||||
end
|
|
||||||
|
|
||||||
test "email: case-insensitive key" do
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:email, "User@Example.com") ==
|
|
||||||
{0, "user@example.com"}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Ash.Union value is unwrapped" do
|
|
||||||
union = %Ash.Union{value: ~D[2024-01-01], type: :date}
|
|
||||||
assert MemberExportSort.custom_field_sort_key(:date, union) == {0, "2024-01-01"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "key_lt/3" do
|
|
||||||
test "asc: smaller key first, nil last" do
|
|
||||||
k_nil = {1, nil}
|
|
||||||
k_early = {0, "2023-01-01"}
|
|
||||||
k_late = {0, "2024-01-01"}
|
|
||||||
refute MemberExportSort.key_lt(k_nil, k_early, "asc")
|
|
||||||
refute MemberExportSort.key_lt(k_nil, k_late, "asc")
|
|
||||||
assert MemberExportSort.key_lt(k_early, k_late, "asc")
|
|
||||||
assert MemberExportSort.key_lt(k_early, k_nil, "asc")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desc: larger key first, nil first" do
|
|
||||||
k_nil = {1, nil}
|
|
||||||
k_early = {0, "2023-01-01"}
|
|
||||||
k_late = {0, "2024-01-01"}
|
|
||||||
assert MemberExportSort.key_lt(k_nil, k_early, "desc")
|
|
||||||
assert MemberExportSort.key_lt(k_nil, k_late, "desc")
|
|
||||||
assert MemberExportSort.key_lt(k_late, k_early, "desc")
|
|
||||||
refute MemberExportSort.key_lt(k_early, k_nil, "desc")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
234
test/mv/membership/member_group_policies_test.exs
Normal file
234
test/mv/membership/member_group_policies_test.exs
Normal 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
|
||||||
|
|
@ -12,7 +12,6 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Authorization
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
@ -21,58 +20,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
%{actor: system_actor}
|
%{actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a role with a specific permission set
|
|
||||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
|
||||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
case Authorization.create_role(
|
|
||||||
%{
|
|
||||||
name: role_name,
|
|
||||||
description: "Test role for #{permission_set_name}",
|
|
||||||
permission_set_name: permission_set_name
|
|
||||||
},
|
|
||||||
actor: actor
|
|
||||||
) do
|
|
||||||
{:ok, role} -> role
|
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a user with a specific permission set
|
|
||||||
# Returns user with role preloaded (required for authorization)
|
|
||||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
|
||||||
# Create role with permission set
|
|
||||||
role = create_role_with_permission_set(permission_set_name, actor)
|
|
||||||
|
|
||||||
# Create user
|
|
||||||
{:ok, user} =
|
|
||||||
Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Assign role to user
|
|
||||||
{:ok, user} =
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|
||||||
|> Ash.update(actor: actor)
|
|
||||||
|
|
||||||
# Reload user with role preloaded (critical for authorization!)
|
|
||||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
|
||||||
user_with_role
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create an admin user (for creating test fixtures)
|
|
||||||
defp create_admin_user(actor) do
|
|
||||||
create_user_with_permission_set("admin", actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a member linked to a user
|
# Helper to create a member linked to a user
|
||||||
defp create_linked_member_for_user(user, actor) do
|
defp create_linked_member_for_user(user, _actor) do
|
||||||
admin = create_admin_user(actor)
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
# NOTE: We need to ensure the member is actually persisted to the database
|
# NOTE: We need to ensure the member is actually persisted to the database
|
||||||
|
|
@ -105,8 +55,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create an unlinked member (no user relationship)
|
# Helper to create an unlinked member (no user relationship)
|
||||||
defp create_unlinked_member(actor) do
|
defp create_unlinked_member(_actor) do
|
||||||
admin = create_admin_user(actor)
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Membership.create_member(
|
||||||
|
|
@ -123,7 +73,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
describe "own_data permission set (Mitglied)" do
|
describe "own_data permission set (Mitglied)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("own_data", actor)
|
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
|
|
@ -207,7 +157,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("read_only", actor)
|
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
|
|
@ -273,7 +223,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
describe "normal_user permission set (Kassenwart)" do
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("normal_user", actor)
|
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
|
|
@ -330,7 +280,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
describe "admin permission set" do
|
describe "admin permission set" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("admin", actor)
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member(actor)
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
|
|
@ -397,7 +347,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
# read_only has Member.read scope :all, but the special case ensures
|
# read_only has Member.read scope :all, but the special case ensures
|
||||||
# users can ALWAYS read their linked member, even if they had no read permission.
|
# users can ALWAYS read their linked member, even if they had no read permission.
|
||||||
# This test verifies the special case works independently of permission sets.
|
# This test verifies the special case works independently of permission sets.
|
||||||
user = create_user_with_permission_set("read_only", actor)
|
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
|
|
@ -416,7 +366,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
|
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
|
||||||
# own_data has Member.read scope :linked, but the special case ensures
|
# own_data has Member.read scope :linked, but the special case ensures
|
||||||
# users can ALWAYS read their linked member regardless of permission set.
|
# users can ALWAYS read their linked member regardless of permission set.
|
||||||
user = create_user_with_permission_set("own_data", actor)
|
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
|
|
@ -437,7 +387,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
} do
|
} do
|
||||||
# Update is NOT handled by special case - it's handled by HasPermission
|
# Update is NOT handled by special case - it's handled by HasPermission
|
||||||
# with :linked scope. own_data has Member.update scope :linked.
|
# with :linked scope. own_data has Member.update scope :linked.
|
||||||
user = create_user_with_permission_set("own_data", actor)
|
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
linked_member = create_linked_member_for_user(user, actor)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
|
|
@ -453,4 +403,184 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
assert updated_member.first_name == "Updated"
|
assert updated_member.first_name == "Updated"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
defmodule Mv.Membership.MembersCSVTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.MembersCSV
|
|
||||||
|
|
||||||
describe "export/2" do
|
|
||||||
test "returns CSV with header and one data row (member fields only)" do
|
|
||||||
member = %{first_name: "Jane", email: "jane@example.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "First Name"
|
|
||||||
assert csv =~ "Email"
|
|
||||||
assert csv =~ "Jane"
|
|
||||||
assert csv =~ "jane@example.com"
|
|
||||||
lines = String.split(csv, "\n", trim: true)
|
|
||||||
assert length(lines) == 2
|
|
||||||
end
|
|
||||||
|
|
||||||
test "header uses display labels not raw field names (regression guard)" do
|
|
||||||
member = %{first_name: "Jane", email: "jane@example.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
header_line = csv |> String.split("\n", trim: true) |> hd()
|
|
||||||
|
|
||||||
assert header_line =~ "First Name"
|
|
||||||
assert header_line =~ "Email"
|
|
||||||
refute header_line =~ "first_name"
|
|
||||||
refute header_line =~ "email"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "escapes cell containing comma (RFC 4180 quoted)" do
|
|
||||||
member = %{first_name: "Doe, John", email: "john@example.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ ~s("Doe, John")
|
|
||||||
assert csv =~ "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do
|
|
||||||
member = %{first_name: ~s(He said "Hi"), email: "a@b.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ ~s("He said ""Hi""")
|
|
||||||
assert csv =~ "a@b.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats date as ISO8601 for member fields" do
|
|
||||||
member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "Join Date", kind: :member_field, key: "join_date"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "2024-03-15"
|
|
||||||
assert csv =~ "Join Date"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "formats nil as empty string" do
|
|
||||||
member = %{first_name: "Only", last_name: nil, email: "x@y.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "First Name"
|
|
||||||
assert csv =~ "Only"
|
|
||||||
assert csv =~ "x@y.com"
|
|
||||||
assert csv =~ "Only,,x@y"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "custom field column uses header and formats value" do
|
|
||||||
custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{
|
|
||||||
first_name: "Test",
|
|
||||||
email: "e@e.com",
|
|
||||||
custom_field_values: [
|
|
||||||
%{custom_field_id: "cf-1", value: true, custom_field: custom_cf}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "Active"
|
|
||||||
assert csv =~ "Yes"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "custom field uses display_name when present, else name" do
|
|
||||||
custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{
|
|
||||||
header: "Display Label",
|
|
||||||
kind: :custom_field,
|
|
||||||
key: "cf-a",
|
|
||||||
custom_field: Map.put(custom_cf, :display_name, "Display Label")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{
|
|
||||||
first_name: "X",
|
|
||||||
email: "x@x.com",
|
|
||||||
custom_field_values: [
|
|
||||||
%{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "Display Label"
|
|
||||||
assert csv =~ "only_a"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "missing custom field value yields empty cell" do
|
|
||||||
cf1 = %{id: "cf-a", name: "FieldA", value_type: :string}
|
|
||||||
cf2 = %{id: "cf-b", name: "FieldB", value_type: :string}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1},
|
|
||||||
%{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{
|
|
||||||
first_name: "X",
|
|
||||||
email: "x@x.com",
|
|
||||||
custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}]
|
|
||||||
}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "First Name,Email,FieldA,FieldB"
|
|
||||||
assert csv =~ "only_a"
|
|
||||||
assert csv =~ "X,x@x.com,only_a,"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "computed column exports membership fee status label" do
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "Membership Fee Status"
|
|
||||||
assert csv =~ "Paid"
|
|
||||||
assert csv =~ "M,m@m.com,Paid"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "computed column with payment_status key exports same value (alias)" do
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Membership Fee Status", kind: :computed, key: :payment_status}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{first_name: "X", payment_status: "Unpaid"}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "Membership Fee Status"
|
|
||||||
assert csv =~ "Unpaid"
|
|
||||||
assert csv =~ "X,Unpaid"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
|
|
||||||
member = %{
|
|
||||||
first_name: "=SUM(A1:A10)",
|
|
||||||
last_name: "+1",
|
|
||||||
email: "@cmd|evil"
|
|
||||||
}
|
|
||||||
|
|
||||||
custom_cf = %{id: "cf-1", name: "Note", value_type: :string}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
|
|
||||||
]
|
|
||||||
|
|
||||||
member_with_cf =
|
|
||||||
Map.put(member, :custom_field_values, [
|
|
||||||
%{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf}
|
|
||||||
])
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member_with_cf], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "'=SUM(A1:A10)"
|
|
||||||
assert csv =~ "'+1"
|
|
||||||
assert csv =~ "'@cmd|evil"
|
|
||||||
assert csv =~ "normal text"
|
|
||||||
refute csv =~ ",'normal text"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "CSV injection: minus and tab prefix are escaped" do
|
|
||||||
member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"}
|
|
||||||
]
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "'-2"
|
|
||||||
assert csv =~ "'\tleading"
|
|
||||||
assert csv =~ "safe@x.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "column order is preserved (headers and values)" do
|
|
||||||
cf1 = %{id: "a", name: "Custom1", value_type: :string}
|
|
||||||
cf2 = %{id: "b", name: "Custom2", value_type: :string}
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
|
||||||
%{header: "Email", kind: :member_field, key: "email"},
|
|
||||||
%{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2},
|
|
||||||
%{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1}
|
|
||||||
]
|
|
||||||
|
|
||||||
member = %{
|
|
||||||
first_name: "M",
|
|
||||||
email: "m@m.com",
|
|
||||||
custom_field_values: [
|
|
||||||
%{custom_field_id: "a", value: "v1", custom_field: cf1},
|
|
||||||
%{custom_field_id: "b", value: "v2", custom_field: cf2}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
iodata = MembersCSV.export([member], columns)
|
|
||||||
csv = IO.iodata_to_binary(iodata)
|
|
||||||
|
|
||||||
assert csv =~ "First Name,Email,Custom2,Custom1"
|
|
||||||
assert csv =~ "M,m@m.com,v2,v1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
294
test/mv/membership_fees/membership_fee_cycle_policies_test.exs
Normal file
294
test/mv/membership_fees/membership_fee_cycle_policies_test.exs
Normal 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
|
||||||
260
test/mv/membership_fees/membership_fee_type_policies_test.exs
Normal file
260
test/mv/membership_fees/membership_fee_type_policies_test.exs
Normal 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
|
||||||
49
test/mv/oidc_role_sync_config_test.exs
Normal file
49
test/mv/oidc_role_sync_config_test.exs
Normal 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
|
||||||
181
test/mv/oidc_role_sync_test.exs
Normal file
181
test/mv/oidc_role_sync_test.exs
Normal 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
222
test/mv/release_test.exs
Normal 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
|
||||||
|
|
@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do
|
||||||
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
|
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin cannot manage roles" do
|
test "non-admin can read roles but cannot create/update/destroy" do
|
||||||
normal_user = %{
|
normal_user = %{
|
||||||
id: "normal-123",
|
id: "normal-123",
|
||||||
role: %{permission_set_name: "normal_user"}
|
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, :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, :update, Mv.Authorization.Role) == false
|
||||||
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Returns assigns for an authenticated user with all required attributes.
|
# 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
|
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",
|
club_name: "Test Club",
|
||||||
mobile: mobile
|
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"
|
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
||||||
|
|
||||||
# Check that nested menu groups exist
|
# 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 html =~ ~s(role="group")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
||||||
|
|
@ -193,7 +200,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for nested menu structure
|
# 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(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -521,7 +530,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert html =~ ~s(role="menuitem")
|
assert html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Check that nested menus exist
|
# 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")
|
assert html =~ ~s(role="group")
|
||||||
|
|
||||||
# Footer section
|
# Footer section
|
||||||
|
|
@ -629,7 +640,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# expanded-menu-group structure present
|
# 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(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -843,7 +856,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
|
|
||||||
# Expanded menu group should have correct structure
|
# Expanded menu group should have correct structure
|
||||||
# (CSS handles hover effects, but we verify 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")
|
assert html =~ ~s(role="group")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue