Compare commits
66 commits
4e55ec9123
...
42e7a031ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e7a031ce | ||
| cc02748cc6 | |||
| ad54b0c462 | |||
| 6ab0365a8c | |||
| ad42a53919 | |||
| c5f1fdce0a | |||
| d573a22769 | |||
| 58a5b086ad | |||
| d441009c8a | |||
| d37fc03a37 | |||
| 55fef5a993 | |||
| 99722dee26 | |||
| a6e35da0f7 | |||
| 50c8a0dc9a | |||
| e065b39ed4 | |||
| b177e41882 | |||
| 09a4b7c937 | |||
| 7a56a0920b | |||
| 59d94cf1c6 | |||
| 361331b76e | |||
| 3415faeb21 | |||
| d34ff57531 | |||
| 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 | |||
| e0f0ca369c | |||
| 7041aa320a | |||
| 96daf2a089 | |||
| b2e9aff359 | |||
| 6aba54df68 |
103 changed files with 6717 additions and 2856 deletions
|
|
@ -273,7 +273,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.95
|
||||
image: renovate/renovate:43.3
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
13
.env.example
13
.env.example
|
|
@ -11,9 +11,22 @@ PHX_HOST=localhost
|
|||
# Recommended: Association settings
|
||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
||||
|
||||
# Optional: Admin user (created/updated on container start via Release.seed_admin)
|
||||
# In production, set these so the first admin can log in. Change password without redeploy:
|
||||
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
|
||||
# ADMIN_EMAIL=admin@example.com
|
||||
# ADMIN_PASSWORD=secure-password
|
||||
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
|
||||
|
||||
# Optional: OIDC Configuration
|
||||
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
||||
|
||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
||||
# OIDC_ADMIN_GROUP_NAME=admin
|
||||
# OIDC_GROUPS_CLAIM=groups
|
||||
|
|
|
|||
|
|
@ -81,9 +81,11 @@ lib/
|
|||
├── membership/ # Membership domain
|
||||
│ ├── membership.ex # Domain definition
|
||||
│ ├── member.ex # Member resource
|
||||
│ ├── custom_field.ex # Custom field (definition) resource
|
||||
│ ├── custom_field_value.ex # Custom field value resource
|
||||
│ ├── custom_field.ex # CustomFieldValue type resource
|
||||
│ ├── setting.ex # Global settings (singleton resource)
|
||||
│ ├── group.ex # Group resource
|
||||
│ ├── member_group.ex # MemberGroup join table resource
|
||||
│ └── email.ex # Email custom type
|
||||
├── membership_fees/ # MembershipFees domain
|
||||
│ ├── membership_fees.ex # Domain definition
|
||||
|
|
@ -149,6 +151,8 @@ lib/
|
|||
│ │ ├── membership_fee_type_live/ # Membership fee type LiveViews
|
||||
│ │ ├── membership_fee_settings_live.ex # Membership fee settings
|
||||
│ │ ├── global_settings_live.ex # Global settings
|
||||
│ │ ├── group_live/ # Group management LiveViews
|
||||
│ │ ├── import_export_live.ex # CSV import/export LiveView
|
||||
│ │ └── contribution_type_live/ # Contribution types (mock-up)
|
||||
│ ├── auth_overrides.ex # AshAuthentication overrides
|
||||
│ ├── endpoint.ex # Phoenix endpoint
|
||||
|
|
@ -194,7 +198,8 @@ test/
|
|||
├── seeds_test.exs # Database seed tests
|
||||
└── support/ # Test helpers
|
||||
├── conn_case.ex # Controller test helpers
|
||||
└── data_case.ex # Data layer test helpers
|
||||
├── data_case.ex # Data layer test helpers
|
||||
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||
```
|
||||
|
||||
### 1.2 Module Organization
|
||||
|
|
@ -641,7 +646,95 @@ def card(assigns) do
|
|||
end
|
||||
```
|
||||
|
||||
### 3.3 System Actor Pattern
|
||||
### 3.3 CSV Import Configuration
|
||||
|
||||
**CSV Import Limits:**
|
||||
|
||||
CSV import functionality supports configurable limits to prevent resource exhaustion:
|
||||
|
||||
```elixir
|
||||
# config/config.exs
|
||||
config :mv,
|
||||
csv_import: [
|
||||
max_file_size_mb: 10, # Maximum file size in megabytes
|
||||
max_rows: 1000 # Maximum number of data rows (excluding header)
|
||||
]
|
||||
```
|
||||
|
||||
**Accessing Configuration:**
|
||||
|
||||
Use `Mv.Config` helper functions:
|
||||
|
||||
```elixir
|
||||
# Get max file size in bytes
|
||||
max_bytes = Mv.Config.csv_import_max_file_size_bytes()
|
||||
|
||||
# Get max file size in megabytes
|
||||
max_mb = Mv.Config.csv_import_max_file_size_mb()
|
||||
|
||||
# Get max rows
|
||||
max_rows = Mv.Config.csv_import_max_rows()
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Set reasonable limits based on server resources
|
||||
- Display limits to users in UI
|
||||
- Validate file size before upload
|
||||
- Process imports in chunks (default: 200 rows per chunk)
|
||||
- Cap error collection (default: 50 errors per import)
|
||||
|
||||
### 3.4 Page-Level Authorization
|
||||
|
||||
**CheckPagePermission Plug:**
|
||||
|
||||
Use `MvWeb.Plugs.CheckPagePermission` for page-level authorization:
|
||||
|
||||
```elixir
|
||||
# lib/mv_web/router.ex
|
||||
defmodule MvWeb.Router do
|
||||
use MvWeb, :router
|
||||
|
||||
# Add plug to router pipeline
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {MvWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug MvWeb.Plugs.CheckPagePermission # Page-level authorization
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Permission Set Route Matrix:**
|
||||
|
||||
Routes are mapped to permission sets:
|
||||
- `own_data`: Can access `/profile` and `/members/:id` (own linked member only)
|
||||
- `read_only`: Can read all data, cannot modify
|
||||
- `normal_user`: Can read and modify most data
|
||||
- `admin`: Full access to all routes
|
||||
|
||||
**Usage in LiveViews:**
|
||||
|
||||
```elixir
|
||||
# Check page access before mount
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
if MvWeb.Authorization.can_access_page?(actor, "/admin/roles") do
|
||||
{:ok, assign(socket, :roles, load_roles(actor))}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/")}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Public Paths:**
|
||||
|
||||
Public paths (login, OIDC callbacks) are excluded from permission checks automatically.
|
||||
|
||||
### 3.5 System Actor Pattern
|
||||
|
||||
**When to Use System Actor:**
|
||||
|
||||
|
|
@ -726,7 +819,7 @@ Two mechanisms exist for bypassing standard authorization:
|
|||
|
||||
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
|
||||
|
||||
### 3.4 Ash Framework
|
||||
### 3.6 Ash Framework
|
||||
|
||||
**Resource Definition Best Practices:**
|
||||
|
||||
|
|
@ -1247,7 +1340,8 @@ test/
|
|||
│ └── components/
|
||||
└── support/ # Test helpers
|
||||
├── conn_case.ex # Controller test setup
|
||||
└── data_case.ex # Database test setup
|
||||
├── data_case.ex # Database test setup
|
||||
└── fixtures.ex # Shared test fixtures (Mv.Fixtures)
|
||||
```
|
||||
|
||||
**Test File Naming:**
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ config :mv,
|
|||
max_rows: 1000
|
||||
]
|
||||
|
||||
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
|
||||
config :mv, :oidc_role_sync,
|
||||
admin_group_name: nil,
|
||||
groups_claim: "groups"
|
||||
|
||||
# Configures the endpoint
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@ if System.get_env("PHX_SERVER") do
|
|||
config :mv, MvWeb.Endpoint, server: true
|
||||
end
|
||||
|
||||
# OIDC group → Admin role sync: read from ENV in all environments (dev/test/prod)
|
||||
config :mv, :oidc_role_sync,
|
||||
admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"),
|
||||
groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups"
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url = build_database_url.()
|
||||
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -15,10 +15,10 @@ This document provides a comprehensive overview of the Mila Membership Managemen
|
|||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| **Tables** | 9 |
|
||||
| **Tables** | 11 |
|
||||
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
|
||||
| **Relationships** | 7 |
|
||||
| **Indexes** | 20+ |
|
||||
| **Relationships** | 9 |
|
||||
| **Indexes** | 25+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
|
@ -77,6 +77,23 @@ This document provides a comprehensive overview of the Mila Membership Managemen
|
|||
- Membership fee default settings
|
||||
- Environment variable support for club name
|
||||
|
||||
#### `groups`
|
||||
- **Purpose:** Group definitions for organizing members
|
||||
- **Rows (Estimated):** Low (typically 5-20 groups per club)
|
||||
- **Key Features:**
|
||||
- Unique group names (case-insensitive)
|
||||
- URL-friendly slugs (auto-generated, immutable)
|
||||
- Optional descriptions
|
||||
- Many-to-many relationship with members
|
||||
|
||||
#### `member_groups`
|
||||
- **Purpose:** Join table for many-to-many relationship between members and groups
|
||||
- **Rows (Estimated):** Medium to High (multiple groups per member)
|
||||
- **Key Features:**
|
||||
- Unique constraint on (member_id, group_id)
|
||||
- CASCADE delete on both sides
|
||||
- Efficient indexes for queries
|
||||
|
||||
### Authorization Domain
|
||||
|
||||
#### `roles`
|
||||
|
|
@ -100,6 +117,10 @@ Member (1) → (N) MembershipFeeCycles
|
|||
↓
|
||||
MembershipFeeType (1)
|
||||
|
||||
Member (N) ←→ (N) Group
|
||||
↓ ↓
|
||||
MemberGroups (N) MemberGroups (N)
|
||||
|
||||
Settings (1) → MembershipFeeType (0..1)
|
||||
```
|
||||
|
||||
|
|
@ -145,6 +166,12 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
- Settings can reference a default fee type
|
||||
- `ON DELETE SET NULL` - if fee type is deleted, setting is cleared
|
||||
|
||||
9. **Member ↔ Group (N:N via MemberGroup)**
|
||||
- Many-to-many relationship through `member_groups` join table
|
||||
- `ON DELETE CASCADE` on both sides - removing member/group removes associations
|
||||
- Unique constraint on (member_id, group_id) prevents duplicates
|
||||
- Groups searchable via member search vector
|
||||
|
||||
## Important Business Rules
|
||||
|
||||
### Email Synchronization
|
||||
|
|
@ -509,7 +536,7 @@ mix run priv/repo/seeds.exs
|
|||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-13
|
||||
**Schema Version:** 1.4
|
||||
**Last Updated:** 2026-01-27
|
||||
**Schema Version:** 1.5
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
|
|
|
|||
|
|
@ -1752,8 +1752,151 @@ This project demonstrates a modern Phoenix application built with:
|
|||
|
||||
---
|
||||
|
||||
**Document Version:** 1.4
|
||||
**Last Updated:** 2026-01-13
|
||||
---
|
||||
|
||||
## Recent Updates (2026-01-13 to 2026-01-27)
|
||||
|
||||
### Groups Feature Implementation (2026-01-27)
|
||||
|
||||
**PR #378:** *Add groups resource* (closes #371)
|
||||
- Created `Mv.Membership.Group` resource with name, slug, description
|
||||
- Created `Mv.Membership.MemberGroup` join table for many-to-many relationship
|
||||
- Automatic slug generation from name (immutable after creation)
|
||||
- Case-insensitive name uniqueness via LOWER(name) index
|
||||
- Database migration: `20260127141620_add_groups_and_member_groups.exs`
|
||||
|
||||
**PR #382:** *Groups Admin UI* (closes #372)
|
||||
- Groups management LiveViews (`/groups`)
|
||||
- Create, edit, delete groups with confirmation
|
||||
- Member count display per group
|
||||
- Add/remove members from groups
|
||||
- Groups displayed in member overview and detail views
|
||||
- Filter and sort by groups in member list
|
||||
|
||||
**Key Features:**
|
||||
- Many-to-many relationship: Members can belong to multiple groups
|
||||
- Groups searchable via member search vector (full-text search)
|
||||
- CASCADE delete: Removing member/group removes associations
|
||||
- Unique constraint prevents duplicate member-group associations
|
||||
|
||||
### CSV Import Feature Implementation (2026-01-27)
|
||||
|
||||
**PR #359:** *Implements CSV Import UI* (closes #335)
|
||||
- Import/Export LiveView (`/import_export`)
|
||||
- CSV file upload with auto-upload
|
||||
- Real-time import progress tracking
|
||||
- Error and warning reporting
|
||||
- Chunked processing (200 rows per chunk)
|
||||
|
||||
**PR #394:** *Adds config for import limits* (closes #336)
|
||||
- Configurable maximum file size (default: 10 MB)
|
||||
- Configurable maximum rows (default: 1000)
|
||||
- Configuration via `config :mv, csv_import: [max_file_size_mb: ..., max_rows: ...]`
|
||||
- UI displays limits to users
|
||||
|
||||
**PR #395:** *Implements custom field CSV import* (closes #338)
|
||||
- Support for importing custom field values via CSV
|
||||
- Custom field mapping by slug or name
|
||||
- Validation of custom field value types
|
||||
- Error reporting with line numbers and field names
|
||||
- CSV templates (German and English) available for download
|
||||
|
||||
**Key Features:**
|
||||
- Member field import (email, first_name, last_name, etc.)
|
||||
- Custom field value import (all types: string, integer, boolean, date, email)
|
||||
- Error capping (max 50 errors per import to prevent memory issues)
|
||||
- Async chunk processing with progress updates
|
||||
- Admin-only access (requires `:create` permission on Member resource)
|
||||
|
||||
### Page Permission Router Plug (2026-01-27)
|
||||
|
||||
**PR #390:** *Page Permission Router Plug* (closes #388)
|
||||
- `MvWeb.Plugs.CheckPagePermission` plug for page-level authorization
|
||||
- Route-based permission checking
|
||||
- Automatic redirects for unauthorized access
|
||||
- Integration with permission sets (own_data, read_only, normal_user, admin)
|
||||
- Documentation: `docs/page-permission-route-coverage.md`
|
||||
|
||||
**Key Features:**
|
||||
- Page-level access control before LiveView mount
|
||||
- Permission set-based route matrix
|
||||
- Redirect targets for different permission levels
|
||||
- Public paths (login, OIDC callbacks) excluded from checks
|
||||
|
||||
### Resource Policies Implementation (2026-01-27)
|
||||
|
||||
**PR #387:** *CustomField Resource Policies* (closes #386)
|
||||
- CustomField resource policies with actor-based authorization
|
||||
- Admin-only create/update/destroy operations
|
||||
- Read access for authenticated users
|
||||
- No system-actor fallback (explicit actor required)
|
||||
|
||||
**PR #377:** *CustomFieldValue Resource Policies* (closes #369)
|
||||
- CustomFieldValue resource policies
|
||||
- own_data permission set: can create/update own linked member's custom field values
|
||||
- Admin and normal_user: full access
|
||||
- Bypass read rule for CustomFieldValue pattern (documented)
|
||||
|
||||
**PR #364:** *User Resource Policies* (closes #363)
|
||||
- User resource policies with scope filtering
|
||||
- own_data: can read/update own user record
|
||||
- Admin: full access
|
||||
- Email change validation for linked members
|
||||
|
||||
### System Actor Improvements (2026-01-27)
|
||||
|
||||
**PR #379:** *Fix System missing system actor in prod and prevent deletion*
|
||||
- System actor user creation in migrations
|
||||
- Block update/destroy on system-actor user
|
||||
- System user handling in UserLive forms
|
||||
- Normalize system actor email
|
||||
|
||||
**PR #361:** *System Actor Mode for Systemic Flows* (closes #348)
|
||||
- System actor pattern for systemic operations
|
||||
- Email synchronization uses system actor
|
||||
- Cycle generation uses system actor
|
||||
- Documentation: `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns)
|
||||
|
||||
**PR #367:** *Remove NoActor bypass*
|
||||
- Removed NoActor bypass to prevent masking authorization bugs
|
||||
- All tests now require explicit actor
|
||||
- Exception: AshAuthentication bypass tests (conscious exception)
|
||||
|
||||
### Email Sync Fixes (2026-01-27)
|
||||
|
||||
**PR #380:** *Fix email sync (user->member) when changing password and email*
|
||||
- Email sync when admin sets password via `admin_set_password`
|
||||
- Bidirectional email synchronization improvements
|
||||
- Validation fixes for linked user-member pairs
|
||||
|
||||
### UI/UX Improvements (2026-01-27)
|
||||
|
||||
**PR #389:** *Change Logo* (closes #385)
|
||||
- Updated application logo
|
||||
- Logo display in sidebar and navigation
|
||||
|
||||
**PR #362:** *Add boolean custom field filters to member overview* (closes #309)
|
||||
- Boolean custom field filtering in member list
|
||||
- Filter by true/false values
|
||||
- Integration with existing filter system
|
||||
|
||||
### Test Performance Optimization (2026-01-27)
|
||||
|
||||
**PR #384:** *Minor test refactoring to improve on performance* (closes #383)
|
||||
- Moved slow tests to nightly test suite
|
||||
- Optimized policy tests
|
||||
- Reduced test complexity in seeds tests
|
||||
- Documentation: `docs/test-performance-optimization.md`
|
||||
|
||||
**Key Changes:**
|
||||
- Fast tests (standard CI): Business logic, validations, data persistence
|
||||
- Slow tests (nightly): Performance tests, large datasets, query optimization
|
||||
- UI tests: Basic HTML rendering, navigation, translations
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.5
|
||||
**Last Updated:** 2026-01-27
|
||||
**Maintainer:** Development Team
|
||||
**Status:** Living Document (update as project evolves)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2026-01-13
|
||||
**Last Updated:** 2026-01-27
|
||||
**Status:** Active Development
|
||||
|
||||
---
|
||||
|
|
@ -29,6 +29,10 @@
|
|||
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
|
||||
- ✅ **Secure OIDC email collision handling** (PR #192)
|
||||
- ✅ **Automatic linking for passwordless users** (PR #192)
|
||||
- ✅ **Page Permission Router Plug** - Page-level authorization (PR #390, closes #388, 2026-01-27)
|
||||
- Route-based permission checking
|
||||
- Automatic redirects for unauthorized access
|
||||
- Integration with permission sets
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||
|
|
@ -55,6 +59,10 @@
|
|||
- ✅ [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M) - Completed
|
||||
- ✅ [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M) - Completed
|
||||
- ✅ [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) - Completed
|
||||
- ✅ [#388](https://git.local-it.org/local-it/mitgliederverwaltung/issues/388) - Page Permission Router Plug (closed 2026-01-27)
|
||||
- ✅ [#386](https://git.local-it.org/local-it/mitgliederverwaltung/issues/386) - CustomField Resource Policies (closed 2026-01-27)
|
||||
- ✅ [#369](https://git.local-it.org/local-it/mitgliederverwaltung/issues/369) - CustomFieldValue Resource Policies (closed 2026-01-27)
|
||||
- ✅ [#363](https://git.local-it.org/local-it/mitgliederverwaltung/issues/363) - User Resource Policies (closed 2026-01-27)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -73,9 +81,24 @@
|
|||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
- ✅ **Groups** - Organize members into groups (PR #378, #382, closes #371, #372, 2026-01-27)
|
||||
- Many-to-many relationship with groups
|
||||
- Groups management UI (`/groups`)
|
||||
- Filter and sort by groups in member list
|
||||
- Groups displayed in member overview and detail views
|
||||
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
|
||||
- Member field import
|
||||
- Custom field value import
|
||||
- Real-time progress tracking
|
||||
- Error reporting
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
- ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27)
|
||||
- ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27)
|
||||
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
|
||||
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
|
||||
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
|
||||
|
||||
**Open Issues:**
|
||||
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
|
||||
|
|
@ -88,7 +111,7 @@
|
|||
- ❌ Advanced filters (date ranges, multiple criteria)
|
||||
- ❌ Pagination (currently all members loaded)
|
||||
- ❌ Bulk operations (bulk delete, bulk update)
|
||||
- ❌ Member import/export (CSV, Excel)
|
||||
- ❌ Excel import for members
|
||||
- ❌ Member profile photos/avatars
|
||||
- ❌ Member history/audit log
|
||||
- ❌ Duplicate detection
|
||||
|
|
@ -288,12 +311,24 @@
|
|||
- ✅ **CSV Import Templates** - German and English templates (#329, 2026-01-13)
|
||||
- Template files in `priv/static/templates/member_import_de.csv` and `member_import_en.csv`
|
||||
- CSV specification documented in `docs/csv-member-import-v1.md`
|
||||
- ✅ **CSV Import Implementation** - Full CSV import feature (#335, #336, #338, 2026-01-27)
|
||||
- Import/Export LiveView (`/import_export`)
|
||||
- Member field import (email, first_name, last_name, etc.)
|
||||
- Custom field value import (all types: string, integer, boolean, date, email)
|
||||
- Real-time progress tracking
|
||||
- Error and warning reporting with line numbers
|
||||
- Configurable limits (max file size, max rows)
|
||||
- Chunked processing (200 rows per chunk)
|
||||
- Admin-only access
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
|
||||
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
|
||||
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import implementation (templates ready, import logic pending)
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
- ❌ Import validation preview (before import)
|
||||
- ❌ Bulk data export
|
||||
- ❌ Backup export
|
||||
- ❌ Data migration tools
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
**Feature:** Groups Management
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2025-01-XX
|
||||
**Status:** Architecture Design - Ready for Implementation
|
||||
**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md))
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -412,15 +412,17 @@ lib/
|
|||
|
||||
## Authorization
|
||||
|
||||
**Status:** ✅ Implemented. Group and MemberGroup resource policies and PermissionSets are in place. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
|
||||
|
||||
### Permission Model (MVP)
|
||||
|
||||
**Resource:** `groups`
|
||||
**Resource:** `Group` (and `MemberGroup`)
|
||||
|
||||
**Actions:**
|
||||
- `read` - View groups (all users with member read permission)
|
||||
- `create` - Create groups (admin only)
|
||||
- `update` - Edit groups (admin only)
|
||||
- `destroy` - Delete groups (admin only)
|
||||
- `read` - View groups (all permission sets)
|
||||
- `create` - Create groups (normal_user and admin)
|
||||
- `update` - Edit groups (normal_user and admin)
|
||||
- `destroy` - Delete groups (normal_user and admin)
|
||||
|
||||
**Scopes:**
|
||||
- `:all` - All groups (for all permission sets that have read access)
|
||||
|
|
@ -442,7 +444,7 @@ lib/
|
|||
**Own Data Permission Set:**
|
||||
- `read` action on `Group` resource with `:all` scope - granted
|
||||
|
||||
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
|
||||
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. normal_user and admin can manage (create/update/destroy) groups.
|
||||
|
||||
### Member-Group Association Permissions
|
||||
|
||||
|
|
|
|||
|
|
@ -334,20 +334,18 @@ lib/
|
|||
|
||||
### Permission System Integration
|
||||
|
||||
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||
**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
|
||||
|
||||
**Required Permissions:**
|
||||
**PermissionSets (lib/mv/authorization/permission_sets.ex):**
|
||||
|
||||
- `MembershipFeeType.create/update/destroy` - Admin only
|
||||
- `MembershipFeeType.read` - Admin, Treasurer, Board
|
||||
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
|
||||
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
|
||||
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
|
||||
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
|
||||
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor.
|
||||
|
||||
**Policy Patterns:**
|
||||
**Resource Policies:**
|
||||
|
||||
- Use existing HasPermission check
|
||||
- Leverage existing roles (Admin, Kassenwart)
|
||||
- Member can read own cycles (linked via member_id)
|
||||
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
|
||||
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
|
||||
|
||||
### LiveView Integration
|
||||
|
||||
|
|
@ -357,7 +355,7 @@ lib/
|
|||
2. MembershipFeeCycle table component (member detail view)
|
||||
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
|
||||
- Displays all cycles in a table with status management
|
||||
- Allows changing cycle status, editing amounts, and regenerating cycles
|
||||
- Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
|
||||
3. Settings form section (admin)
|
||||
4. Member list column (membership fee status)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ Control CRUD operations on:
|
|||
- CustomFieldValue (custom field values)
|
||||
- CustomField (custom field definitions)
|
||||
- Role (role management)
|
||||
- Group (group definitions; read all, create/update/destroy normal_user and admin)
|
||||
- MemberGroup (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**
|
||||
|
||||
|
|
@ -105,6 +109,7 @@ Control access to LiveView pages:
|
|||
- Show pages (detail views)
|
||||
- Form pages (create/edit)
|
||||
- Admin pages
|
||||
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
|
||||
|
||||
**5. Granular Scopes**
|
||||
|
||||
|
|
@ -121,6 +126,8 @@ Three scope levels for permissions:
|
|||
- **Linked Member Email:** Only admins can edit email of member linked to user
|
||||
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
|
||||
- **User-Member Linking:** Only admins can link/unlink users and members
|
||||
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
|
||||
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
|
||||
|
||||
**7. UI Consistency**
|
||||
|
||||
|
|
@ -684,6 +691,12 @@ Quick reference table showing what each permission set allows:
|
|||
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||
| **CustomField** (all) | R | R | R | R, C, U, D |
|
||||
| **Role** (all) | - | - | - | R, C, U, D |
|
||||
| **Group** (all) | R | R | R, C, U, D | R, C, U, D |
|
||||
| **MemberGroup** (linked) | R | - | - | - |
|
||||
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
|
||||
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
|
||||
| **MembershipFeeCycle** (linked) | R | - | - | - |
|
||||
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||
|
||||
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
||||
|
||||
|
|
@ -1012,16 +1025,21 @@ defmodule Mv.Membership.Member do
|
|||
authorize_if expr(id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# 2. GENERAL: Check permissions from role
|
||||
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
|
||||
# - :read_only → can READ all members (scope :all), no update permission
|
||||
# - :normal_user → can CRUD all members (scope :all)
|
||||
# - :admin → can CRUD all members (scope :all)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
# 2. READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||||
policy action_type([:read, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions
|
||||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all
|
||||
policy action_type([:create, :update]) do
|
||||
description "Forbid user link unless admin; then check permissions"
|
||||
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||
end
|
||||
|
||||
|
|
@ -1041,6 +1059,8 @@ end
|
|||
- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
|
||||
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
|
||||
|
||||
**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:**
|
||||
|
||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||
|
|
@ -1135,23 +1155,20 @@ end
|
|||
|
||||
**Location:** `lib/mv/authorization/role.ex`
|
||||
|
||||
**Special Protection:** System roles cannot be deleted.
|
||||
**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary.
|
||||
|
||||
**Special Protection:** System roles cannot be deleted (validation on destroy).
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Authorization.Role do
|
||||
use Ash.Resource, ...
|
||||
use Ash.Resource,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
policies do
|
||||
# Only admin can manage roles
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
description "Check permissions from user's role (read all, create/update/destroy admin only)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# DEFAULT: Forbid
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
forbid_if always()
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent deletion of system roles
|
||||
|
|
@ -1188,13 +1205,43 @@ end
|
|||
|
||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||
|--------|----------|----------|------------|-------------|-------|
|
||||
| Read | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
*Cannot destroy if `is_system_role=true`
|
||||
|
||||
### User Role Assignment (Admin-Only)
|
||||
|
||||
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
|
||||
|
||||
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
|
||||
|
||||
### Group Resource Policies
|
||||
|
||||
**Location:** `lib/membership/group.ex`
|
||||
|
||||
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
|
||||
|
||||
### MemberGroup Resource Policies
|
||||
|
||||
**Location:** `lib/membership/member_group.ex`
|
||||
|
||||
Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).
|
||||
|
||||
### MembershipFeeType Resource Policies
|
||||
|
||||
**Location:** `lib/membership_fees/membership_fee_type.ex`
|
||||
|
||||
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
|
||||
|
||||
### MembershipFeeCycle Resource Policies
|
||||
|
||||
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
|
||||
|
||||
Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.
|
||||
|
||||
---
|
||||
|
||||
## Page Permission System
|
||||
|
|
@ -2002,7 +2049,10 @@ Users and Members are separate entities that can be linked. Special rules:
|
|||
- A user cannot link themselves to an existing member
|
||||
- A user CAN create a new member and be directly linked to it (self-service)
|
||||
|
||||
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
||||
**Enforcement:**
|
||||
|
||||
- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
||||
- **Member side:** Only admins may set or change the 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
|
||||
|
||||
|
|
|
|||
|
|
@ -78,10 +78,11 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
|
|||
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
||||
- ✅ Role database table and CRUD interface
|
||||
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
||||
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
|
||||
- ✅ Page-level permissions via Phoenix Plug
|
||||
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
|
||||
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
|
||||
- ✅ UI authorization helpers for conditional rendering
|
||||
- ✅ Special case: Member email validation for linked users
|
||||
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
|
||||
- ✅ Seed data for 5 roles
|
||||
|
||||
**Benefits of Hardcoded Approach:**
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ defmodule Mv.Accounts.User do
|
|||
extensions: [AshAuthentication],
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
repo Mv.Repo
|
||||
|
|
@ -146,9 +149,10 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
update :update_user do
|
||||
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
||||
# Only accept email directly - member_id is NOT in accept list
|
||||
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
||||
accept [:email]
|
||||
|
||||
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
|
||||
# member_id is NOT in accept list - use argument :member for relationship management.
|
||||
accept [:email, :role_id]
|
||||
# Allow member to be passed as argument for relationship management
|
||||
argument :member, :map, allow_nil?: true
|
||||
|
||||
|
|
@ -183,6 +187,13 @@ defmodule Mv.Accounts.User do
|
|||
require_atomic? false
|
||||
end
|
||||
|
||||
# Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync.
|
||||
# Same "at least one admin" validation as update_user (see validations where action_is).
|
||||
update :set_role_from_oidc_sync do
|
||||
accept [:role_id]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
# Uses the official Ash Authentication HashPasswordChange with correct context
|
||||
update :admin_set_password do
|
||||
|
|
@ -247,6 +258,8 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
read :sign_in_with_rauthy do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
|
@ -256,6 +269,27 @@ defmodule Mv.Accounts.User do
|
|||
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||
# cannot be accessed via OIDC login without password verification.
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
|
||||
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
|
||||
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
|
||||
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
|
||||
user_info = Ash.Query.get_argument(query, :user_info) || %{}
|
||||
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
|
||||
|
||||
users =
|
||||
case result do
|
||||
nil -> []
|
||||
u when is_struct(u, User) -> [u]
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
end
|
||||
|
||||
create :register_with_rauthy do
|
||||
|
|
@ -293,6 +327,18 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{}
|
||||
|
||||
Ash.Changeset.after_action(changeset, fn _cs, record ->
|
||||
Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens)
|
||||
# Return original record so __metadata__.token (from GenerateTokenChange) is preserved
|
||||
{:ok, record}
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -319,6 +365,13 @@ defmodule Mv.Accounts.User do
|
|||
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||
end
|
||||
|
||||
# set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
|
||||
# Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set.
|
||||
bypass action(:set_role_from_oidc_sync) do
|
||||
description "Internal: OIDC role sync (server-side only)"
|
||||
authorize_if Mv.Authorization.Checks.OidcRoleSyncContext
|
||||
end
|
||||
|
||||
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
|
|
@ -387,6 +440,63 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
end
|
||||
|
||||
# Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
|
||||
# Only block when the user is leaving admin (target role is not admin). Switching between
|
||||
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
|
||||
validate fn changeset, _context ->
|
||||
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
|
||||
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
|
||||
|
||||
if is_nil(new_role_id) do
|
||||
:ok
|
||||
else
|
||||
current_role_id = changeset.data.role_id
|
||||
|
||||
current_role =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.get!(current_role_id, authorize?: false)
|
||||
|
||||
new_role =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.get!(new_role_id, authorize?: false)
|
||||
|
||||
# Only block when current user is admin and target role is not admin (leaving admin)
|
||||
if current_role.permission_set_name == "admin" and
|
||||
new_role.permission_set_name != "admin" do
|
||||
admin_role_ids =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|
||||
|> Ash.read!(authorize?: false)
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
# Count only non-system users with admin role (system user is for internal ops)
|
||||
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||
|
||||
count =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||
|> Ash.Query.filter(expr(email != ^system_email))
|
||||
|> Ash.count!(authorize?: false)
|
||||
|
||||
if count <= 1 do
|
||||
{:error,
|
||||
field: :role_id, message: "At least one user must keep the Admin role."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:update],
|
||||
where: [action_is([:update_user, :set_role_from_oidc_sync])]
|
||||
|
||||
# Prevent modification of the system actor user (required for internal operations).
|
||||
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||
validate fn changeset, _context ->
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ defmodule Mv.Membership.Group do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
alias Mv.Helpers
|
||||
|
|
@ -63,6 +64,13 @@ defmodule Mv.Membership.Group do
|
|||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:name)
|
||||
|
||||
|
|
@ -136,7 +144,7 @@ defmodule Mv.Membership.Group do
|
|||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
||||
|> maybe_exclude_id(exclude_id)
|
||||
|> Helpers.query_exclude_id(exclude_id)
|
||||
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
|
|
@ -155,7 +163,4 @@ defmodule Mv.Membership.Group do
|
|||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -153,16 +153,18 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
# When :user argument is present and nil/empty, unrelate (admin-only via policy).
|
||||
# Must run before manage_relationship; on_missing: :ignore then does nothing for nil input.
|
||||
change Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil
|
||||
|
||||
# Manage the user relationship during member update
|
||||
# on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may
|
||||
# change the link; unlink is explicit via user: nil, forbidden for non-admins by policy).
|
||||
change manage_relationship(:user, :user,
|
||||
# Look up existing user and relate to it
|
||||
on_lookup: :relate,
|
||||
# Error if user doesn't exist in database
|
||||
on_no_match: :error,
|
||||
# Error if user is already linked to another member (prevents "stealing")
|
||||
on_match: :error,
|
||||
# If no user provided, remove existing relationship (allows user removal)
|
||||
on_missing: :unrelate
|
||||
on_missing: :ignore
|
||||
)
|
||||
|
||||
# Sync member email to user when email changes (Member → User)
|
||||
|
|
@ -312,14 +314,18 @@ defmodule Mv.Membership.Member do
|
|||
authorize_if expr(id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# GENERAL: Check permissions from user's role
|
||||
# HasPermission handles update permissions correctly:
|
||||
# - :own_data → can update linked member (scope :linked)
|
||||
# - :read_only → cannot update any member (no update permission)
|
||||
# - :normal_user → can update all members (scope :all)
|
||||
# - :admin → can update all members (scope :all)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
# READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||||
policy action_type([:read, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# CREATE/UPDATE: Forbid member–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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -56,6 +57,26 @@ defmodule Mv.Membership.MemberGroup do
|
|||
end
|
||||
end
|
||||
|
||||
# Authorization: read uses bypass for :linked (own_data only) then HasPermission for :all;
|
||||
# create/destroy use HasPermission (normal_user + admin only).
|
||||
# Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission.
|
||||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only member_groups where member_id == actor.member_id"
|
||||
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
description "Check read permission from role (read_only/normal_user/admin :all)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action_type([:create, :destroy]) do
|
||||
description "Check create/destroy from role (normal_user + admin only)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:member_id)
|
||||
validate present(:group_id)
|
||||
|
|
@ -118,7 +139,7 @@ defmodule Mv.Membership.MemberGroup do
|
|||
query =
|
||||
Mv.Membership.MemberGroup
|
||||
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|
||||
|> maybe_exclude_id(exclude_id)
|
||||
|> Helpers.query_exclude_id(exclude_id)
|
||||
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
|
|
@ -135,7 +156,4 @@ defmodule Mv.Membership.MemberGroup do
|
|||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -155,12 +155,15 @@ defmodule Mv.Membership.Setting do
|
|||
on: [:create, :update]
|
||||
|
||||
# Validate default_membership_fee_type_id exists if set
|
||||
validate fn changeset, _context ->
|
||||
validate fn changeset, context ->
|
||||
fee_type_id =
|
||||
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
||||
|
||||
if fee_type_id do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
|
||||
# Check existence only; action is already restricted by policy (e.g. admin).
|
||||
opts = [domain: Mv.MembershipFees, authorize?: false]
|
||||
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
def change(changeset, _opts, context) do
|
||||
# Only calculate if membership_fee_start_date is not already set
|
||||
if has_start_date?(changeset) do
|
||||
changeset
|
||||
else
|
||||
calculate_and_set_start_date(changeset)
|
||||
calculate_and_set_start_date(changeset, context)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -56,10 +56,13 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
end
|
||||
end
|
||||
|
||||
defp calculate_and_set_start_date(changeset) do
|
||||
defp calculate_and_set_start_date(changeset, context) do
|
||||
actor = Map.get(context || %{}, :actor)
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
with {:ok, join_date} <- get_join_date(changeset),
|
||||
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
||||
{:ok, interval} <- get_interval(membership_fee_type_id),
|
||||
{:ok, interval} <- get_interval(membership_fee_type_id, opts),
|
||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
||||
|
|
@ -118,8 +121,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_interval(membership_fee_type_id) do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
|
||||
defp get_interval(membership_fee_type_id, opts) do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
|
||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||
{:error, _} -> {:error, :membership_fee_type_not_found}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
|||
use Ash.Resource.Change
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
def change(changeset, _opts, context) do
|
||||
if changing_membership_fee_type?(changeset) do
|
||||
validate_interval_match(changeset)
|
||||
validate_interval_match(changeset, context)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
|
@ -33,9 +33,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
|||
end
|
||||
|
||||
# Validate that the new type has the same interval as the current type
|
||||
defp validate_interval_match(changeset) do
|
||||
defp validate_interval_match(changeset, context) do
|
||||
current_type_id = get_current_type_id(changeset)
|
||||
new_type_id = get_new_type_id(changeset)
|
||||
actor = Map.get(context || %{}, :actor)
|
||||
|
||||
cond do
|
||||
# If no current type, allow any change (first assignment)
|
||||
|
|
@ -48,13 +49,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
|||
|
||||
# Both types exist - validate intervals match
|
||||
true ->
|
||||
validate_intervals_match(changeset, current_type_id, new_type_id)
|
||||
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that intervals match when both types exist
|
||||
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
|
||||
case get_intervals(current_type_id, new_type_id) do
|
||||
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
|
||||
case get_intervals(current_type_id, new_type_id, actor) do
|
||||
{:ok, current_interval, new_interval} ->
|
||||
if current_interval == new_interval do
|
||||
changeset
|
||||
|
|
@ -85,11 +86,16 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
|||
end
|
||||
end
|
||||
|
||||
# Get intervals for both types
|
||||
defp get_intervals(current_type_id, new_type_id) do
|
||||
# Get intervals for both types (actor required for authorization when resource has policies)
|
||||
defp get_intervals(current_type_id, new_type_id, actor) do
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
case {
|
||||
Ash.get(MembershipFeeType, current_type_id, opts),
|
||||
Ash.get(MembershipFeeType, new_type_id, opts)
|
||||
} do
|
||||
{{:ok, current_type}, {:ok, new_type}} ->
|
||||
{:ok, current_type.interval, new_type.interval}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "membership_fee_cycles"
|
||||
|
|
@ -83,6 +84,19 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
|||
end
|
||||
end
|
||||
|
||||
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
|
||||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only cycles where member_id == actor.member_id"
|
||||
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
||||
end
|
||||
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "membership_fee_types"
|
||||
|
|
@ -61,6 +62,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
|||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from role (all can read, only admin can create/update/destroy)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
# Prevent interval changes after creation
|
||||
validate fn changeset, _context ->
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> maybe_exclude_id(exclude_member_id)
|
||||
|> Mv.Helpers.query_exclude_id(exclude_member_id)
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
|
@ -101,7 +101,4 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
|
|
|
|||
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:
|
||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id → Member.id → User.member_id)
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -131,26 +132,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
resource_name
|
||||
) do
|
||||
:authorized ->
|
||||
# For :all scope, authorize directly
|
||||
{:ok, true}
|
||||
|
||||
{:filter, filter_expr} ->
|
||||
# For :own/:linked scope:
|
||||
# - With a record, evaluate filter against record for strict authorization
|
||||
# - Without a record (queries/lists), return false
|
||||
#
|
||||
# NOTE: Returning false here forces the use of expr-based bypass policies.
|
||||
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
|
||||
# when strict_check returns :unknown. Instead, resources should use bypass policies
|
||||
# with expr() directly for filter-based authorization (see User resource).
|
||||
if record do
|
||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||
else
|
||||
# No record yet (e.g., read/list queries) - deny at strict_check level
|
||||
# Resources must use expr-based bypass policies for list filtering
|
||||
# Create: use a dedicated check that does not return a filter (e.g. CustomFieldValueCreateScope)
|
||||
{:ok, false}
|
||||
end
|
||||
strict_check_filter_scope(record, filter_expr, actor, resource_name)
|
||||
|
||||
false ->
|
||||
{:ok, false}
|
||||
|
|
@ -174,6 +159,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
end
|
||||
end
|
||||
|
||||
# For :own/:linked scope: with record evaluate filter; without record deny (resources use bypass + expr).
|
||||
defp strict_check_filter_scope(record, filter_expr, actor, resource_name) do
|
||||
if record do
|
||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, authorizer, _opts) do
|
||||
resource = authorizer.resource
|
||||
|
|
@ -278,36 +272,28 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
# For :own scope with User resource: id == actor.id
|
||||
# For :linked scope with Member resource: id == actor.member_id
|
||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||
case {resource_name, record} do
|
||||
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||
# Check if this user's ID matches the actor's ID (scope :own)
|
||||
if user_id == actor.id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
result =
|
||||
case {resource_name, record} do
|
||||
# Scope :own
|
||||
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||
user_id == actor.id
|
||||
|
||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||
# Check if this member's ID matches the actor's member_id
|
||||
if member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
# Scope :linked
|
||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||
member_id == actor.member_id
|
||||
|
||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||
# Check if this CFV's member_id matches the actor's member_id
|
||||
if cfv_member_id == actor.member_id do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, false}
|
||||
end
|
||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||
cfv_member_id == actor.member_id
|
||||
|
||||
_ ->
|
||||
# For other cases or when record is not available, return :unknown
|
||||
# This will cause Ash to use auto_filter instead
|
||||
{:ok, :unknown}
|
||||
end
|
||||
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) ->
|
||||
mg_member_id == actor.member_id
|
||||
|
||||
_ ->
|
||||
:unknown
|
||||
end
|
||||
|
||||
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
|
||||
out
|
||||
end
|
||||
|
||||
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
||||
|
|
@ -347,24 +333,20 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
defp apply_scope(:linked, actor, resource_name) do
|
||||
case resource_name do
|
||||
"Member" ->
|
||||
# User.member_id → Member.id (inverse relationship)
|
||||
# Filter: member.id == actor.member_id
|
||||
# If actor has no member_id, return no results (use false or impossible condition)
|
||||
if is_nil(actor.member_id) do
|
||||
{:filter, expr(false)}
|
||||
else
|
||||
{:filter, expr(id == ^actor.member_id)}
|
||||
end
|
||||
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
|
||||
linked_filter_by_member_id(actor, :id)
|
||||
|
||||
"CustomFieldValue" ->
|
||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||
# Filter: custom_field_value.member_id == actor.member_id
|
||||
# If actor has no member_id, return no results
|
||||
if is_nil(actor.member_id) do
|
||||
{:filter, expr(false)}
|
||||
else
|
||||
{:filter, expr(member_id == ^actor.member_id)}
|
||||
end
|
||||
linked_filter_by_member_id(actor, :member_id)
|
||||
|
||||
"MemberGroup" ->
|
||||
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
|
||||
linked_filter_by_member_id(actor, :member_id)
|
||||
|
||||
"MembershipFeeCycle" ->
|
||||
# MembershipFeeCycle.member_id → Member.id → User.member_id (own linked member's cycles)
|
||||
linked_filter_by_member_id(actor, :member_id)
|
||||
|
||||
_ ->
|
||||
# Fallback for other resources
|
||||
|
|
@ -372,6 +354,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
end
|
||||
end
|
||||
|
||||
# Returns {:filter, expr(false)} if actor has no member_id; otherwise {:filter, expr(field == ^actor.member_id)}.
|
||||
# Used for :linked scope on Member (field :id), CustomFieldValue and MemberGroup (field :member_id).
|
||||
defp linked_filter_by_member_id(actor, _field) when is_nil(actor.member_id) do
|
||||
{:filter, expr(false)}
|
||||
end
|
||||
|
||||
defp linked_filter_by_member_id(actor, :id), do: {:filter, expr(id == ^actor.member_id)}
|
||||
|
||||
defp linked_filter_by_member_id(actor, :member_id),
|
||||
do: {:filter, expr(member_id == ^actor.member_id)}
|
||||
|
||||
# Log authorization failures for debugging (lazy evaluation)
|
||||
defp log_auth_failure(actor, resource, action, reason) do
|
||||
Logger.debug(fn ->
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
}
|
||||
|
||||
# DRY helpers for shared resource permission lists (used in own_data, read_only, normal_user, admin)
|
||||
defp perm(resource, action, scope),
|
||||
do: %{resource: resource, action: action, scope: scope, granted: true}
|
||||
|
||||
# All four CRUD actions for a resource with scope :all (used for admin)
|
||||
defp perm_all(resource),
|
||||
do: [
|
||||
perm(resource, :read, :all),
|
||||
perm(resource, :create, :all),
|
||||
perm(resource, :update, :all),
|
||||
perm(resource, :destroy, :all)
|
||||
]
|
||||
|
||||
# User: read/update own credentials only (all non-admin sets allow password changes)
|
||||
defp user_own_credentials, do: [perm("User", :read, :own), perm("User", :update, :own)]
|
||||
|
||||
defp group_read_all, do: [perm("Group", :read, :all)]
|
||||
defp custom_field_read_all, do: [perm("CustomField", :read, :all)]
|
||||
defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)]
|
||||
defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)]
|
||||
defp role_read_all, do: [perm("Role", :read, :all)]
|
||||
|
||||
@doc """
|
||||
Returns the list of all valid permission set names.
|
||||
|
||||
|
|
@ -94,29 +116,22 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
|
||||
def get_permissions(:own_data) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||
# All permission sets grant User.update :own to allow password changes.
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read/update linked member
|
||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
||||
|
||||
# CustomField: Can read all (needed for forms)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
|
||||
# Group: Can read all (needed for viewing groups)
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
resources:
|
||||
user_own_credentials() ++
|
||||
[
|
||||
perm("Member", :read, :linked),
|
||||
perm("Member", :update, :linked),
|
||||
perm("CustomFieldValue", :read, :linked),
|
||||
perm("CustomFieldValue", :update, :linked),
|
||||
perm("CustomFieldValue", :create, :linked),
|
||||
perm("CustomFieldValue", :destroy, :linked)
|
||||
] ++
|
||||
custom_field_read_all() ++
|
||||
group_read_all() ++
|
||||
[perm("MemberGroup", :read, :linked)] ++
|
||||
membership_fee_type_read_all() ++
|
||||
[perm("MembershipFeeCycle", :read, :linked)] ++
|
||||
role_read_all(),
|
||||
pages: [
|
||||
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||
# Own profile (sidebar links to /users/:id) and own user edit
|
||||
|
|
@ -133,25 +148,18 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
|
||||
def get_permissions(:read_only) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||
# All permission sets grant User.update :own to allow password changes.
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read all members, no modifications
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read all custom field values
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Can read all
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
|
||||
# Group: Can read all
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
resources:
|
||||
user_own_credentials() ++
|
||||
[
|
||||
perm("Member", :read, :all),
|
||||
perm("CustomFieldValue", :read, :all)
|
||||
] ++
|
||||
custom_field_read_all() ++
|
||||
group_read_all() ++
|
||||
[perm("MemberGroup", :read, :all)] ++
|
||||
membership_fee_type_read_all() ++
|
||||
membership_fee_cycle_read_all() ++
|
||||
role_read_all(),
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||
|
|
@ -176,31 +184,38 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
|
||||
def get_permissions(:normal_user) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||
# All permission sets grant User.update :own to allow password changes.
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Full CRUD except destroy (safety)
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
# Note: destroy intentionally omitted for safety
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Read only (admin manages definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
|
||||
# Group: Can read all
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
resources:
|
||||
user_own_credentials() ++
|
||||
[
|
||||
perm("Member", :read, :all),
|
||||
perm("Member", :create, :all),
|
||||
perm("Member", :update, :all),
|
||||
# destroy intentionally omitted for safety
|
||||
perm("CustomFieldValue", :read, :all),
|
||||
perm("CustomFieldValue", :create, :all),
|
||||
perm("CustomFieldValue", :update, :all),
|
||||
perm("CustomFieldValue", :destroy, :all)
|
||||
] ++
|
||||
custom_field_read_all() ++
|
||||
[
|
||||
perm("Group", :read, :all),
|
||||
perm("Group", :create, :all),
|
||||
perm("Group", :update, :all),
|
||||
perm("Group", :destroy, :all)
|
||||
] ++
|
||||
[
|
||||
perm("MemberGroup", :read, :all),
|
||||
perm("MemberGroup", :create, :all),
|
||||
perm("MemberGroup", :destroy, :all)
|
||||
] ++
|
||||
membership_fee_type_read_all() ++
|
||||
[
|
||||
perm("MembershipFeeCycle", :read, :all),
|
||||
perm("MembershipFeeCycle", :create, :all),
|
||||
perm("MembershipFeeCycle", :update, :all),
|
||||
perm("MembershipFeeCycle", :destroy, :all)
|
||||
] ++
|
||||
role_read_all(),
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||
|
|
@ -221,52 +236,39 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
"/custom_field_values/:id/edit",
|
||||
# Groups overview
|
||||
"/groups",
|
||||
# Create group
|
||||
"/groups/new",
|
||||
# Group detail
|
||||
"/groups/:slug"
|
||||
"/groups/:slug",
|
||||
# Edit group
|
||||
"/groups/:slug/edit"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:admin) do
|
||||
# MemberGroup has no :update action in the domain; use read/create/destroy only
|
||||
member_group_perms = [
|
||||
perm("MemberGroup", :read, :all),
|
||||
perm("MemberGroup", :create, :all),
|
||||
perm("MemberGroup", :destroy, :all)
|
||||
]
|
||||
|
||||
%{
|
||||
resources: [
|
||||
# User: Full management including other users
|
||||
%{resource: "User", action: :read, scope: :all, granted: true},
|
||||
%{resource: "User", action: :create, scope: :all, granted: true},
|
||||
%{resource: "User", action: :update, scope: :all, granted: true},
|
||||
%{resource: "User", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Member: Full CRUD
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Full CRUD (admin manages custom field definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Role: Full CRUD (admin manages roles)
|
||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Group: Full CRUD (admin manages groups)
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :destroy, scope: :all, granted: true}
|
||||
],
|
||||
resources:
|
||||
perm_all("User") ++
|
||||
perm_all("Member") ++
|
||||
perm_all("CustomFieldValue") ++
|
||||
perm_all("CustomField") ++
|
||||
perm_all("Role") ++
|
||||
perm_all("Group") ++
|
||||
member_group_perms ++
|
||||
perm_all("MembershipFeeType") ++
|
||||
perm_all("MembershipFeeCycle"),
|
||||
pages: [
|
||||
# Explicit admin-only pages (for clarity and future restrictions)
|
||||
"/settings",
|
||||
"/membership_fee_settings",
|
||||
# Wildcard: Admin can access all pages
|
||||
"*"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ defmodule Mv.Authorization.Role do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Authorization,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "roles"
|
||||
|
|
@ -86,6 +87,13 @@ defmodule Mv.Authorization.Role do
|
|||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Role access: read for all permission sets, create/update/destroy for admin only (PermissionSets)"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate one_of(
|
||||
:permission_set_name,
|
||||
|
|
@ -173,4 +181,18 @@ defmodule Mv.Authorization.Role do
|
|||
|> Ash.Query.filter(name == "Mitglied")
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Admin role if it exists.
|
||||
|
||||
Used by release tasks (e.g. seed_admin) and OIDC role sync to assign the admin role.
|
||||
"""
|
||||
@spec get_admin_role() :: {:ok, t() | nil} | {:error, term()}
|
||||
def get_admin_role do
|
||||
require Ash.Query
|
||||
|
||||
__MODULE__
|
||||
|> Ash.Query.filter(name == "Admin")
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
# Ash 3.12+ calls this to decide whether to run the change in certain contexts.
|
||||
@impl true
|
||||
def has_change?, do: true
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
|
|
@ -40,26 +44,29 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
defp sync_email(changeset) do
|
||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
with {:ok, record} <- Helpers.extract_record(result),
|
||||
{:ok, user, member} <- get_user_and_member(record) do
|
||||
# When called from Member-side, we need to update the member in the result
|
||||
# When called from User-side, we update the linked member in DB only
|
||||
case record do
|
||||
%Mv.Membership.Member{} ->
|
||||
# Member-side: Override member email in result with user email
|
||||
Helpers.override_with_linked_email(result, user.email)
|
||||
|
||||
%Mv.Accounts.User{} ->
|
||||
# User-side: Sync user email to linked member in DB
|
||||
Helpers.sync_email_to_linked_record(result, member, user.email)
|
||||
end
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
apply_sync(result)
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_sync(result) do
|
||||
with {:ok, record} <- Helpers.extract_record(result),
|
||||
{:ok, user, member} <- get_user_and_member(record) do
|
||||
sync_by_record_type(result, record, user, member)
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end
|
||||
|
||||
# When called from Member-side, we update the member in the result.
|
||||
# When called from User-side, we sync user email to the linked member in DB.
|
||||
defp sync_by_record_type(result, %Mv.Membership.Member{}, user, _member) do
|
||||
Helpers.override_with_linked_email(result, user.email)
|
||||
end
|
||||
|
||||
defp sync_by_record_type(result, %Mv.Accounts.User{}, user, member) do
|
||||
Helpers.sync_email_to_linked_record(result, member, user.email)
|
||||
end
|
||||
|
||||
# Retrieves user and member - works for both resource types
|
||||
# Uses system actor via Loader functions
|
||||
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule Mv.Helpers do
|
|||
Provides utilities that are not specific to a single domain or layer.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@doc """
|
||||
Converts an actor to Ash options list for authorization.
|
||||
Returns empty list if actor is nil.
|
||||
|
|
@ -24,4 +26,22 @@ defmodule Mv.Helpers do
|
|||
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||
def ash_actor_opts(nil), do: []
|
||||
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
|
||||
|
||||
@doc """
|
||||
Returns the query unchanged if `exclude_id` is nil; otherwise adds a filter `id != ^exclude_id`.
|
||||
|
||||
Used in uniqueness validations that must exclude the current record (e.g. name uniqueness
|
||||
on update, duplicate association checks). Call with the record's primary key to exclude it
|
||||
from the result set.
|
||||
|
||||
## Examples
|
||||
|
||||
query
|
||||
|> Ash.Query.filter(name == ^name)
|
||||
|> Mv.Helpers.query_exclude_id(current_id)
|
||||
|
||||
"""
|
||||
@spec query_exclude_id(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
|
||||
def query_exclude_id(query, nil), do: query
|
||||
def query_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
query =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> maybe_exclude_id(exclude_user_id)
|
||||
|> Mv.Helpers.query_exclude_id(exclude_user_id)
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
|
@ -76,7 +76,4 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
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 """
|
||||
Used for executing DB release tasks when run in production without Mix
|
||||
installed.
|
||||
|
||||
## Tasks
|
||||
|
||||
- `migrate/0` - Runs all pending Ecto migrations.
|
||||
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||
to update the admin password without redeploying.
|
||||
"""
|
||||
@app :mv
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
require Ash.Query
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
|
||||
|
|
@ -18,6 +31,158 @@ defmodule Mv.Release do
|
|||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE).
|
||||
|
||||
Starts the application if not already running (required when called via `bin/mv eval`;
|
||||
Ash/Telemetry need the running app). Idempotent.
|
||||
|
||||
- If ADMIN_EMAIL is unset: no-op (idempotent).
|
||||
- If ADMIN_PASSWORD (and ADMIN_PASSWORD_FILE) are unset and the user does not exist:
|
||||
no user is created (no fallback password in production).
|
||||
- If both ADMIN_EMAIL and ADMIN_PASSWORD are set: creates or updates the user with
|
||||
Admin role and the given password. Safe to run on every deployment or via
|
||||
`bin/mv eval "Mv.Release.seed_admin()"` to change the admin password without redeploying.
|
||||
"""
|
||||
def seed_admin do
|
||||
# Ensure app (and Telemetry/Ash deps) are started when run via bin/mv eval
|
||||
case Application.ensure_all_started(@app) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
||||
end
|
||||
|
||||
admin_email = get_env("ADMIN_EMAIL", nil)
|
||||
admin_password = get_env_or_file("ADMIN_PASSWORD", nil)
|
||||
|
||||
cond do
|
||||
is_nil(admin_email) or admin_email == "" ->
|
||||
:ok
|
||||
|
||||
is_nil(admin_password) or admin_password == "" ->
|
||||
ensure_admin_role_only(admin_email)
|
||||
|
||||
true ->
|
||||
ensure_admin_user(admin_email, admin_password)
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_admin_role_only(email) do
|
||||
case Role.get_admin_role() do
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:ok, %Role{} = admin_role} ->
|
||||
case get_user_by_email(email) do
|
||||
{:ok, %User{} = user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_admin_user(email, password) do
|
||||
if is_nil(password) or password == "" do
|
||||
:ok
|
||||
else
|
||||
do_ensure_admin_user(email, password)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_ensure_admin_user(email, password) do
|
||||
case Role.get_admin_role() do
|
||||
{:ok, nil} ->
|
||||
# Admin role does not exist (e.g. migrations not run); skip
|
||||
:ok
|
||||
|
||||
{:ok, %Role{} = admin_role} ->
|
||||
case get_user_by_email(email) do
|
||||
{:ok, nil} ->
|
||||
create_admin_user(email, password, admin_role)
|
||||
|
||||
{:ok, user} ->
|
||||
update_admin_user(user, password, admin_role)
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp create_admin_user(email, password, admin_role) do
|
||||
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||
{:ok, user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp update_admin_user(user, password, admin_role) do
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp get_user_by_email(email) do
|
||||
User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||
end
|
||||
|
||||
defp get_env(key, default) do
|
||||
System.get_env(key, default)
|
||||
end
|
||||
|
||||
defp get_env_or_file(var_name, default) do
|
||||
file_var = "#{var_name}_FILE"
|
||||
|
||||
case System.get_env(file_var) do
|
||||
nil ->
|
||||
System.get_env(var_name, default)
|
||||
|
||||
file_path ->
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
String.trim_trailing(content)
|
||||
|
||||
{:error, _} ->
|
||||
default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp repos do
|
||||
Application.fetch_env!(@app, :ecto_repos)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -545,6 +545,9 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :label, :string
|
||||
attr :class, :string
|
||||
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
|
||||
|
||||
attr :sort_field, :any,
|
||||
doc: "optional; when equal to table sort_field, aria-sort is set on this th"
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
|
@ -560,7 +563,13 @@ defmodule MvWeb.CoreComponents do
|
|||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
||||
<th
|
||||
:for={col <- @col}
|
||||
class={Map.get(col, :class)}
|
||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||
>
|
||||
{col[:label]}
|
||||
</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
|
|
@ -646,6 +655,16 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
||||
if not is_nil(col_sort) and col_sort == sort_field and sort_order in [:asc, :desc] do
|
||||
if sort_order == :asc, do: "ascending", else: "descending"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
/>
|
||||
<% 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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.TableComponents do
|
|||
type="button"
|
||||
phx-click="sort"
|
||||
phx-value-field={@field}
|
||||
aria-sort={aria_sort(@sort_field, @sort_order, @field)}
|
||||
class="flex items-center gap-1 hover:underline focus:outline-none"
|
||||
>
|
||||
<span>{@label}</span>
|
||||
|
|
@ -33,12 +32,4 @@ defmodule MvWeb.TableComponents do
|
|||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp aria_sort(current_field, current_order, this_field) do
|
||||
cond do
|
||||
current_field != this_field -> "none"
|
||||
current_order == :asc -> "ascending"
|
||||
true -> "descending"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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
|
||||
|
|
@ -7,7 +7,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
- Manage custom fields
|
||||
- Real-time form validation
|
||||
- Success/error feedback
|
||||
- CSV member import (admin only)
|
||||
|
||||
## Settings
|
||||
- `club_name` - The name of the association/club (required)
|
||||
|
|
@ -15,47 +14,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Save settings changes
|
||||
- `start_import` - Start CSV member import (admin only)
|
||||
|
||||
## CSV Import
|
||||
|
||||
The CSV import feature allows administrators to upload CSV files and import members.
|
||||
|
||||
### File Upload
|
||||
|
||||
Files are uploaded automatically when selected (`auto_upload: true`). No manual
|
||||
upload trigger is required.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Currently, there is no rate limiting for CSV imports. Administrators can start
|
||||
multiple imports in quick succession. This is intentional for bulk data migration
|
||||
scenarios, but should be monitored in production.
|
||||
|
||||
### Limits
|
||||
|
||||
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
|
||||
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
|
||||
- Processing: chunks of 200 rows
|
||||
- Errors: capped at 50 per import
|
||||
|
||||
## Note
|
||||
Settings is a singleton resource - there is only one settings record.
|
||||
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
|
||||
|
||||
CSV member import has been moved to the Import/Export page (`/admin/import-export`).
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Config
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
# CSV Import configuration constants
|
||||
@max_errors 50
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
|
@ -69,22 +40,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :idle)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:max_errors, @max_errors)
|
||||
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|
||||
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
|
||||
|> assign_form()
|
||||
# Configure file upload with auto-upload enabled
|
||||
# Files are uploaded automatically when selected, no need for manual trigger
|
||||
|> allow_upload(:csv_file,
|
||||
accept: ~w(.csv),
|
||||
max_entries: 1,
|
||||
max_file_size: Config.csv_import_max_file_size_bytes(),
|
||||
auto_upload: true
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
|
@ -133,211 +90,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
<%!-- CSV Import Section (Admin only) --%>
|
||||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"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 memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<.link
|
||||
href="#custom_fields"
|
||||
class="link"
|
||||
data-testid="custom-fields-link"
|
||||
>
|
||||
{gettext("Manage Memberdata")}
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-base-content/70 mb-2">
|
||||
{gettext("Download CSV templates:")}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_en.csv"}
|
||||
download="member_import_en.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("English Template")}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_de.csv"}
|
||||
download="member_import_de.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
id="csv-upload-form"
|
||||
for={%{}}
|
||||
multipart={true}
|
||||
phx-change="validate_csv_upload"
|
||||
phx-submit="start_import"
|
||||
data-testid="csv-upload-form"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<label class="label" id="csv_file_help">
|
||||
<span class="label-text-alt">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<.button
|
||||
type="submit"
|
||||
phx-disable-with={gettext("Starting import...")}
|
||||
variant="primary"
|
||||
disabled={
|
||||
@import_status == :running or
|
||||
Enum.empty?(@uploads.csv_file.entries) or
|
||||
@uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?))
|
||||
}
|
||||
data-testid="start-import-button"
|
||||
>
|
||||
{gettext("Start Import")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<%= if @import_status == :running or @import_status == :done do %>
|
||||
<%= if @import_progress do %>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mt-4"
|
||||
data-testid="import-progress-container"
|
||||
>
|
||||
<%= if @import_progress.status == :running do %>
|
||||
<p class="text-sm" data-testid="import-progress-text">
|
||||
{gettext("Processing chunk %{current} of %{total}...",
|
||||
current: @import_progress.current_chunk,
|
||||
total: @import_progress.total_chunks
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_progress.status == :done do %>
|
||||
<section class="space-y-4" data-testid="import-results-panel">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{gettext("Import Results")}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
{gettext("Summary")}
|
||||
</h3>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Successfully inserted: %{count} member(s)",
|
||||
count: @import_progress.inserted
|
||||
)}
|
||||
</p>
|
||||
<%= if @import_progress.failed > 0 do %>
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-exclamation-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if @import_progress.errors_truncated? do %>
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Error list truncated to %{count} entries",
|
||||
count: @max_errors
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if length(@import_progress.errors) > 0 do %>
|
||||
<div data-testid="import-error-list">
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
<.icon
|
||||
name="hero-exclamation-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Errors")}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for error <- @import_progress.errors do %>
|
||||
<li>
|
||||
{gettext("Line %{line}: %{message}",
|
||||
line: error.csv_line_number || "?",
|
||||
message: error.message || gettext("Unknown error")
|
||||
)}
|
||||
<%= if error.field do %>
|
||||
{gettext(" (Field: %{field})", field: error.field)}
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if length(@import_progress.warnings) > 0 do %>
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{gettext("Warnings")}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for warning <- @import_progress.warnings do %>
|
||||
<li>{warning}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</.form_section>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -370,115 +122,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_csv_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("start_import", _params, socket) do
|
||||
case check_import_prerequisites(socket) do
|
||||
{:error, message} ->
|
||||
{:noreply, put_flash(socket, :error, message)}
|
||||
|
||||
:ok ->
|
||||
process_csv_upload(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if import can be started (admin permission, status, upload ready)
|
||||
defp check_import_prerequisites(socket) do
|
||||
# Ensure user role is loaded before authorization check
|
||||
user = socket.assigns[:current_user]
|
||||
user_with_role = Actor.ensure_loaded(user)
|
||||
|
||||
cond do
|
||||
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
|
||||
{:error, gettext("Only administrators can import members from CSV files.")}
|
||||
|
||||
socket.assigns.import_status == :running ->
|
||||
{:error, gettext("Import is already running. Please wait for it to complete.")}
|
||||
|
||||
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
|
||||
{:error, gettext("Please select a CSV file to import.")}
|
||||
|
||||
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
|
||||
{:error,
|
||||
gettext("Please wait for the file upload to complete before starting the import.")}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Processes CSV upload and starts import
|
||||
defp process_csv_upload(socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
start_import(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
error_message = format_error_message(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to prepare CSV import: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
# Starts the import process
|
||||
defp start_import(socket, import_state) do
|
||||
progress = initialize_import_progress(import_state)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, progress)
|
||||
|> assign(:import_status, :running)
|
||||
|
||||
send(self(), {:process_chunk, 0})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Initializes import progress structure
|
||||
defp initialize_import_progress(import_state) do
|
||||
%{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: import_state.warnings || [],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: length(import_state.chunks),
|
||||
errors_truncated?: false
|
||||
}
|
||||
end
|
||||
|
||||
# Formats error messages for display
|
||||
defp format_error_message(error) do
|
||||
case error do
|
||||
%{message: msg} when is_binary(msg) -> msg
|
||||
%{errors: errors} when is_list(errors) -> inspect(errors)
|
||||
reason when is_binary(reason) -> reason
|
||||
other -> inspect(other)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
|
|
@ -558,139 +201,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{:noreply, assign(socket, :settings, updated_settings)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:process_chunk, idx}, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state, import_progress: progress}
|
||||
when is_map(import_state) and is_map(progress) ->
|
||||
if idx >= 0 and idx < length(import_state.chunks) do
|
||||
start_chunk_processing_task(socket, import_state, progress, idx)
|
||||
else
|
||||
handle_chunk_error(socket, :invalid_index, idx)
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Missing required assigns - mark as error
|
||||
handle_chunk_error(socket, :missing_state, idx)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:chunk_done, idx, result}, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state, import_progress: progress}
|
||||
when is_map(import_state) and is_map(progress) ->
|
||||
handle_chunk_result(socket, import_state, progress, idx, result)
|
||||
|
||||
_ ->
|
||||
# Missing required assigns - mark as error
|
||||
handle_chunk_error(socket, :missing_state, idx)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:chunk_error, idx, reason}, socket) do
|
||||
handle_chunk_error(socket, :processing_failed, idx, reason)
|
||||
end
|
||||
|
||||
# Starts async task to process a chunk
|
||||
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues
|
||||
defp start_chunk_processing_task(socket, import_state, progress, idx) do
|
||||
chunk = Enum.at(import_state.chunks, idx)
|
||||
# Ensure user role is loaded before using as actor
|
||||
user = socket.assigns[:current_user]
|
||||
actor = Actor.ensure_loaded(user)
|
||||
live_view_pid = self()
|
||||
|
||||
# Process chunk with existing error count for capping
|
||||
opts = [
|
||||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
max_errors: @max_errors,
|
||||
actor: actor
|
||||
]
|
||||
|
||||
# Get locale from socket for translations in background tasks
|
||||
locale = socket.assigns[:locale] || "de"
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
if Config.sql_sandbox?() do
|
||||
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
|
||||
{:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts
|
||||
)
|
||||
|
||||
# In test mode, send the message - it will be processed when render() is called
|
||||
# in the test. The test helper wait_for_import_completion() handles message processing
|
||||
send(live_view_pid, {:chunk_done, idx, chunk_result})
|
||||
else
|
||||
# Start async task to process chunk in production
|
||||
# Use start_child for fire-and-forget: no monitor, no Task messages
|
||||
# We only use our own send/2 messages for communication
|
||||
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
|
||||
# Set locale in task process for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
{:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts
|
||||
)
|
||||
|
||||
send(live_view_pid, {:chunk_done, idx, chunk_result})
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Handles chunk processing result from async task
|
||||
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
|
||||
# Merge progress
|
||||
new_progress = merge_progress(progress, chunk_result, idx)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|
||||
# Schedule next chunk or mark as done
|
||||
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Handles chunk processing errors
|
||||
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
|
||||
error_message =
|
||||
case error_type do
|
||||
:invalid_index ->
|
||||
gettext("Invalid chunk index: %{idx}", idx: idx)
|
||||
|
||||
:missing_state ->
|
||||
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
|
||||
|
||||
:processing_failed ->
|
||||
gettext("Failed to process chunk %{idx}: %{reason}",
|
||||
idx: idx,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_status, :error)
|
||||
|> put_flash(:error, error_message)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
@ -703,71 +213,4 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp consume_and_read_csv(socket) do
|
||||
result =
|
||||
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
|
||||
case File.read(path) do
|
||||
{:ok, content} -> {:ok, content}
|
||||
{:error, reason} -> {:error, Exception.message(reason)}
|
||||
end
|
||||
end)
|
||||
|
||||
result
|
||||
|> case do
|
||||
[content] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
[{:ok, content}] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
[{:error, reason}] ->
|
||||
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
|
||||
|
||||
[] ->
|
||||
{:error, gettext("No file was uploaded")}
|
||||
|
||||
_other ->
|
||||
{:error, gettext("Failed to read uploaded file")}
|
||||
end
|
||||
end
|
||||
|
||||
defp merge_progress(progress, chunk_result, current_chunk_idx) do
|
||||
# Merge errors with cap of @max_errors overall
|
||||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, @max_errors)
|
||||
errors_truncated? = length(all_errors) > @max_errors
|
||||
|
||||
# Merge warnings (optional dedupe - simple append for now)
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
|
||||
# Update status based on whether we're done
|
||||
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
||||
%{
|
||||
inserted: progress.inserted + chunk_result.inserted,
|
||||
failed: progress.failed + chunk_result.failed,
|
||||
errors: new_errors,
|
||||
warnings: new_warnings,
|
||||
status: new_status,
|
||||
current_chunk: chunks_processed,
|
||||
total_chunks: progress.total_chunks,
|
||||
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
|
||||
}
|
||||
end
|
||||
|
||||
defp schedule_next_chunk(socket, current_idx, total_chunks) do
|
||||
next_idx = current_idx + 1
|
||||
|
||||
if next_idx < total_chunks do
|
||||
# Schedule next chunk
|
||||
send(self(), {:process_chunk, next_idx})
|
||||
socket
|
||||
else
|
||||
# All chunks processed - status already set to :done in merge_progress
|
||||
socket
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
828
lib/mv_web/live/import_export_live.ex
Normal file
828
lib/mv_web/live/import_export_live.ex
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
defmodule MvWeb.ImportExportLive do
|
||||
@moduledoc """
|
||||
LiveView for importing and exporting members via CSV.
|
||||
|
||||
## Features
|
||||
- CSV member import (admin only)
|
||||
- Real-time import progress tracking
|
||||
- Error and warning reporting
|
||||
- Custom fields support
|
||||
|
||||
## CSV Import
|
||||
|
||||
The CSV import feature allows administrators to upload CSV files and import members.
|
||||
|
||||
### File Upload
|
||||
|
||||
Files are uploaded automatically when selected (`auto_upload: true`). No manual
|
||||
upload trigger is required.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Currently, there is no rate limiting for CSV imports. Administrators can start
|
||||
multiple imports in quick succession. This is intentional for bulk data migration
|
||||
scenarios, but should be monitored in production.
|
||||
|
||||
### Limits
|
||||
|
||||
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
|
||||
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
|
||||
- Processing: chunks of 200 rows
|
||||
- Errors: capped at 50 per import
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Config
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
# Maximum number of errors to collect per import to prevent memory issues
|
||||
# and keep error display manageable. Additional errors are silently dropped
|
||||
# after this limit is reached.
|
||||
@max_errors 50
|
||||
|
||||
# Maximum length for error messages before truncation
|
||||
@max_error_message_length 200
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Get locale from session for translations
|
||||
locale = session["locale"] || "de"
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
# Get club name from settings
|
||||
club_name =
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Import/Export"))
|
||||
|> assign(:club_name, club_name)
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :idle)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:max_errors, @max_errors)
|
||||
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|
||||
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
|
||||
# Configure file upload with auto-upload enabled
|
||||
# Files are uploaded automatically when selected, no need for manual trigger
|
||||
|> allow_upload(:csv_file,
|
||||
accept: ~w(.csv),
|
||||
max_entries: 1,
|
||||
max_file_size: Config.csv_import_max_file_size_bytes(),
|
||||
auto_upload: true
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
|
||||
<.header>
|
||||
{gettext("Import/Export")}
|
||||
<:subtitle>
|
||||
{gettext("Import members from CSV files or export member data.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<%!-- CSV Import Section --%>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
{import_info_box(assigns)}
|
||||
{template_links(assigns)}
|
||||
{import_form(assigns)}
|
||||
<%= if @import_status == :running or @import_status == :done do %>
|
||||
{import_progress(assigns)}
|
||||
<% end %>
|
||||
</.form_section>
|
||||
|
||||
<%!-- Export Section (Placeholder) --%>
|
||||
<.form_section title={gettext("Export Members (CSV)")}>
|
||||
<div role="note" class="alert alert-info">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Export functionality will be available in a future release.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
<% else %>
|
||||
<div role="alert" class="alert alert-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p>{gettext("You do not have permission to access this page.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders the info box explaining CSV import requirements
|
||||
defp import_info_box(assigns) do
|
||||
~H"""
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"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."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<.link
|
||||
href={~p"/settings#custom_fields"}
|
||||
class="link"
|
||||
data-testid="custom-fields-link"
|
||||
>
|
||||
{gettext("Manage Member Data")}
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders template download links
|
||||
defp template_links(assigns) do
|
||||
~H"""
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-base-content/70 mb-2">
|
||||
{gettext("Download CSV templates:")}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_en.csv"}
|
||||
download="member_import_en.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("English Template")}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_de.csv"}
|
||||
download="member_import_de.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders the CSV upload form
|
||||
defp import_form(assigns) do
|
||||
~H"""
|
||||
<.form
|
||||
id="csv-upload-form"
|
||||
for={%{}}
|
||||
multipart={true}
|
||||
phx-change="validate_csv_upload"
|
||||
phx-submit="start_import"
|
||||
data-testid="csv-upload-form"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<p class="label-text-alt mt-1" id="csv_file_help">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.button
|
||||
type="submit"
|
||||
phx-disable-with={gettext("Starting import...")}
|
||||
variant="primary"
|
||||
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
|
||||
data-testid="start-import-button"
|
||||
>
|
||||
{gettext("Start Import")}
|
||||
</.button>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders import progress and results
|
||||
defp import_progress(assigns) do
|
||||
~H"""
|
||||
<%= if @import_progress do %>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mt-4"
|
||||
data-testid="import-progress-container"
|
||||
>
|
||||
<%= if @import_progress.status == :running do %>
|
||||
<p class="text-sm" data-testid="import-progress-text">
|
||||
{gettext("Processing chunk %{current} of %{total}...",
|
||||
current: @import_progress.current_chunk,
|
||||
total: @import_progress.total_chunks
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_progress.status == :done do %>
|
||||
{import_results(assigns)}
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders import results summary, errors, and warnings
|
||||
defp import_results(assigns) do
|
||||
~H"""
|
||||
<section class="space-y-4" data-testid="import-results-panel">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{gettext("Import Results")}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
{gettext("Summary")}
|
||||
</h3>
|
||||
<div class="text-sm space-y-2">
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Successfully inserted: %{count} member(s)",
|
||||
count: @import_progress.inserted
|
||||
)}
|
||||
</p>
|
||||
<%= if @import_progress.failed > 0 do %>
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-exclamation-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if @import_progress.errors_truncated? do %>
|
||||
<p>
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if length(@import_progress.errors) > 0 do %>
|
||||
<div data-testid="import-error-list">
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
<.icon
|
||||
name="hero-exclamation-circle"
|
||||
class="size-4 inline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{gettext("Errors")}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for error <- @import_progress.errors do %>
|
||||
<li>
|
||||
{gettext("Line %{line}: %{message}",
|
||||
line: error.csv_line_number || "?",
|
||||
message: error.message || gettext("Unknown error")
|
||||
)}
|
||||
<%= if error.field do %>
|
||||
{gettext(" (Field: %{field})", field: error.field)}
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if length(@import_progress.warnings) > 0 do %>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">
|
||||
{gettext("Warnings")}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for warning <- @import_progress.warnings do %>
|
||||
<li>{warning}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_csv_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("start_import", _params, socket) do
|
||||
case check_import_prerequisites(socket) do
|
||||
{:error, message} ->
|
||||
{:noreply, put_flash(socket, :error, message)}
|
||||
|
||||
:ok ->
|
||||
process_csv_upload(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if all prerequisites for starting an import are met.
|
||||
#
|
||||
# Validates:
|
||||
# - User has admin permissions
|
||||
# - No import is currently running
|
||||
# - CSV file is uploaded and ready
|
||||
#
|
||||
# Returns `:ok` if all checks pass, `{:error, message}` otherwise.
|
||||
#
|
||||
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
|
||||
# so ensure_actor_loaded is primarily for clarity.
|
||||
@spec check_import_prerequisites(Phoenix.LiveView.Socket.t()) ::
|
||||
:ok | {:error, String.t()}
|
||||
defp check_import_prerequisites(socket) do
|
||||
# on_mount already ensures role is loaded, but we keep this for clarity
|
||||
user_with_role = ensure_actor_loaded(socket)
|
||||
|
||||
cond do
|
||||
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
|
||||
{:error, gettext("Only administrators can import members from CSV files.")}
|
||||
|
||||
socket.assigns.import_status == :running ->
|
||||
{:error, gettext("Import is already running. Please wait for it to complete.")}
|
||||
|
||||
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
|
||||
{:error, gettext("Please select a CSV file to import.")}
|
||||
|
||||
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
|
||||
{:error,
|
||||
gettext("Please wait for the file upload to complete before starting the import.")}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Processes CSV upload and starts import process.
|
||||
#
|
||||
# Reads the uploaded CSV file, prepares it for import, and initiates
|
||||
# the chunked processing workflow.
|
||||
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp process_csv_upload(socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
start_import(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
error_message = format_error_message(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
# Starts the import process by initializing progress tracking and scheduling the first chunk.
|
||||
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp start_import(socket, import_state) do
|
||||
progress = initialize_import_progress(import_state)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, progress)
|
||||
|> assign(:import_status, :running)
|
||||
|
||||
send(self(), {:process_chunk, 0})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Initializes the import progress tracking structure with default values.
|
||||
@spec initialize_import_progress(map()) :: map()
|
||||
defp initialize_import_progress(import_state) do
|
||||
%{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: import_state.warnings || [],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: length(import_state.chunks),
|
||||
errors_truncated?: false
|
||||
}
|
||||
end
|
||||
|
||||
# Formats error messages for user-friendly display.
|
||||
#
|
||||
# Handles various error types including Ash errors, maps with message fields,
|
||||
# lists of errors, and fallback formatting for unknown types.
|
||||
@spec format_error_message(any()) :: String.t()
|
||||
defp format_error_message(error) do
|
||||
case error do
|
||||
%Ash.Error.Invalid{} = ash_error ->
|
||||
format_ash_error(ash_error)
|
||||
|
||||
%{message: msg} when is_binary(msg) ->
|
||||
msg
|
||||
|
||||
%{errors: errors} when is_list(errors) ->
|
||||
format_error_list(errors)
|
||||
|
||||
reason when is_binary(reason) ->
|
||||
reason
|
||||
|
||||
other ->
|
||||
format_unknown_error(other)
|
||||
end
|
||||
end
|
||||
|
||||
# Formats Ash validation errors for display
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||
Enum.map_join(errors, ", ", &format_single_error/1)
|
||||
end
|
||||
|
||||
defp format_ash_error(error) do
|
||||
format_unknown_error(error)
|
||||
end
|
||||
|
||||
# Formats a list of errors into a readable string
|
||||
defp format_error_list(errors) do
|
||||
Enum.map_join(errors, ", ", &format_single_error/1)
|
||||
end
|
||||
|
||||
# Formats a single error item
|
||||
defp format_single_error(error) when is_map(error) do
|
||||
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
|
||||
end
|
||||
|
||||
defp format_single_error(error) do
|
||||
to_string(error)
|
||||
end
|
||||
|
||||
# Formats unknown error types with truncation for very long messages
|
||||
defp format_unknown_error(other) do
|
||||
error_str = inspect(other, limit: :infinity, pretty: true)
|
||||
|
||||
if String.length(error_str) > @max_error_message_length do
|
||||
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
|
||||
else
|
||||
error_str
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:process_chunk, idx}, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state, import_progress: progress}
|
||||
when is_map(import_state) and is_map(progress) ->
|
||||
if idx < length(import_state.chunks) do
|
||||
start_chunk_processing_task(socket, import_state, progress, idx)
|
||||
else
|
||||
handle_chunk_error(socket, :invalid_index, idx)
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Missing required assigns - mark as error
|
||||
handle_chunk_error(socket, :missing_state, idx)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:chunk_done, idx, result}, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state, import_progress: progress}
|
||||
when is_map(import_state) and is_map(progress) ->
|
||||
handle_chunk_result(socket, import_state, progress, idx, result)
|
||||
|
||||
_ ->
|
||||
# Missing required assigns - mark as error
|
||||
handle_chunk_error(socket, :missing_state, idx)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:chunk_error, idx, reason}, socket) do
|
||||
handle_chunk_error(socket, :processing_failed, idx, reason)
|
||||
end
|
||||
|
||||
# Processes a chunk with error handling and sends result message to LiveView.
|
||||
#
|
||||
# Handles errors from MemberCSV.process_chunk and sends appropriate messages
|
||||
# to the LiveView process for progress tracking.
|
||||
@spec process_chunk_with_error_handling(
|
||||
list(),
|
||||
map(),
|
||||
map(),
|
||||
keyword(),
|
||||
pid(),
|
||||
non_neg_integer()
|
||||
) :: :ok
|
||||
defp process_chunk_with_error_handling(
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
) do
|
||||
result =
|
||||
try do
|
||||
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
|
||||
rescue
|
||||
e ->
|
||||
{:error, Exception.message(e)}
|
||||
catch
|
||||
:exit, reason ->
|
||||
{:error, inspect(reason)}
|
||||
|
||||
:throw, reason ->
|
||||
{:error, inspect(reason)}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, chunk_result} ->
|
||||
send(live_view_pid, {:chunk_done, idx, chunk_result})
|
||||
|
||||
{:error, reason} ->
|
||||
send(live_view_pid, {:chunk_error, idx, reason})
|
||||
end
|
||||
end
|
||||
|
||||
# Starts async task to process a chunk of CSV rows.
|
||||
#
|
||||
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues.
|
||||
@spec start_chunk_processing_task(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
map(),
|
||||
map(),
|
||||
non_neg_integer()
|
||||
) :: {:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp start_chunk_processing_task(socket, import_state, progress, idx) do
|
||||
chunk = Enum.at(import_state.chunks, idx)
|
||||
actor = ensure_actor_loaded(socket)
|
||||
live_view_pid = self()
|
||||
|
||||
# Process chunk with existing error count for capping
|
||||
opts = [
|
||||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
max_errors: @max_errors,
|
||||
actor: actor
|
||||
]
|
||||
|
||||
# Get locale from socket for translations in background tasks
|
||||
locale = socket.assigns[:locale] || "de"
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
if Config.sql_sandbox?() do
|
||||
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
|
||||
# In test mode, send the message - it will be processed when render() is called
|
||||
# in the test. The test helper wait_for_import_completion() handles message processing
|
||||
process_chunk_with_error_handling(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
# Start async task to process chunk in production
|
||||
# Use start_child for fire-and-forget: no monitor, no Task messages
|
||||
# We only use our own send/2 messages for communication
|
||||
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
|
||||
# Set locale in task process for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
process_chunk_with_error_handling(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Handles chunk processing result from async task and schedules the next chunk.
|
||||
@spec handle_chunk_result(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
map(),
|
||||
map(),
|
||||
non_neg_integer(),
|
||||
map()
|
||||
) :: {:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
|
||||
# Merge progress
|
||||
new_progress = merge_progress(progress, chunk_result, idx)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|
||||
# Schedule next chunk or mark as done
|
||||
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Handles chunk processing errors and updates socket with error status.
|
||||
@spec handle_chunk_error(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
:invalid_index | :missing_state | :processing_failed,
|
||||
non_neg_integer(),
|
||||
any()
|
||||
) :: {:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
|
||||
error_message =
|
||||
case error_type do
|
||||
:invalid_index ->
|
||||
gettext("Invalid chunk index: %{idx}", idx: idx)
|
||||
|
||||
:missing_state ->
|
||||
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
|
||||
|
||||
:processing_failed ->
|
||||
gettext("Failed to process chunk %{idx}: %{reason}",
|
||||
idx: idx,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_status, :error)
|
||||
|> put_flash(:error, error_message)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Consumes uploaded CSV file entries and reads the file content.
|
||||
#
|
||||
# Returns the file content as a binary string or an error tuple.
|
||||
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
|
||||
{:ok, String.t()} | {:error, String.t()}
|
||||
defp consume_and_read_csv(socket) do
|
||||
raw = consume_uploaded_entries(socket, :csv_file, &read_file_entry/2)
|
||||
|
||||
case raw do
|
||||
[{:ok, content}] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
# Phoenix LiveView test (render_upload) can return raw content list when callback return is treated as value
|
||||
[content] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
[{:error, reason}] ->
|
||||
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
|
||||
|
||||
[] ->
|
||||
{:error, gettext("No file was uploaded")}
|
||||
|
||||
_other ->
|
||||
{:error, gettext("Failed to read uploaded file: unexpected format")}
|
||||
end
|
||||
end
|
||||
|
||||
# Reads a single file entry from the uploaded path
|
||||
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
defp read_file_entry(%{path: path}, _entry) do
|
||||
case File.read(path) do
|
||||
{:ok, content} ->
|
||||
{:ok, content}
|
||||
|
||||
{:error, reason} when is_atom(reason) ->
|
||||
# POSIX error atoms (e.g., :enoent) need to be formatted
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, %File.Error{reason: reason}} ->
|
||||
# File.Error struct with reason atom
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
# Fallback for other error types
|
||||
{:error, Exception.message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
# Merges chunk processing results into the overall import progress.
|
||||
#
|
||||
# Handles error capping, warning merging, and status updates.
|
||||
@spec merge_progress(map(), map(), non_neg_integer()) :: map()
|
||||
defp merge_progress(progress, chunk_result, current_chunk_idx) do
|
||||
# Merge errors with cap of @max_errors overall
|
||||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, @max_errors)
|
||||
errors_truncated? = length(all_errors) > @max_errors
|
||||
|
||||
# Merge warnings (optional dedupe - simple append for now)
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
|
||||
# Update status based on whether we're done
|
||||
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
||||
%{
|
||||
inserted: progress.inserted + chunk_result.inserted,
|
||||
failed: progress.failed + chunk_result.failed,
|
||||
errors: new_errors,
|
||||
warnings: new_warnings,
|
||||
status: new_status,
|
||||
current_chunk: chunks_processed,
|
||||
total_chunks: progress.total_chunks,
|
||||
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
|
||||
}
|
||||
end
|
||||
|
||||
# Schedules the next chunk for processing or marks import as complete.
|
||||
@spec schedule_next_chunk(Phoenix.LiveView.Socket.t(), non_neg_integer(), non_neg_integer()) ::
|
||||
Phoenix.LiveView.Socket.t()
|
||||
defp schedule_next_chunk(socket, current_idx, total_chunks) do
|
||||
next_idx = current_idx + 1
|
||||
|
||||
if next_idx < total_chunks do
|
||||
# Schedule next chunk
|
||||
send(self(), {:process_chunk, next_idx})
|
||||
socket
|
||||
else
|
||||
# All chunks processed - status already set to :done in merge_progress
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# Determines if the import button should be disabled based on import status and upload state
|
||||
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
||||
defp import_button_disabled?(:running, _entries), do: true
|
||||
defp import_button_disabled?(_status, []), do: true
|
||||
defp import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
|
||||
defp import_button_disabled?(_status, _entries), do: false
|
||||
|
||||
# Ensures the actor (user with role) is loaded from socket assigns.
|
||||
#
|
||||
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
|
||||
# so this is primarily for clarity and defensive programming.
|
||||
@spec ensure_actor_loaded(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
|
||||
defp ensure_actor_loaded(socket) do
|
||||
user = socket.assigns[:current_user]
|
||||
# on_mount already ensures role is loaded, but we keep this for clarity
|
||||
Actor.ensure_loaded(user)
|
||||
end
|
||||
end
|
||||
|
|
@ -177,7 +177,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
phx-change="validate"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<option value="">{gettext("None")}</option>
|
||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||
<option value="">{gettext("Select a membership fee type")}</option>
|
||||
<%= for fee_type <- @available_fee_types do %>
|
||||
<option
|
||||
value={fee_type.id}
|
||||
|
|
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="text-error text-sm mt-1">{msg}</p>
|
||||
<% end %>
|
||||
<%= if @interval_warning do %>
|
||||
|
|
|
|||
|
|
@ -125,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
|
|||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||
a user is linked but not visible. --%>
|
||||
<%= if can_access_page?(@current_user, "/users") do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
|
|
@ -287,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||
@impl true
|
||||
def handle_info({:put_flash, type, message}, socket) do
|
||||
{:noreply, put_flash(socket, type, message)}
|
||||
end
|
||||
|
||||
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
|
||||
@impl true
|
||||
def handle_info({:member_updated, updated_member}, socket) do
|
||||
member =
|
||||
updated_member
|
||||
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|
||||
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
|
||||
|
||||
{:noreply, assign(socket, :member, member)}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
require Ash.Query
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees
|
||||
|
|
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Action Buttons --%>
|
||||
<%!-- Action Buttons (only when user has permission) --%>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<.button
|
||||
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||
phx-click="regenerate_cycles"
|
||||
phx-target={@myself}
|
||||
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
||||
|
|
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@cycles)}
|
||||
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
{gettext("Delete All Cycles")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={@member.membership_fee_type}
|
||||
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||
phx-click="open_create_cycle_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-primary"
|
||||
|
|
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Amount")}>
|
||||
<span
|
||||
class="font-mono cursor-pointer hover:text-primary"
|
||||
phx-click="edit_cycle_amount"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
title={gettext("Click to edit amount")}
|
||||
>
|
||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||
</span>
|
||||
<%= if @can_update_cycle do %>
|
||||
<span
|
||||
class="font-mono cursor-pointer hover:text-primary"
|
||||
phx-click="edit_cycle_amount"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
title={gettext("Click to edit amount")}
|
||||
>
|
||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Status")}>
|
||||
|
|
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
<:action :let={cycle}>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
<%= if @can_update_cycle do %>
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @can_destroy_cycle do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</:action>
|
||||
</.table>
|
||||
|
|
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
# Get available fee types (filtered to same interval if member has a type)
|
||||
available_fee_types = get_available_fee_types(member, actor)
|
||||
|
||||
# Permission flags for cycle actions (so read_only does not see create/update/destroy UI)
|
||||
can_create_cycle = can?(actor, :create, MembershipFeeCycle)
|
||||
can_destroy_cycle = can?(actor, :destroy, MembershipFeeCycle)
|
||||
can_update_cycle = can?(actor, :update, MembershipFeeCycle)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:can_create_cycle, can_create_cycle)
|
||||
|> assign(:can_destroy_cycle, can_destroy_cycle)
|
||||
|> assign(:can_update_cycle, can_update_cycle)
|
||||
|> assign_new(:interval_warning, fn -> nil end)
|
||||
|> assign_new(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|
|
@ -439,7 +457,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:cycles, [])
|
||||
|> assign(
|
||||
:available_fee_types,
|
||||
get_available_fee_types(updated_member, current_actor(socket))
|
||||
get_available_fee_types(updated_member, actor)
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||
|
|
@ -470,13 +488,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
if interval_warning do
|
||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||
else
|
||||
actor = current_actor(socket)
|
||||
|
||||
case update_member_fee_type(member, fee_type_id, actor) do
|
||||
{:ok, updated_member} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
updated_member
|
||||
|> Ash.load!(
|
||||
|
|
@ -502,7 +516,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:cycles, cycles)
|
||||
|> assign(
|
||||
:available_fee_types,
|
||||
get_available_fee_types(updated_member, current_actor(socket))
|
||||
get_available_fee_types(updated_member, actor)
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|
|
@ -554,17 +568,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
end
|
||||
|
||||
def handle_event("regenerate_cycles", _params, socket) do
|
||||
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
|
||||
actor = current_actor(socket)
|
||||
|
||||
# SECURITY: Only admins can manually regenerate cycles via UI
|
||||
# Cycle generation itself uses system actor, but UI access should be restricted
|
||||
if actor.role && actor.role.permission_set_name == "admin" do
|
||||
if can?(actor, :create, MembershipFeeCycle) do
|
||||
socket = assign(socket, :regenerating, true)
|
||||
member = socket.assigns.member
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
|
|
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -722,61 +735,31 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
|
||||
expected = String.downcase(gettext("Yes"))
|
||||
|
||||
if confirmation != expected do
|
||||
if confirmation == expected do
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
cycles = socket.assigns.cycles
|
||||
|
||||
reset_modal = fn s ->
|
||||
s
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
end
|
||||
|
||||
if can?(actor, :destroy, MembershipFeeCycle) do
|
||||
do_delete_all_cycles(socket, member, actor, cycles, reset_modal)
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> reset_modal.()
|
||||
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:error, gettext("Confirmation text does not match"))}
|
||||
else
|
||||
member = socket.assigns.member
|
||||
|
||||
# Delete all cycles atomically using Ecto query
|
||||
import Ecto.Query
|
||||
|
||||
deleted_count =
|
||||
Mv.Repo.delete_all(
|
||||
from c in Mv.MembershipFees.MembershipFeeCycle,
|
||||
where: c.member_id == ^member.id
|
||||
)
|
||||
|
||||
if deleted_count > 0 do
|
||||
# Reload member to get updated cycles
|
||||
actor = current_actor(socket)
|
||||
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!(
|
||||
[
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated_cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("No cycles to delete"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -895,6 +878,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
# Helper functions
|
||||
|
||||
defp do_delete_all_cycles(socket, member, actor, cycles, reset_modal) do
|
||||
result =
|
||||
Enum.reduce_while(cycles, {:ok, 0}, fn cycle, {:ok, count} ->
|
||||
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
|
||||
:ok -> {:cont, {:ok, count + 1}}
|
||||
{:ok, _} -> {:cont, {:ok, count + 1}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:ok, deleted_count} when deleted_count > 0 ->
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!(
|
||||
[:membership_fee_type, membership_fee_cycles: [:membership_fee_type]],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated_cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> reset_modal.()
|
||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reset_modal.()
|
||||
|> put_flash(:info, gettext("No cycles to delete"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reset_modal.()
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_available_fee_types(member, actor) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|
|
@ -940,6 +972,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Forbidden{}) do
|
||||
gettext("You are not allowed to perform this action.")
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,20 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
membership_fee_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -200,10 +200,12 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
membership_fee_type =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
||||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
|
||||
end
|
||||
|
||||
page_title =
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
require Jason
|
||||
|
||||
alias Mv.Authorization
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
|
||||
|
|
@ -50,6 +52,18 @@ defmodule MvWeb.UserLive.Form do
|
|||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
<%= if @user && @can_assign_role do %>
|
||||
<div class="mt-4">
|
||||
<.input
|
||||
field={@form[:role_id]}
|
||||
type="select"
|
||||
label={gettext("Role")}
|
||||
options={Enum.map(@roles, &{&1.name, &1.id})}
|
||||
prompt={gettext("Select role...")}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Password Section -->
|
||||
<div class="mt-6">
|
||||
<label class="flex items-center space-x-2">
|
||||
|
|
@ -67,6 +81,18 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
<%= if @show_password_fields do %>
|
||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
||||
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
||||
<p class="text-sm font-semibold text-red-800">
|
||||
{gettext("SSO / OIDC user")}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
{gettext(
|
||||
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
label={gettext("Password")}
|
||||
|
|
@ -300,6 +326,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||
# Only admins can assign user roles (Role update permission).
|
||||
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
|
||||
roles = if can_assign_role, do: load_roles(actor), else: []
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -307,6 +336,8 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(user: user)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||
|> assign(:can_assign_role, can_assign_role)
|
||||
|> assign(:roles, roles)
|
||||
|> assign(:show_password_fields, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|
|
@ -357,7 +388,10 @@ defmodule MvWeb.UserLive.Form do
|
|||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
# First save the user without member changes
|
||||
# Include current member in params when not linking/unlinking so update_user's
|
||||
# manage_relationship(on_missing: :unrelate) does not accidentally unlink.
|
||||
user_params = params_with_member_if_unchanged(socket, user_params)
|
||||
|
||||
case submit_form(socket.assigns.form, user_params, actor) do
|
||||
{:ok, user} ->
|
||||
handle_member_linking(socket, user, actor)
|
||||
|
|
@ -529,6 +563,20 @@ defmodule MvWeb.UserLive.Form do
|
|||
defp get_action_name(:update), do: gettext("updated")
|
||||
defp get_action_name(other), do: to_string(other)
|
||||
|
||||
# When user has a linked member and we are not linking/unlinking, include current member in params
|
||||
# so update_user's manage_relationship(on_missing: :unrelate) does not unlink the member.
|
||||
defp params_with_member_if_unchanged(socket, params) do
|
||||
user = socket.assigns.user
|
||||
linking = socket.assigns.selected_member_id
|
||||
unlinking = socket.assigns[:unlink_member]
|
||||
|
||||
if user && user.member_id && !linking && !unlinking do
|
||||
Map.put(params, "member", %{"id" => user.member_id})
|
||||
else
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_member_link_error(socket, error) do
|
||||
error_message = extract_error_message(error)
|
||||
|
||||
|
|
@ -572,7 +620,8 @@ defmodule MvWeb.UserLive.Form do
|
|||
assigns: %{
|
||||
user: user,
|
||||
show_password_fields: show_password_fields,
|
||||
can_manage_member_linking: can_manage_member_linking
|
||||
can_manage_member_linking: can_manage_member_linking,
|
||||
can_assign_role: can_assign_role
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
|
|
@ -580,16 +629,25 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
form =
|
||||
if user do
|
||||
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
|
||||
# For existing users: admin uses update_user (email + member + role_id); non-admin uses update (email only).
|
||||
# Password change uses admin_set_password for both.
|
||||
action =
|
||||
cond do
|
||||
show_password_fields -> :admin_set_password
|
||||
can_manage_member_linking -> :update_user
|
||||
can_manage_member_linking or can_assign_role -> :update_user
|
||||
true -> :update
|
||||
end
|
||||
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||
form =
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||
|
||||
# Ensure role_id is always included on submit when role dropdown is shown (AshPhoenix.Form
|
||||
# only submits keys in touched_forms; marking as touched avoids role change being dropped).
|
||||
if can_assign_role and action == :update_user do
|
||||
AshPhoenix.Form.touch(form, [:role_id])
|
||||
else
|
||||
form
|
||||
end
|
||||
else
|
||||
# For new users, use password registration if password fields are shown
|
||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||
|
|
@ -668,6 +726,14 @@ defmodule MvWeb.UserLive.Form do
|
|||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
end
|
||||
|
||||
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
||||
defp load_roles(actor) do
|
||||
case Authorization.list_roles(actor: actor) do
|
||||
{:ok, roles} -> roles
|
||||
{:error, _} -> []
|
||||
end
|
||||
end
|
||||
|
||||
# Extract user-friendly error message from Ash.Error
|
||||
@spec extract_error_message(any()) :: String.t()
|
||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ defmodule MvWeb.UserLive.Index do
|
|||
users =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|
||||
|> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor)
|
||||
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||
|
||||
sorted = Enum.sort_by(users, & &1.email)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
rows={@users}
|
||||
row_id={fn user -> "row-#{user.id}" end}
|
||||
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
<:col
|
||||
:let={user}
|
||||
|
|
@ -45,6 +47,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={user}
|
||||
sort_field={:email}
|
||||
label={
|
||||
sort_button(%{
|
||||
field: :email,
|
||||
|
|
@ -56,11 +59,28 @@
|
|||
>
|
||||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Role")}>
|
||||
{user.role.name}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">{gettext("No member linked")}</span>
|
||||
<span class="text-base-content/70">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Password")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
|
||||
<span>{gettext("Enabled")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
|
||||
<span>{gettext("Linked")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,16 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
<.list>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||
<:item title={gettext("Password Authentication")}>
|
||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
||||
do: gettext("Enabled"),
|
||||
else: gettext("Not enabled")}
|
||||
</:item>
|
||||
<:item title={gettext("OIDC")}>
|
||||
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
||||
do: gettext("Linked"),
|
||||
else: gettext("Not linked")}
|
||||
</:item>
|
||||
<:item title={gettext("Linked Member")}>
|
||||
<%= if @user.member do %>
|
||||
|
|
@ -79,7 +87,9 @@ defmodule MvWeb.UserLive.Show do
|
|||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||
|
||||
user =
|
||||
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||
|
||||
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ defmodule MvWeb.Router do
|
|||
live "/admin/roles/:id", RoleLive.Show, :show
|
||||
live "/admin/roles/:id/edit", RoleLive.Form, :edit
|
||||
|
||||
# Import/Export (Admin only)
|
||||
live "/admin/import-export", ImportExportLive
|
||||
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
|
|
|
|||
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_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
|
||||
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
|
||||
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
|
|
@ -28,21 +28,21 @@
|
|||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
|
||||
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
|
||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.5.1", "7302a4fda1920ba541b456c2d7a97acc3c7f9d7b938b5435927883b709c968a2", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "797fdca7cc60d7588c6e285b0d7ea73f2dce8b123bac43eae70271fa519bb907"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
|
|
@ -57,26 +57,26 @@
|
|||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
|
||||
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
||||
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
|
||||
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
||||
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
|
||||
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
||||
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"},
|
||||
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
||||
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
|||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
|
||||
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -77,12 +77,12 @@ msgstr "Abbrechen"
|
|||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
|
||||
msgstr "Falsches Passwort. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
|
||||
msgstr "Ungültige Sitzung. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -102,32 +102,32 @@ msgstr "Verknüpfen..."
|
|||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
|
||||
msgstr "Sitzung abgelaufen. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
|
||||
msgstr "Dein OIDC-Konto wurde erfolgreich verknüpft! Du wirst zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
|
||||
msgstr "Konto aktiviert! Du wirst zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
|
||||
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuche es erneut oder kontaktiere den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
|
||||
msgstr "Die E-Mail-Adresse aus deinem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider oder kontaktiere den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
|
||||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
|
|||
|
|
@ -239,27 +239,27 @@ msgstr "Mitglied wurde erfolgreich %{action}"
|
|||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed in"
|
||||
msgstr "Sie sind jetzt angemeldet"
|
||||
msgstr "Du bist jetzt angemeldet"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed out"
|
||||
msgstr "Sie sind jetzt abgemeldet"
|
||||
msgstr "Du bist jetzt abgemeldet"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n"
|
||||
msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestätigt.\nSie können Ihr Konto über den Link bestätigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n"
|
||||
msgstr "Du hast dich bereits auf andere Weise angemeldet, aber dein Konto noch nicht bestätigt.\nDu kannst dein Konto über den Link bestätigen, den wir dir gesendet haben, oder durch Zurücksetzen deines Passworts.\n"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your email address has now been confirmed"
|
||||
msgstr "Ihre E-Mail-Adresse wurde bestätigt"
|
||||
msgstr "Deine E-Mail-Adresse wurde bestätigt"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
||||
msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
|
|
@ -294,6 +294,7 @@ msgstr "Beschreibung"
|
|||
msgid "Edit User"
|
||||
msgstr "Benutzer*in bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enabled"
|
||||
|
|
@ -398,7 +399,7 @@ msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
|
|
@ -438,7 +439,7 @@ msgstr "Administrator*innen-Hinweis"
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||
msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen."
|
||||
msgstr "Als Administrator*in kannst du direkt ein neues Passwort für diese*n Benutzer*in setzen."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -453,7 +454,7 @@ msgstr "Passwort ändern"
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Check 'Change Password' above to set a new password for this user."
|
||||
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
|
||||
msgstr "Aktiviere 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -471,6 +472,7 @@ msgid "Include both letters and numbers"
|
|||
msgstr "Buchstaben und Zahlen verwenden"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
|
@ -498,7 +500,7 @@ msgstr "Passwort setzen"
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
|
||||
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktiviere 'Passwort setzen', um eines hinzuzufügen."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -568,27 +570,27 @@ msgstr "Vorname"
|
|||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen."
|
||||
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Passwort, um dein OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
msgstr "Anmeldung fehlgeschlagen. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please try again."
|
||||
msgstr "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
msgstr "Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider."
|
||||
msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -666,7 +668,7 @@ msgstr "Einstellungen erfolgreich gespeichert"
|
|||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
|
||||
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändere bitte zuerst eine der E-Mail-Adressen."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -958,7 +960,6 @@ msgid "Last name"
|
|||
msgstr "Nachname"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
|
@ -1071,7 +1072,7 @@ msgstr "Ein Fehler ist aufgetreten"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr "Möchten Sie diesen Zyklus wirklich löschen?"
|
||||
msgstr "Möchtest du diesen Zyklus wirklich löschen?"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1091,7 +1092,7 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to edit amount"
|
||||
msgstr "Klicken Sie, um den Betrag zu bearbeiten"
|
||||
msgstr "Klicke, um den Betrag zu bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1411,7 +1412,7 @@ msgstr "Zahlungsintervall"
|
|||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please confirm the amount change first"
|
||||
msgstr "Bitte bestätigen Sie zuerst die Betragsänderung"
|
||||
msgstr "Bitte bestätige zuerst die Betragsänderung"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1441,7 +1442,7 @@ msgstr "Mitgliedsbeitragsart speichern"
|
|||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
msgstr "Wählen Sie eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
|
||||
msgstr "Wähle eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1482,12 +1483,12 @@ msgstr "Art"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr "Geben Sie '%{confirmation}' ein, um zu bestätigen"
|
||||
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr "Verwenden Sie dieses Formular, um Mitgliedsbeitragsarten in Ihrer Datenbank zu verwalten."
|
||||
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1498,7 +1499,7 @@ msgstr "Warnung"
|
|||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wählen Sie eine Mitgliedsbeitragsart mit demselben Intervall."
|
||||
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wähle eine Mitgliedsbeitragsart mit demselben Intervall."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1622,7 +1623,7 @@ msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
|
|||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
||||
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weisen Sie sie zunächst einer anderen Rolle zu."
|
||||
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weise sie zunächst einer anderen Rolle zu."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1670,6 +1671,9 @@ msgstr "Profil"
|
|||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role"
|
||||
msgstr "Rolle"
|
||||
|
|
@ -1742,7 +1746,7 @@ msgstr "Sidebar umschalten"
|
|||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr "Verwenden Sie dieses Formular, um Rollen in Ihrer Datenbank zu verwalten."
|
||||
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1772,7 +1776,7 @@ msgstr "read_only - Lesezugriff auf alle Daten"
|
|||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to %{action} members."
|
||||
msgstr "Sie haben keine Berechtigung, Mitglieder zu %{action}."
|
||||
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1817,22 +1821,22 @@ msgstr "Benutzer*in nicht gefunden"
|
|||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
||||
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this user"
|
||||
msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
msgstr "Sie haben keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
|
||||
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this user"
|
||||
msgstr "Sie haben keine Berechtigung, diese*n Benutzer*in zu löschen"
|
||||
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1844,7 +1848,7 @@ msgstr "erstellt"
|
|||
msgid "updated"
|
||||
msgstr "aktualisiert"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
|
|
@ -1863,12 +1867,12 @@ msgstr "Mitglied nicht gefunden"
|
|||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this member"
|
||||
msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
||||
msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this member"
|
||||
msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
|
||||
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1918,17 +1922,17 @@ msgstr "Fehler beim %{action} des Mitglieds."
|
|||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to save member. Please try again."
|
||||
msgstr "Fehler beim Speichern des Mitglieds. Bitte versuchen Sie es erneut."
|
||||
msgstr "Fehler beim Speichern des Mitglieds. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please correct the errors in the form and try again."
|
||||
msgstr "Bitte korrigieren Sie die Fehler im Formular und versuchen Sie es erneut."
|
||||
msgstr "Bitte korrigiere die Fehler im Formular und versuche es erneut."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Validation failed. Please check your input."
|
||||
msgstr "Validierung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe."
|
||||
msgstr "Validierung fehlgeschlagen. Bitte überprüfe deine Eingabe."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1965,152 +1969,137 @@ msgstr "Bezahlstatus"
|
|||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
msgstr "Nur Administrator*innen können Zyklen regenerieren"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid " (Field: %{field})"
|
||||
msgstr " (Datenfeld: %{field})"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
msgstr "CSV Datei"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Download CSV templates:"
|
||||
msgstr "CSV Vorlagen herunterladen:"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "English Template"
|
||||
msgstr "Englische Vorlage"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error list truncated to %{count} entries"
|
||||
msgstr "Liste der Fehler auf %{count} Einträge reduziert"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Errors"
|
||||
msgstr "Fehler"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{error}"
|
||||
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{reason}"
|
||||
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to process chunk %{idx}: %{reason}"
|
||||
msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to read file: %{reason}"
|
||||
msgstr "Fehler beim Lesen der Datei: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to read uploaded file"
|
||||
msgstr "Fehler beim Lesen der hochgeladenen Datei"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed: %{count} row(s)"
|
||||
msgstr "Fehlgeschlagen: %{count} Zeile(n)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "German Template"
|
||||
msgstr "Deutsche Vorlage"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
msgstr "Import-Ergebnisse"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import is already running. Please wait for it to complete."
|
||||
msgstr "Import läuft bereits. Bitte warten Sie, bis er abgeschlossen ist."
|
||||
msgstr "Import läuft bereits. Bitte warte, bis er abgeschlossen ist."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import state is missing. Cannot process chunk %{idx}."
|
||||
msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid chunk index: %{idx}"
|
||||
msgstr "Ungültiger Chunk-Index: %{idx}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Line %{line}: %{message}"
|
||||
msgstr "Zeile %{line}: %{message}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No file was uploaded"
|
||||
msgstr "Es wurde keine Datei hochgeladen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can import members from CSV files."
|
||||
msgstr "Nur Administrator*innen können Mitglieder aus CSV-Dateien importieren."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please select a CSV file to import."
|
||||
msgstr "Bitte wählen Sie eine CSV-Datei zum Importieren."
|
||||
msgstr "Bitte wähle eine CSV-Datei zum Importieren."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please wait for the file upload to complete before starting the import."
|
||||
msgstr "Bitte warten Sie, bis der Datei-Upload abgeschlossen ist, bevor Sie den Import starten."
|
||||
msgstr "Bitte warte, bis der Datei-Upload abgeschlossen ist, bevor du den Import startest."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Processing chunk %{current} of %{total}..."
|
||||
msgstr "Verarbeite Chunk %{current} von %{total}..."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Start Import"
|
||||
msgstr "Import starten"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Starting import..."
|
||||
msgstr "Import wird gestartet..."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Successfully inserted: %{count} member(s)"
|
||||
msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Summary"
|
||||
msgstr "Zusammenfassung"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Warnings"
|
||||
msgstr "Warnungen"
|
||||
|
|
@ -2256,9 +2245,9 @@ msgstr "Nicht berechtigt."
|
|||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen."
|
||||
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "CSV files only, maximum %{size} MB"
|
||||
msgstr "Nur CSV Dateien, maximal %{size} MB"
|
||||
|
|
@ -2283,22 +2272,100 @@ msgstr "Datenfeld: %{name} – erwartet %{type} %{details}, erhalten: %{value}"
|
|||
msgid "custom_field: %{name} – expected %{type}, got: %{value}"
|
||||
msgstr "Datenfeld: %{name} – erwartet %{type}, erhalten: %{value}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage Memberdata"
|
||||
msgstr "Mitgliederdaten verwalten"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
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 memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. Sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
||||
msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld ist, erstellen Sie es in Mila vor dem Import."
|
||||
msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld ist, erstelle es in Mila vor dem Import."
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export Members (CSV)"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export functionality will be available in a future release."
|
||||
msgstr "Export-Funktionalität ist im nächsten release verfügbar."
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to read uploaded file: unexpected format"
|
||||
msgstr "Fehler beim Lesen der hochgeladenen Datei"
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files or export member data."
|
||||
msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import/Export"
|
||||
msgstr "Import/Export"
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage Member Data"
|
||||
msgstr "Mitgliederdaten verwalten"
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
|
||||
|
||||
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Only administrators or the linked user can change the email for members linked to users"
|
||||
msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select role..."
|
||||
msgstr "Keine auswählen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are not allowed to perform this action."
|
||||
msgstr "Du hast keine Berechtigung, diese Aktion auszuführen."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type"
|
||||
msgstr "Mitgliedsbeitragstyp auswählen"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked"
|
||||
msgstr "Verknüpft"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "OIDC"
|
||||
msgstr "OIDC"
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not linked"
|
||||
msgstr "Nicht verknüpft"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSO / OIDC user"
|
||||
msgstr "SSO-/OIDC-Benutzer*in"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||
msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Only administrators can regenerate cycles"
|
||||
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ msgstr "muss vorhanden sein"
|
|||
|
||||
## Custom validation messages from Mv.Accounts.User
|
||||
msgid "User already has a member. Remove existing member first."
|
||||
msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied."
|
||||
msgstr "Benutzer*in hat bereits ein Mitglied. Entferne zuerst das vorhandene Mitglied."
|
||||
|
||||
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
|
||||
msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten"
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ msgstr ""
|
|||
msgid "Edit User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enabled"
|
||||
|
|
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
|
@ -959,7 +961,6 @@ msgid "Last name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
|
@ -1671,6 +1672,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role"
|
||||
msgstr ""
|
||||
|
|
@ -1845,7 +1849,7 @@ msgstr ""
|
|||
msgid "updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
|
|
@ -1966,152 +1970,137 @@ msgstr ""
|
|||
msgid "Reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid " (Field: %{field})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Download CSV templates:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "English Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error list truncated to %{count} entries"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Errors"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to process chunk %{idx}: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to read file: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to read uploaded file"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed: %{count} row(s)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "German Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import is already running. Please wait for it to complete."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import state is missing. Cannot process chunk %{idx}."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid chunk index: %{idx}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Line %{line}: %{message}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No file was uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can import members from CSV files."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please select a CSV file to import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please wait for the file upload to complete before starting the import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Processing chunk %{current} of %{total}..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Start Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Starting import..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Successfully inserted: %{count} member(s)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warnings"
|
||||
msgstr ""
|
||||
|
|
@ -2259,7 +2248,7 @@ msgstr ""
|
|||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV files only, maximum %{size} MB"
|
||||
msgstr ""
|
||||
|
|
@ -2284,22 +2273,95 @@ msgstr ""
|
|||
msgid "custom_field: %{name} – expected %{type}, got: %{value}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage Memberdata"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export functionality will be available in a future release."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to read uploaded file: unexpected format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files or export member data."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import/Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage Member Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators or the linked user can change the email for members linked to users"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select role..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are not allowed to perform this action."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "OIDC"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSO / OIDC user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ msgstr ""
|
|||
msgid "Edit User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enabled"
|
||||
|
|
@ -472,6 +473,7 @@ msgid "Include both letters and numbers"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
|
@ -959,7 +961,6 @@ msgid "Last name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
|
@ -1671,6 +1672,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Role"
|
||||
msgstr ""
|
||||
|
|
@ -1845,7 +1849,7 @@ msgstr ""
|
|||
msgid "updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown error"
|
||||
|
|
@ -1966,152 +1970,137 @@ msgstr ""
|
|||
msgid "Reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid " (Field: %{field})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Download CSV templates:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "English Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error list truncated to %{count} entries"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Errors"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to prepare CSV import: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to process chunk %{idx}: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to read file: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to read uploaded file"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed: %{count} row(s)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "German Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import is already running. Please wait for it to complete."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import state is missing. Cannot process chunk %{idx}."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid chunk index: %{idx}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Line %{line}: %{message}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No file was uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can import members from CSV files."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Please select a CSV file to import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please wait for the file upload to complete before starting the import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Processing chunk %{current} of %{total}..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Start Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Starting import..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Successfully inserted: %{count} member(s)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Warnings"
|
||||
msgstr ""
|
||||
|
|
@ -2259,7 +2248,7 @@ msgstr ""
|
|||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "CSV files only, maximum %{size} MB"
|
||||
msgstr ""
|
||||
|
|
@ -2284,22 +2273,100 @@ msgstr ""
|
|||
msgid "custom_field: %{name} – expected %{type}, got: %{value}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage Memberdata"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
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 memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
||||
msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export functionality will be available in a future release."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to read uploaded file: unexpected format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files or export member data."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import/Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage Member Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_export_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/member/validations/email_change_permission.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Only administrators or the linked user can change the email for members linked to users"
|
||||
msgstr "Only administrators or the linked user can change the email for members linked to users"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select role..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are not allowed to perform this action."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select a membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "OIDC"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Not linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "SSO / OIDC user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Only administrators can regenerate cycles"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
|
|||
|
||||
require Ash.Query
|
||||
|
||||
# Create example membership fee types
|
||||
# Create example membership fee types (no admin user yet; skip authorization for bootstrap)
|
||||
for fee_type_attrs <- [
|
||||
%{
|
||||
name: "Standard (Jährlich)",
|
||||
|
|
@ -39,7 +39,12 @@ for fee_type_attrs <- [
|
|||
] do
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|
||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_name)
|
||||
|> Ash.create!(
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_name,
|
||||
authorize?: false,
|
||||
domain: Mv.MembershipFees
|
||||
)
|
||||
end
|
||||
|
||||
for attrs <- [
|
||||
|
|
@ -127,8 +132,15 @@ for attrs <- [
|
|||
)
|
||||
end
|
||||
|
||||
# Get admin email from environment variable or use default
|
||||
# Admin email: default for dev/test so seed_admin has a target
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
System.put_env("ADMIN_EMAIL", admin_email)
|
||||
|
||||
# In dev/test, set fallback password so seed_admin creates the admin user when none is set
|
||||
if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and
|
||||
is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do
|
||||
System.put_env("ADMIN_PASSWORD", "testpassword")
|
||||
end
|
||||
|
||||
# Create all authorization roles (idempotent - creates only if they don't exist)
|
||||
# Roles are created using create_role_with_system_flag to allow setting is_system_role
|
||||
|
|
@ -209,39 +221,9 @@ if is_nil(admin_role) do
|
|||
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||
end
|
||||
|
||||
# Assign admin role to user with ADMIN_EMAIL (if user exists)
|
||||
# This handles both existing users (e.g., from OIDC) and newly created users
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||
{:ok, existing_admin_user} when not is_nil(existing_admin_user) ->
|
||||
# User already exists (e.g., via OIDC) - assign admin role
|
||||
# Use authorize?: false for bootstrap - this is initial setup
|
||||
existing_admin_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
{:ok, nil} ->
|
||||
# User doesn't exist - create admin user with password
|
||||
# Use authorize?: false for bootstrap - no admin user exists yet to use as actor
|
||||
Accounts.create_user!(%{email: admin_email},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn user ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|
||||
{:error, error} ->
|
||||
raise "Failed to check for existing admin user: #{inspect(error)}"
|
||||
end
|
||||
# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE).
|
||||
# Reduces duplication and exercises the same path as production entrypoint.
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
# Load admin user with role for use as actor in member operations
|
||||
# This ensures all member operations have proper authorization
|
||||
|
|
@ -299,12 +281,12 @@ case Accounts.User
|
|||
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
|
||||
end
|
||||
|
||||
# Load all membership fee types for assignment
|
||||
# Load all membership fee types for assignment (admin actor for authorization)
|
||||
# Sort by name to ensure deterministic order
|
||||
all_fee_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
||||
|> Enum.to_list()
|
||||
|
||||
# Create sample members for testing - use upsert to prevent duplicates
|
||||
|
|
@ -452,7 +434,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
end
|
||||
end)
|
||||
|
||||
# Create additional users for user-member linking examples
|
||||
# Create additional users for user-member linking examples (no password by default)
|
||||
# Only admin gets a password (admin_set_password when created); all other users have no password.
|
||||
additional_users = [
|
||||
%{email: "hans.mueller@example.de"},
|
||||
%{email: "greta.schmidt@example.de"},
|
||||
|
|
@ -462,15 +445,12 @@ additional_users = [
|
|||
|
||||
created_users =
|
||||
Enum.map(additional_users, fn user_attrs ->
|
||||
# Use admin user as actor for additional user creation (not bootstrap)
|
||||
user =
|
||||
Accounts.create_user!(user_attrs,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
|
||||
# Reload user to ensure all fields (including member_id) are loaded
|
||||
Accounts.User
|
||||
|
|
@ -744,7 +724,14 @@ IO.puts("📝 Created sample data:")
|
|||
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
||||
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
|
||||
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
|
||||
IO.puts(" - Admin user: #{admin_email} (password: testpassword)")
|
||||
|
||||
password_configured =
|
||||
System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil
|
||||
|
||||
IO.puts(
|
||||
" - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})"
|
||||
)
|
||||
|
||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||
|
||||
IO.puts(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ set -e
|
|||
echo "==> Running database migrations..."
|
||||
/app/bin/migrate
|
||||
|
||||
echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..."
|
||||
/app/bin/mv eval "Mv.Release.seed_admin()"
|
||||
|
||||
echo "==> Starting application..."
|
||||
exec /app/bin/server
|
||||
|
||||
|
|
|
|||
|
|
@ -41,18 +41,6 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
assert is_nil(found_user.oidc_id)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password authentication uses email as identity_field" do
|
||||
# Verify the configuration: password strategy should use email as identity_field
|
||||
# This test checks the AshAuthentication configuration
|
||||
|
||||
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||
|
||||
assert password_strategy != nil
|
||||
assert password_strategy.identity_field == :email
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "multiple users can exist with different emails" do
|
||||
user1 =
|
||||
|
|
@ -130,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
)
|
||||
|
||||
case result do
|
||||
{:ok, found_user} when is_struct(found_user) ->
|
||||
assert found_user.id == user.id
|
||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||
|
||||
{:ok, [found_user]} ->
|
||||
assert found_user.id == user.id
|
||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||
|
|
@ -137,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
{:ok, []} ->
|
||||
flunk("User should be found by oidc_id")
|
||||
|
||||
{:ok, nil} ->
|
||||
flunk("User should be found by oidc_id")
|
||||
|
||||
{:error, error} ->
|
||||
flunk("Unexpected error: #{inspect(error)}")
|
||||
end
|
||||
|
|
@ -231,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -272,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
defmodule Mv.Membership.CustomFieldSlugTest do
|
||||
@moduledoc """
|
||||
Tests for automatic slug generation on CustomField resource.
|
||||
Tests for CustomField slug business rules only.
|
||||
|
||||
This test suite verifies:
|
||||
1. Slugs are automatically generated from the name attribute
|
||||
2. Slugs are unique (cannot have duplicates)
|
||||
3. Slugs are immutable (don't change when name changes)
|
||||
4. Slugs handle various edge cases (unicode, special chars, etc.)
|
||||
5. Slugs can be used for lookups
|
||||
We test our business logic, not Ash/slugify implementation details:
|
||||
- Slug is generated from name on create (one smoke test)
|
||||
- Slug is unique (business rule)
|
||||
- Slug is immutable (does not change when name is updated; cannot be set manually)
|
||||
- Slug cannot be empty (rejects name with only special characters)
|
||||
|
||||
We do not test: slugify edge cases (umlauts, truncation, etc.) or Ash/Ecto struct/load behavior.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -18,8 +19,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "automatic slug generation on create" do
|
||||
test "generates slug from name with simple ASCII text", %{actor: actor} do
|
||||
describe "slug generation (business rule)" do
|
||||
test "slug is generated from name on create", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
|
|
@ -30,78 +31,6 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
|
||||
assert custom_field.slug == "mobile-phone"
|
||||
end
|
||||
|
||||
test "generates slug from name with German umlauts", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Café Müller",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "cafe-muller"
|
||||
end
|
||||
|
||||
test "generates slug with lowercase conversion", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "TEST NAME",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "generates slug by removing special characters", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "E-Mail & Address!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "e-mail-address"
|
||||
end
|
||||
|
||||
test "generates slug by replacing multiple spaces with single hyphen", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Multiple Spaces",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "multiple-spaces"
|
||||
end
|
||||
|
||||
test "trims leading and trailing hyphens", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "-Test-",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Straße",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "strasse"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug uniqueness" do
|
||||
|
|
@ -248,29 +177,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "slug edge cases" do
|
||||
test "handles very long names by truncating slug", %{actor: actor} do
|
||||
# Create a name at the maximum length (100 chars)
|
||||
long_name = String.duplicate("abcdefghij", 10)
|
||||
# 100 characters exactly
|
||||
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: long_name,
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Slug should be truncated to maximum 100 characters
|
||||
assert String.length(custom_field.slug) <= 100
|
||||
# Should be the full slugified version since name is exactly 100 chars
|
||||
assert custom_field.slug == long_name
|
||||
end
|
||||
|
||||
describe "slug cannot be empty (business rule)" do
|
||||
test "rejects name with only special characters", %{actor: actor} do
|
||||
# When name contains only special characters, slug would be empty
|
||||
# This should fail validation
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
|
|
@ -279,107 +187,9 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Should fail because slug would be empty
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
||||
end
|
||||
|
||||
test "handles mixed special characters and text", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test@#$%Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# slugify keeps the hyphen between words
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles numbers in name", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Field 123 Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "field-123-test"
|
||||
end
|
||||
|
||||
test "handles consecutive hyphens in name", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test---Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Should reduce multiple hyphens to single hyphen
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles name with dots and underscores", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test.field_name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Dots and underscores should be handled (either kept or converted)
|
||||
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug in queries and responses" do
|
||||
test "slug is included in struct after create", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Slug should be present in the struct
|
||||
assert Map.has_key?(custom_field, :slug)
|
||||
assert custom_field.slug != nil
|
||||
end
|
||||
|
||||
test "can load custom field and slug is present", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Load it back
|
||||
loaded_custom_field = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||
|
||||
assert loaded_custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "slug is returned in list queries", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
custom_fields = Ash.read!(CustomField, actor: actor)
|
||||
|
||||
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
|
||||
assert found.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based lookup (future feature)" do
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Mv.Membership.GroupTest do
|
|||
@moduledoc """
|
||||
Tests for Group resource validations, CRUD operations, and relationships.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
@ -232,23 +232,7 @@ defmodule Mv.Membership.GroupTest do
|
|||
end
|
||||
|
||||
describe "Relationships & Deletion" do
|
||||
test "group has many_to_many members relationship (load with preloading)", %{actor: actor} do
|
||||
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
|
||||
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Load group with members
|
||||
{:ok, group_with_members} =
|
||||
Ash.load(group, :members, actor: actor, domain: Mv.Membership)
|
||||
|
||||
assert length(group_with_members.members) == 1
|
||||
assert hd(group_with_members.members).id == member.id
|
||||
end
|
||||
|
||||
# We test business/data rules (CASCADE), not Ash relationship loading (framework).
|
||||
test "delete group cascades to member_groups (members remain intact)", %{actor: actor} do
|
||||
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
|
||||
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Mv.Membership.MemberGroupTest do
|
|||
@moduledoc """
|
||||
Tests for MemberGroup join table resource - validations and cascade delete behavior.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -54,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
|||
|
||||
# Create a valid fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
Ash.create(
|
||||
MembershipFeeType,
|
||||
%{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Setting a valid fee type should work
|
||||
{:ok, updated} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
assert updated.default_membership_fee_type_id == fee_type.id
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{errors: errors} = changeset
|
||||
|
|
@ -90,7 +90,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{errors: errors} = changeset
|
||||
|
|
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
||||
assert error.message =~ "yearly"
|
||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?,
|
||||
"Should prevent change from #{interval1} to #{interval2}"
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
||||
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||
assert updated.status == :unpaid
|
||||
end
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
|
||||
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||
assert updated.status == :unpaid
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -155,9 +155,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Try to delete
|
||||
|
|
@ -176,9 +180,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Create a member without explicitly setting membership_fee_type_id
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeType resource.
|
||||
Tests for MembershipFeeType business rules only.
|
||||
|
||||
We test: required fields, allowed interval values, uniqueness, amount constraints,
|
||||
interval immutability, and referential integrity (cannot delete when in use).
|
||||
We do not test: standard CRUD (create/update/delete when no constraints apply).
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -11,34 +15,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "create MembershipFeeType" do
|
||||
test "can create membership fee type with valid attributes", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Standard Membership",
|
||||
amount: Decimal.new("120.00"),
|
||||
interval: :yearly,
|
||||
description: "Standard yearly membership fee"
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{} = fee_type} =
|
||||
Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
|
||||
assert fee_type.name == "Standard Membership"
|
||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
||||
assert fee_type.interval == :yearly
|
||||
assert fee_type.description == "Standard yearly membership fee"
|
||||
end
|
||||
|
||||
test "can create membership fee type without description", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Basic",
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :monthly
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
end
|
||||
|
||||
describe "create MembershipFeeType - business rules" do
|
||||
test "requires name", %{actor: actor} do
|
||||
attrs = %{
|
||||
amount: Decimal.new("100.00"),
|
||||
|
|
@ -69,28 +46,24 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
assert error_on_field?(error, :interval)
|
||||
end
|
||||
|
||||
test "validates interval enum values - monthly", %{actor: actor} do
|
||||
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :monthly
|
||||
end
|
||||
test "accepts valid interval values (monthly, quarterly, half_yearly, yearly)", %{
|
||||
actor: actor
|
||||
} do
|
||||
for {interval, name} <- [
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
half_yearly: "Half Yearly",
|
||||
yearly: "Yearly"
|
||||
] do
|
||||
attrs = %{
|
||||
name: "#{name} #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("10.00"),
|
||||
interval: interval
|
||||
}
|
||||
|
||||
test "validates interval enum values - quarterly", %{actor: actor} do
|
||||
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :quarterly
|
||||
end
|
||||
|
||||
test "validates interval enum values - half_yearly", %{actor: actor} do
|
||||
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :half_yearly
|
||||
end
|
||||
|
||||
test "validates interval enum values - yearly", %{actor: actor} do
|
||||
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :yearly
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == interval
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects invalid interval values", %{actor: actor} do
|
||||
|
|
@ -128,13 +101,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "update MembershipFeeType" do
|
||||
describe "update MembershipFeeType - business rules" do
|
||||
setup %{actor: actor} do
|
||||
{:ok, fee_type} =
|
||||
Ash.create(
|
||||
MembershipFeeType,
|
||||
%{
|
||||
name: "Original Name",
|
||||
name: "Original Name #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly,
|
||||
description: "Original description"
|
||||
|
|
@ -145,28 +118,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "can update name", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
|
||||
assert updated.name == "Updated Name"
|
||||
end
|
||||
|
||||
test "can update amount", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
|
||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||
end
|
||||
|
||||
test "can update description", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} =
|
||||
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
|
||||
|
||||
assert updated.description == "Updated description"
|
||||
end
|
||||
|
||||
test "can clear description", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}, actor: actor)
|
||||
assert updated.description == nil
|
||||
end
|
||||
|
||||
test "interval immutability: update fails when interval is changed", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
|
|
@ -179,7 +130,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "delete MembershipFeeType" do
|
||||
describe "delete MembershipFeeType - business rules (referential integrity)" do
|
||||
setup %{actor: actor} do
|
||||
{:ok, fee_type} =
|
||||
Ash.create(
|
||||
|
|
@ -195,12 +146,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "can delete when not in use", %{actor: actor, fee_type: fee_type} do
|
||||
result = Ash.destroy(fee_type, actor: actor)
|
||||
# Ash.destroy returns :ok or {:ok, _} depending on version
|
||||
assert result == :ok or match?({:ok, _}, result)
|
||||
end
|
||||
|
||||
test "cannot delete when members are assigned", %{actor: actor, fee_type: fee_type} do
|
||||
alias Mv.Membership.Member
|
||||
|
||||
|
|
@ -264,9 +209,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Try to delete
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -19,59 +18,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
# Create role with permission set
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
# Helper to create another user (for testing access to other users)
|
||||
defp create_other_user(actor) do
|
||||
create_user_with_permission_set("own_data", actor)
|
||||
end
|
||||
|
||||
# Shared test setup for permission sets with scope :own access
|
||||
defp setup_user_with_own_access(permission_set, actor) do
|
||||
user = create_user_with_permission_set(permission_set, actor)
|
||||
other_user = create_other_user(actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture(permission_set)
|
||||
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} =
|
||||
|
|
@ -80,217 +30,101 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("own_data", actor)
|
||||
# Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User)
|
||||
describe "non-admin permission sets (own_data, read_only, normal_user)" do
|
||||
setup %{actor: actor} = context do
|
||||
permission_set = context[:permission_set] || "own_data"
|
||||
setup_user_with_own_access(permission_set, actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
for permission_set <- ["own_data", "read_only", "normal_user"] do
|
||||
@tag permission_set: permission_set
|
||||
test "can read own user record (#{permission_set})", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "can update own email (#{permission_set})", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot read other users - not found due to auto_filter (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot update other users - forbidden (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("read_only", actor)
|
||||
end
|
||||
@tag permission_set: permission_set
|
||||
test "list users returns only own user (#{permission_set})", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot create user - forbidden (#{permission_set})", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@tag permission_set: permission_set
|
||||
test "cannot change role via update_user - forbidden (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
other_role = Mv.Fixtures.role_fixture("read_only")
|
||||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("normal_user", actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
|
||||
|> Ash.update(actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "admin permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
other_user = create_other_user(actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} =
|
||||
|
|
@ -343,6 +177,88 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
# Verify user is deleted
|
||||
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
||||
end
|
||||
|
||||
test "admin can assign role to another user via update_user", %{
|
||||
other_user: other_user
|
||||
} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
|
||||
{:ok, updated} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: admin)
|
||||
|
||||
assert updated.role_id == normal_user_role.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "admin role assignment and last-admin validation" do
|
||||
test "two admins: one can change own role to normal_user (other remains admin)", %{
|
||||
actor: _actor
|
||||
} do
|
||||
_admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
|
||||
admin_a = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
_admin_b = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, updated} =
|
||||
admin_a
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: admin_a)
|
||||
|
||||
assert updated.role_id == normal_user_role.id
|
||||
end
|
||||
|
||||
test "single admin: changing own role to normal_user returns validation error", %{
|
||||
actor: _actor
|
||||
} do
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
single_admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
single_admin
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: single_admin)
|
||||
|
||||
error_messages =
|
||||
Enum.flat_map(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} when is_binary(msg) -> [msg]
|
||||
%{message: msg} when is_binary(msg) -> [msg]
|
||||
_ -> []
|
||||
end)
|
||||
|
||||
assert Enum.any?(error_messages, fn msg ->
|
||||
msg =~ "least one user must keep the Admin role" or msg =~ "Admin role"
|
||||
end),
|
||||
"Expected last-admin validation message, got: #{inspect(error_messages)}"
|
||||
end
|
||||
|
||||
test "admin can switch to another admin role (two roles with permission_set_name admin)", %{
|
||||
actor: _actor
|
||||
} do
|
||||
# Two distinct roles both with permission_set_name "admin" (e.g. "Admin" and "Superadmin")
|
||||
admin_role_a = Mv.Fixtures.role_fixture("admin")
|
||||
admin_role_b = Mv.Fixtures.role_fixture("admin")
|
||||
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
# Ensure user has role_a so we can switch to role_b
|
||||
{:ok, admin_user} =
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_a.id})
|
||||
|> Ash.update(actor: admin_user)
|
||||
|
||||
assert admin_user.role_id == admin_role_a.id
|
||||
|
||||
# Switching to another admin role must be allowed (no last-admin error)
|
||||
{:ok, updated} =
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_b.id})
|
||||
|> Ash.update(actor: admin_user)
|
||||
|
||||
assert updated.role_id == admin_role_b.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "AshAuthentication bypass" do
|
||||
|
|
|
|||
|
|
@ -496,6 +496,281 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
|||
|
||||
assert "*" in permissions.pages
|
||||
end
|
||||
|
||||
test "admin pages include explicit /settings and /membership_fee_settings" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
assert "/settings" in permissions.pages
|
||||
assert "/membership_fee_settings" in permissions.pages
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MemberGroup resource" do
|
||||
test "own_data has MemberGroup read with scope :linked only" do
|
||||
permissions = PermissionSets.get_permissions(:own_data)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :linked
|
||||
assert mg_read.granted == true
|
||||
assert mg_create == nil || mg_create.granted == false
|
||||
end
|
||||
|
||||
test "read_only has MemberGroup read with scope :all, no create/destroy" do
|
||||
permissions = PermissionSets.get_permissions(:read_only)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create == nil || mg_create.granted == false
|
||||
assert mg_destroy == nil || mg_destroy.granted == false
|
||||
end
|
||||
|
||||
test "normal_user has MemberGroup read/create/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:normal_user)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create != nil
|
||||
assert mg_create.scope == :all
|
||||
assert mg_create.granted == true
|
||||
assert mg_destroy != nil
|
||||
assert mg_destroy.scope == :all
|
||||
assert mg_destroy.granted == true
|
||||
end
|
||||
|
||||
test "admin has MemberGroup read/create/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create != nil
|
||||
assert mg_create.granted == true
|
||||
assert mg_destroy != nil
|
||||
assert mg_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MembershipFeeType resource" do
|
||||
test "all permission sets have MembershipFeeType read with scope :all" do
|
||||
for set <- PermissionSets.all_permission_sets() do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mft_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :read
|
||||
end)
|
||||
|
||||
assert mft_read != nil, "Permission set #{set} should have MembershipFeeType read"
|
||||
assert mft_read.scope == :all
|
||||
assert mft_read.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
test "only admin has MembershipFeeType create/update/destroy" do
|
||||
for set <- [:own_data, :read_only, :normal_user] do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mft_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :create
|
||||
end)
|
||||
|
||||
mft_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :update
|
||||
end)
|
||||
|
||||
mft_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mft_create == nil || mft_create.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType create"
|
||||
|
||||
assert mft_update == nil || mft_update.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType update"
|
||||
|
||||
assert mft_destroy == nil || mft_destroy.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType destroy"
|
||||
end
|
||||
|
||||
admin_permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mft_create =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :create
|
||||
end)
|
||||
|
||||
mft_update =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :update
|
||||
end)
|
||||
|
||||
mft_destroy =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mft_create != nil
|
||||
assert mft_create.scope == :all
|
||||
assert mft_create.granted == true
|
||||
assert mft_update != nil
|
||||
assert mft_update.granted == true
|
||||
assert mft_destroy != nil
|
||||
assert mft_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MembershipFeeCycle resource" do
|
||||
test "all permission sets have MembershipFeeCycle read; own_data uses :linked, others :all" do
|
||||
for set <- PermissionSets.all_permission_sets() do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
||||
assert mfc_read.granted == true
|
||||
|
||||
expected_scope = if set == :own_data, do: :linked, else: :all
|
||||
|
||||
assert mfc_read.scope == expected_scope,
|
||||
"Permission set #{set} should have MembershipFeeCycle read scope #{expected_scope}, got #{mfc_read.scope}"
|
||||
end
|
||||
end
|
||||
|
||||
test "read_only has MembershipFeeCycle read only, no update" do
|
||||
permissions = PermissionSets.get_permissions(:read_only)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
assert mfc_update == nil || mfc_update.granted == false
|
||||
end
|
||||
|
||||
test "normal_user has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:normal_user)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
mfc_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||
end)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
mfc_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mfc_read != nil && mfc_read.granted == true
|
||||
assert mfc_create != nil && mfc_create.scope == :all && mfc_create.granted == true
|
||||
assert mfc_update != nil && mfc_update.granted == true
|
||||
assert mfc_destroy != nil && mfc_destroy.scope == :all && mfc_destroy.granted == true
|
||||
end
|
||||
|
||||
test "admin has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
mfc_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||
end)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
mfc_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mfc_read != nil
|
||||
assert mfc_read.granted == true
|
||||
assert mfc_create != nil
|
||||
assert mfc_create.granted == true
|
||||
assert mfc_update != nil
|
||||
assert mfc_update.granted == true
|
||||
assert mfc_destroy != nil
|
||||
assert mfc_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "valid_permission_set?/1" do
|
||||
|
|
|
|||
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
|
||||
|
||||
describe "permission_set_name validation" do
|
||||
test "accepts valid permission set names" do
|
||||
test "accepts valid permission set names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Test Role",
|
||||
permission_set_name: "own_data"
|
||||
}
|
||||
|
||||
assert {:ok, role} = Authorization.create_role(attrs)
|
||||
assert {:ok, role} = Authorization.create_role(attrs, actor: actor)
|
||||
assert role.permission_set_name == "own_data"
|
||||
end
|
||||
|
||||
test "rejects invalid permission set names" do
|
||||
test "rejects invalid permission set names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Test Role",
|
||||
permission_set_name: "invalid_set"
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert error_message(errors, :permission_set_name) =~ "must be one of"
|
||||
end
|
||||
|
||||
test "accepts all four valid permission sets" do
|
||||
test "accepts all four valid permission sets", %{actor: actor} do
|
||||
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
|
||||
|
||||
for permission_set <- valid_sets do
|
||||
|
|
@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do
|
|||
permission_set_name: permission_set
|
||||
}
|
||||
|
||||
assert {:ok, _role} = Authorization.create_role(attrs)
|
||||
assert {:ok, _role} = Authorization.create_role(attrs, actor: actor)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do
|
|||
{:ok, system_role} = Ash.create(changeset, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.destroy_role(system_role)
|
||||
Authorization.destroy_role(system_role, actor: actor)
|
||||
|
||||
message = error_message(errors, :is_system_role)
|
||||
assert message =~ "Cannot delete system role"
|
||||
end
|
||||
|
||||
test "allows deletion of non-system roles" do
|
||||
test "allows deletion of non-system roles", %{actor: actor} do
|
||||
# is_system_role defaults to false, so regular create works
|
||||
{:ok, regular_role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Regular Role",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{name: "Regular Role", permission_set_name: "read_only"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
assert :ok = Authorization.destroy_role(regular_role)
|
||||
assert :ok = Authorization.destroy_role(regular_role, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
describe "name uniqueness" do
|
||||
test "enforces unique role names" do
|
||||
test "enforces unique role names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Unique Role",
|
||||
permission_set_name: "own_data"
|
||||
}
|
||||
|
||||
assert {:ok, _} = Authorization.create_role(attrs)
|
||||
assert {:ok, _} = Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
|
||||
assert error_message(errors, :name) =~ "has already been taken"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
|
||||
end
|
||||
|
||||
# Helper function to ensure admin role exists
|
||||
# Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false)
|
||||
defp ensure_admin_role do
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||
nil ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
},
|
||||
authorize?: false
|
||||
)
|
||||
|
||||
role
|
||||
|
||||
|
|
@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
_ ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
},
|
||||
authorize?: false
|
||||
)
|
||||
|
||||
role
|
||||
end
|
||||
|
|
@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
test "raises error if system user has wrong role", %{system_user: system_user} do
|
||||
# Create a non-admin role (using read_only as it's a valid permission set)
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
{:ok, read_only_role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Read Only Role",
|
||||
description: "Read-only access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Read Only Role",
|
||||
description: "Read-only access",
|
||||
permission_set_name: "read_only"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,67 +8,30 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
defp create_custom_field do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
defp create_custom_field(actor) do
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
||||
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||
|
||||
field
|
||||
end
|
||||
|
||||
describe "read access (all roles)" do
|
||||
test "user with own_data can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
test "user with own_data can read all custom fields", %{actor: _actor} do
|
||||
custom_field = create_custom_field()
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
|
|
@ -78,9 +41,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with read_only can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
test "user with read_only can read all custom fields", %{actor: _actor} do
|
||||
custom_field = create_custom_field()
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
|
|
@ -90,9 +53,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with normal_user can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
test "user with normal_user can read all custom fields", %{actor: _actor} do
|
||||
custom_field = create_custom_field()
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
|
|
@ -102,9 +65,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with admin can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
test "user with admin can read all custom fields", %{actor: _actor} do
|
||||
custom_field = create_custom_field()
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
|
|
@ -116,9 +79,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
end
|
||||
|
||||
describe "write access - non-admin cannot create/update/destroy" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
setup %{actor: _actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
custom_field = create_custom_field()
|
||||
%{user: user, custom_field: custom_field}
|
||||
end
|
||||
|
||||
|
|
@ -152,9 +115,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
|
|||
end
|
||||
|
||||
describe "write access - admin can create/update/destroy" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
setup %{actor: _actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
custom_field = create_custom_field()
|
||||
%{user: user, custom_field: custom_field}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -20,47 +19,9 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
defp create_linked_member_for_user(user, _actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
defp create_linked_member_for_user(user, actor) do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
|
|
@ -68,18 +29,20 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
actor: admin
|
||||
)
|
||||
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|
||||
|> Ash.update(actor: actor, domain: Mv.Accounts, return_notifications?: false)
|
||||
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
|
||||
|
||||
member
|
||||
end
|
||||
|
||||
defp create_unlinked_member(actor) do
|
||||
defp create_unlinked_member(_actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
|
|
@ -87,25 +50,29 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
last_name: "Member",
|
||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
actor: admin
|
||||
)
|
||||
|
||||
member
|
||||
end
|
||||
|
||||
defp create_custom_field(actor) do
|
||||
defp create_custom_field do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||
|
||||
field
|
||||
end
|
||||
|
||||
defp create_custom_field_value(member_id, custom_field_id, value, actor) do
|
||||
defp create_custom_field_value(member_id, custom_field_id, value) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
|
|
@ -113,22 +80,22 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
custom_field_id: custom_field_id,
|
||||
value: %{"_union_type" => "string", "_union_value" => value}
|
||||
})
|
||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
||||
|> Ash.create(actor: admin, domain: Mv.Membership)
|
||||
|
||||
cfv
|
||||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||
|
||||
cfv_unlinked =
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
|
@ -177,10 +144,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
test "can create custom field value for linked member", %{
|
||||
user: user,
|
||||
linked_member: linked_member,
|
||||
actor: actor
|
||||
actor: _actor
|
||||
} do
|
||||
# Create a second custom field via admin (own_data cannot create CustomField)
|
||||
custom_field2 = create_custom_field(actor)
|
||||
custom_field2 = create_custom_field()
|
||||
|
||||
{:ok, cfv} =
|
||||
CustomFieldValue
|
||||
|
|
@ -257,15 +224,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||
|
||||
cfv_unlinked =
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
|
@ -340,15 +307,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||
|
||||
cfv_unlinked =
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
|
@ -379,10 +346,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
test "can create custom field value", %{
|
||||
user: user,
|
||||
unlinked_member: unlinked_member,
|
||||
actor: actor
|
||||
actor: _actor
|
||||
} do
|
||||
# normal_user cannot create CustomField; use actor (admin) to create it
|
||||
custom_field = create_custom_field(actor)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
{:ok, cfv} =
|
||||
CustomFieldValue
|
||||
|
|
@ -421,15 +388,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
describe "admin permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
|
||||
|
||||
cfv_unlinked =
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
|
||||
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
|
@ -457,7 +424,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
end
|
||||
|
||||
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
|
||||
custom_field = create_custom_field(user)
|
||||
custom_field = create_custom_field()
|
||||
|
||||
{:ok, cfv} =
|
||||
CustomFieldValue
|
||||
|
|
|
|||
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
|
||||
|
|
@ -8,7 +8,6 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
@ -17,49 +16,8 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
defp create_admin_user(actor) do
|
||||
create_user_with_permission_set("admin", actor)
|
||||
end
|
||||
|
||||
defp create_linked_member_for_user(user, actor) do
|
||||
admin = create_admin_user(actor)
|
||||
defp create_linked_member_for_user(user, _actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(
|
||||
|
|
@ -79,8 +37,8 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
member
|
||||
end
|
||||
|
||||
defp create_unlinked_member(actor) do
|
||||
admin = create_admin_user(actor)
|
||||
defp create_unlinked_member(_actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(
|
||||
|
|
@ -97,7 +55,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
|
||||
describe "unlinked member" do
|
||||
test "normal_user can update email of unlinked member", %{actor: actor} do
|
||||
normal_user = create_user_with_permission_set("normal_user", actor)
|
||||
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
new_email = "new#{System.unique_integer([:positive])}@example.com"
|
||||
|
|
@ -109,7 +67,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
end
|
||||
|
||||
test "validation does not block when member has no linked user", %{actor: actor} do
|
||||
normal_user = create_user_with_permission_set("normal_user", actor)
|
||||
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
new_email = "other#{System.unique_integer([:positive])}@example.com"
|
||||
|
|
@ -121,10 +79,10 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
|
||||
describe "linked member – another user's member" do
|
||||
test "normal_user cannot update email of another user's linked member", %{actor: actor} do
|
||||
user_a = create_user_with_permission_set("own_data", actor)
|
||||
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user_a, actor)
|
||||
|
||||
normal_user_b = create_user_with_permission_set("normal_user", actor)
|
||||
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
new_email = "other#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
|
|
@ -135,9 +93,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
end
|
||||
|
||||
test "admin can update email of linked member", %{actor: actor} do
|
||||
user_a = create_user_with_permission_set("own_data", actor)
|
||||
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user_a, actor)
|
||||
admin = create_admin_user(actor)
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
|
|
@ -150,7 +108,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
|
||||
describe "linked member – own member" do
|
||||
test "own_data user can update email of their own linked member", %{actor: actor} do
|
||||
own_data_user = create_user_with_permission_set("own_data", actor)
|
||||
own_data_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(own_data_user, actor)
|
||||
|
||||
{:ok, own_data_user} =
|
||||
|
|
@ -168,7 +126,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
end
|
||||
|
||||
test "normal_user with linked member can update email of that same member", %{actor: actor} do
|
||||
normal_user = create_user_with_permission_set("normal_user", actor)
|
||||
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
linked_member = create_linked_member_for_user(normal_user, actor)
|
||||
|
||||
{:ok, normal_user} =
|
||||
|
|
@ -188,9 +146,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
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 = create_user_with_permission_set("own_data", actor)
|
||||
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user_a, actor)
|
||||
normal_user_b = create_user_with_permission_set("normal_user", actor)
|
||||
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
|
||||
assert {:ok, updated} =
|
||||
Membership.update_member(linked_member, %{first_name: "UpdatedName"},
|
||||
|
|
@ -202,9 +160,9 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
end
|
||||
|
||||
test "updating email of linked member as admin succeeds", %{actor: actor} do
|
||||
user_a = create_user_with_permission_set("own_data", actor)
|
||||
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user_a, actor)
|
||||
admin = create_admin_user(actor)
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
|
|
@ -217,7 +175,7 @@ defmodule Mv.Membership.MemberEmailValidationTest do
|
|||
|
||||
describe "read_only" do
|
||||
test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
|
||||
read_only_user = create_user_with_permission_set("read_only", actor)
|
||||
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
linked_member = create_linked_member_for_user(read_only_user, actor)
|
||||
|
||||
{:ok, read_only_user} =
|
||||
|
|
|
|||
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.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -21,58 +20,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
# Create role with permission set
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
# Helper to create an admin user (for creating test fixtures)
|
||||
defp create_admin_user(actor) do
|
||||
create_user_with_permission_set("admin", actor)
|
||||
end
|
||||
|
||||
# Helper to create a member linked to a user
|
||||
defp create_linked_member_for_user(user, actor) do
|
||||
admin = create_admin_user(actor)
|
||||
defp create_linked_member_for_user(user, _actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
# Create member
|
||||
# NOTE: We need to ensure the member is actually persisted to the database
|
||||
|
|
@ -105,8 +55,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
# Helper to create an unlinked member (no user relationship)
|
||||
defp create_unlinked_member(actor) do
|
||||
admin = create_admin_user(actor)
|
||||
defp create_unlinked_member(_actor) do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(
|
||||
|
|
@ -123,7 +73,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
|
|
@ -207,7 +157,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
|
|
@ -273,7 +223,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
|
|
@ -330,7 +280,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
describe "admin permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
|
|
@ -397,7 +347,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
# read_only has Member.read scope :all, but the special case ensures
|
||||
# users can ALWAYS read their linked member, even if they had no read permission.
|
||||
# This test verifies the special case works independently of permission sets.
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
|
|
@ -416,7 +366,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
|
||||
# own_data has Member.read scope :linked, but the special case ensures
|
||||
# users can ALWAYS read their linked member regardless of permission set.
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
|
|
@ -437,7 +387,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
} do
|
||||
# Update is NOT handled by special case - it's handled by HasPermission
|
||||
# with :linked scope. own_data has Member.update scope :linked.
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
|
|
@ -453,4 +403,184 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member user link - only admin may set or change user link" do
|
||||
setup %{actor: actor} do
|
||||
normal_user =
|
||||
Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
admin =
|
||||
Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
%{normal_user: normal_user, admin: admin, unlinked_member: unlinked_member}
|
||||
end
|
||||
|
||||
test "normal_user can create member without :user argument", %{normal_user: normal_user} do
|
||||
{:ok, member} =
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "NoLink",
|
||||
last_name: "Member",
|
||||
email: "nolink#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: normal_user
|
||||
)
|
||||
|
||||
assert member.first_name == "NoLink"
|
||||
# Member has_one :user (FK on User side); ensure no user is linked
|
||||
{:ok, member} =
|
||||
Ash.load(member, :user, domain: Mv.Membership, actor: normal_user)
|
||||
|
||||
assert is_nil(member.user)
|
||||
end
|
||||
|
||||
test "normal_user cannot create member with :user argument (forbidden)", %{
|
||||
normal_user: normal_user
|
||||
} do
|
||||
other_user =
|
||||
Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
attrs = %{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com",
|
||||
user: %{id: other_user.id}
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.create_member(attrs, actor: normal_user)
|
||||
end
|
||||
|
||||
test "normal_user can update member without :user argument", %{
|
||||
normal_user: normal_user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
{:ok, updated} =
|
||||
Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"},
|
||||
actor: normal_user
|
||||
)
|
||||
|
||||
assert updated.first_name == "UpdatedByNormal"
|
||||
end
|
||||
|
||||
test "normal_user cannot update member with :user argument (forbidden)", %{
|
||||
normal_user: normal_user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
other_user =
|
||||
Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}}
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.update_member(unlinked_member, params, actor: normal_user)
|
||||
end
|
||||
|
||||
test "normal_user cannot update member with user: nil (unlink forbidden)", %{
|
||||
normal_user: normal_user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
# Link member first (via admin), then normal_user tries to unlink via user: nil
|
||||
admin =
|
||||
Mv.Fixtures.user_with_role_fixture("admin") |> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
link_target =
|
||||
Mv.Fixtures.user_with_role_fixture("own_data") |> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
{:ok, linked_member} =
|
||||
Membership.update_member(
|
||||
unlinked_member,
|
||||
%{user: %{id: link_target.id}},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
# Passing user: nil explicitly tries to unlink; only admin may do that
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.update_member(linked_member, %{user: nil}, actor: normal_user)
|
||||
end
|
||||
|
||||
test "normal_user update linked member without :user keeps link", %{
|
||||
normal_user: normal_user,
|
||||
admin: admin,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
# Admin links member to a user
|
||||
link_target =
|
||||
Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
{:ok, linked_member} =
|
||||
Membership.update_member(
|
||||
unlinked_member,
|
||||
%{user: %{id: link_target.id}},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
# normal_user updates only first_name (no :user) – link must remain (on_missing: :ignore)
|
||||
{:ok, updated} =
|
||||
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: normal_user)
|
||||
|
||||
assert updated.first_name == "Updated"
|
||||
|
||||
{:ok, user} =
|
||||
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
|
||||
|
||||
assert user.member_id == updated.id
|
||||
end
|
||||
|
||||
test "admin can create member with :user argument", %{admin: admin} do
|
||||
link_target =
|
||||
Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
attrs = %{
|
||||
first_name: "AdminLinked",
|
||||
last_name: "Member",
|
||||
email: "adminlinked#{System.unique_integer([:positive])}@example.com",
|
||||
user: %{id: link_target.id}
|
||||
}
|
||||
|
||||
{:ok, member} = Membership.create_member(attrs, actor: admin)
|
||||
|
||||
assert member.first_name == "AdminLinked"
|
||||
|
||||
{:ok, link_target} =
|
||||
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
|
||||
|
||||
assert link_target.member_id == member.id
|
||||
end
|
||||
|
||||
test "admin can update member with :user argument (link)", %{
|
||||
admin: admin,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
link_target =
|
||||
Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
{:ok, updated} =
|
||||
Membership.update_member(
|
||||
unlinked_member,
|
||||
%{user: %{id: link_target.id}},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
assert updated.id == unlinked_member.id
|
||||
|
||||
{:ok, reloaded_user} =
|
||||
Ash.get(Mv.Accounts.User, link_target.id,
|
||||
domain: Mv.Accounts,
|
||||
load: [:member],
|
||||
actor: admin
|
||||
)
|
||||
|
||||
assert reloaded_user.member_id == updated.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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
|
||||
end
|
||||
|
||||
test "non-admin cannot manage roles" do
|
||||
test "non-admin can read roles but cannot create/update/destroy" do
|
||||
normal_user = %{
|
||||
id: "normal-123",
|
||||
role: %{permission_set_name: "normal_user"}
|
||||
}
|
||||
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true
|
||||
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert is_nil(new_user.hashed_password)
|
||||
|
||||
# Verify user can be found by oidc_id
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == new_user.id
|
||||
end
|
||||
end
|
||||
|
|
@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert linked_user.hashed_password == password_user.hashed_password
|
||||
|
||||
# Step 5: User can now sign in via OIDC
|
||||
{:ok, [signed_in_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
signed_in_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert signed_in_user.id == password_user.id
|
||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||
end
|
||||
|
|
@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
# Test sign_in_with_rauthy action directly
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
assert to_string(found_user.email) == "existing@example.com"
|
||||
assert found_user.oidc_id == "existing_oidc_123"
|
||||
|
|
@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: correct_user_info,
|
||||
|
|
@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
|
||||
# Try with wrong oidc_id but correct email
|
||||
|
|
@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
# Use a fixed date in 2024 to ensure 2023 is last completed
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles and fee type (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
|
||||
assert last_cycle == nil
|
||||
|
|
@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
result = MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
original_config = Application.get_env(:mv, :csv_import, [])
|
||||
|
||||
try do
|
||||
# Arrange: Set custom row limit to 500
|
||||
Application.put_env(:mv, :csv_import, max_rows: 500)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Generate CSV with 501 rows (exceeding custom limit of 500)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
|
@ -53,17 +54,17 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
# Act: Upload CSV and submit form
|
||||
upload_csv_file(view, large_csv, "too_many_rows_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Assert: Import should be rejected with error message
|
||||
html = render(view)
|
||||
# Business rule: import should be rejected when exceeding configured limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or
|
||||
html =~ "Failed to prepare"
|
||||
assert html =~ "Failed to prepare CSV import"
|
||||
after
|
||||
# Restore original config
|
||||
Application.put_env(:mv, :csv_import, original_config)
|
||||
|
|
|
|||
|
|
@ -3,22 +3,6 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
import Phoenix.LiveViewTest
|
||||
alias Mv.Membership
|
||||
|
||||
# Helper function to upload CSV file in tests
|
||||
# Reduces code duplication across multiple test cases
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
describe "Global Settings LiveView" do
|
||||
setup %{conn: conn} do
|
||||
user = create_test_user(%{email: "admin@example.com"})
|
||||
|
|
@ -97,595 +81,4 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
assert render(view) =~ "updated" or render(view) =~ "success"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import Section" do
|
||||
test "admin user sees import section", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for import section heading or identifier
|
||||
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
|
||||
end
|
||||
|
||||
test "admin user sees custom fields notice", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for custom fields notice text
|
||||
assert html =~ "Use the data field name"
|
||||
end
|
||||
|
||||
test "admin user sees template download links", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for English template link
|
||||
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
|
||||
|
||||
# Check for German template link
|
||||
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
|
||||
end
|
||||
|
||||
test "template links use static path helper", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that links contain the static path pattern
|
||||
# Static paths typically start with /templates/ or contain the full path
|
||||
assert html =~ "/templates/member_import_en.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
|
||||
|
||||
assert html =~ "/templates/member_import_de.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
|
||||
end
|
||||
|
||||
test "admin user sees file upload input", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for file input element
|
||||
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
|
||||
end
|
||||
|
||||
test "file upload has CSV-only restriction", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for CSV file type restriction in help text or accept attribute
|
||||
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
|
||||
end
|
||||
|
||||
test "non-admin user does not see import section", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Import" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
|
||||
end
|
||||
|
||||
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
# Trigger start_import event via form submit
|
||||
assert view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "non-admin cannot start import", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create invalid CSV (missing required fields)
|
||||
invalid_csv = "invalid_header\nincomplete_row"
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, invalid_csv, "invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message (flash)
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
|
||||
# The error is handled correctly in production, but test framework has limitations
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
empty_csv = " "
|
||||
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
|
||||
File.write!(csv_path, empty_csv)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "empty.csv",
|
||||
content: empty_csv,
|
||||
size: byte_size(empty_csv),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("empty.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 3: Chunk Processing" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content}
|
||||
end
|
||||
|
||||
test "happy path: valid CSV processes all chunks and shows done status", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
# In test mode, chunks are processed synchronously and messages are sent via send/2
|
||||
# render(view) processes handle_info messages, so we call it multiple times
|
||||
# to ensure all messages are processed
|
||||
# Use the same approach as "success rendering" test which works
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or
|
||||
has_element?(view, "[data-testid='import-results-panel']")
|
||||
end
|
||||
|
||||
test "error handling: invalid CSV shows errors with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(500)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count > 0
|
||||
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
|
||||
|
||||
# Should show line numbers in errors (from service, not recalculated)
|
||||
# Line numbers should be 2, 3 (header is line 1)
|
||||
assert html =~ "2" or html =~ "3" or html =~ "line"
|
||||
end
|
||||
|
||||
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 100 invalid rows (all missing email)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
large_invalid_csv = header <> Enum.join(invalid_rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failed count == 100
|
||||
assert html =~ "100" or html =~ "failed"
|
||||
|
||||
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
|
||||
# The important thing is that processing completes without crashing
|
||||
assert html =~ "done" or html =~ "complete" or html =~ "finished"
|
||||
end
|
||||
|
||||
test "chunk scheduling: progress updates show chunk processing", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait a bit for processing to start
|
||||
Process.sleep(200)
|
||||
|
||||
# Check that status area exists (with aria-live for accessibility)
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or
|
||||
html =~ "Processing" or html =~ "chunk"
|
||||
|
||||
# Final state should be :done
|
||||
Process.sleep(500)
|
||||
final_html = render(view)
|
||||
assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 4: Results UI" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read CSV with unknown custom field
|
||||
unknown_custom_field_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content,
|
||||
unknown_custom_field_csv: unknown_custom_field_csv}
|
||||
end
|
||||
|
||||
test "success rendering: valid CSV shows success count", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed"
|
||||
end
|
||||
|
||||
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count
|
||||
assert html =~ "Failed" or html =~ "failed"
|
||||
|
||||
# Should show error list with line numbers (from service, not recalculated)
|
||||
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
|
||||
# Should show error messages
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Errors"
|
||||
end
|
||||
|
||||
test "warning rendering: CSV with unknown custom field shows warnings block", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
csv_path =
|
||||
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
|
||||
|
||||
File.write!(csv_path, csv_content)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "unknown_custom.csv",
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("unknown_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show warnings block (if warnings were generated)
|
||||
# Warnings are generated when unknown custom field columns are detected
|
||||
# Check if warnings section exists OR if import completed successfully
|
||||
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
|
||||
import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results"
|
||||
|
||||
# If warnings exist, they should contain the column name
|
||||
if has_warnings do
|
||||
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
|
||||
html =~ "will be ignored"
|
||||
end
|
||||
|
||||
# Import should complete (either with or without warnings)
|
||||
assert import_completed
|
||||
end
|
||||
|
||||
test "A11y: file input has label", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for label associated with file input
|
||||
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
|
||||
html =~ ~r/<label[^>]*>.*CSV File/i
|
||||
end
|
||||
|
||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
# Check for aria-live attribute in status area
|
||||
assert html =~ ~r/aria-live=["']polite["']/i
|
||||
end
|
||||
|
||||
test "A11y: links have descriptive text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that links have descriptive text (not just "click here")
|
||||
# Template links should have text like "English Template" or "German Template"
|
||||
assert html =~ "English Template" or html =~ "German Template" or
|
||||
html =~ "English" or html =~ "German"
|
||||
|
||||
# Custom Fields section should have descriptive text (Data Field button)
|
||||
# The component uses "New Data Field" button, not a link
|
||||
assert html =~ "Data Field" or html =~ "New Data Field"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 5: Edge Cases" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Read CSV with BOM
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should succeed (BOM is stripped automatically)
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Inserted"
|
||||
# Should not show error about BOM
|
||||
refute html =~ "BOM" or html =~ "encoding"
|
||||
end
|
||||
|
||||
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show error with correct line number (line 4, not line 3)
|
||||
# The error should be on the line with invalid email, which is after the empty line
|
||||
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
|
||||
# Should show error message
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
|
||||
end
|
||||
|
||||
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 1001 rows dynamically
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_csv, "too_many_rows.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
# Should show user-friendly error about row limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
|
||||
html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create .txt file (not .csv)
|
||||
txt_content = "This is not a CSV file\nJust some text\n"
|
||||
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
|
||||
File.write!(txt_path, txt_content)
|
||||
|
||||
# Try to upload .txt file
|
||||
# Note: allow_upload is configured to accept only .csv, so this should fail
|
||||
# In tests, we can't easily simulate file type rejection, but we can check
|
||||
# that the UI shows appropriate help text
|
||||
html = render(view)
|
||||
# Should show CSV-only restriction in help text
|
||||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
||||
end
|
||||
|
||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that file input has accept attribute for CSV
|
||||
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
669
test/mv_web/live/import_export_live_test.exs
Normal file
669
test/mv_web/live/import_export_live_test.exs
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
defmodule MvWeb.ImportExportLiveTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
# Helper function to upload CSV file in tests
|
||||
# Reduces code duplication across multiple test cases
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
describe "Import/Export LiveView" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "renders the import/export page", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
assert html =~ "Import/Export"
|
||||
end
|
||||
|
||||
test "displays import section for admin user", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
assert html =~ "Import Members (CSV)"
|
||||
end
|
||||
|
||||
test "displays export section placeholder", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
assert html =~ "Export Members (CSV)" or html =~ "Export"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import Section" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "admin user sees import section", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Check for import section heading or identifier
|
||||
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
|
||||
end
|
||||
|
||||
test "admin user sees custom fields notice", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Check for custom fields notice text
|
||||
assert html =~ "Use the data field name"
|
||||
end
|
||||
|
||||
test "admin user sees template download links", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for English template link
|
||||
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
|
||||
|
||||
# Check for German template link
|
||||
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
|
||||
end
|
||||
|
||||
test "template links use static path helper", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that links contain the static path pattern
|
||||
# Static paths typically start with /templates/ or contain the full path
|
||||
assert html =~ "/templates/member_import_en.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
|
||||
|
||||
assert html =~ "/templates/member_import_de.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
|
||||
end
|
||||
|
||||
test "admin user sees file upload input", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for file input element
|
||||
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
|
||||
end
|
||||
|
||||
test "file upload has CSV-only restriction", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for CSV file type restriction in help text or accept attribute
|
||||
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
|
||||
end
|
||||
|
||||
test "non-admin user sees permission error", %{conn: conn} do
|
||||
# Member (own_data) user
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
# Router plug redirects non-admin users before LiveView loads
|
||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
|
||||
live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Should redirect to user profile page
|
||||
assert redirect_path =~ "/users/"
|
||||
# Should show permission error in flash
|
||||
assert error_message =~ "don't have permission"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Import" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
|
||||
end
|
||||
|
||||
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
# Trigger start_import event via form submit
|
||||
assert view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started using data-testid
|
||||
# Either import-progress-container exists (import started) OR we see a CSV error
|
||||
html = render(view)
|
||||
import_started = has_element?(view, "[data-testid='import-progress-container']")
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started - check for progress container
|
||||
assert import_started
|
||||
end
|
||||
end
|
||||
|
||||
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started using data-testid
|
||||
html = render(view)
|
||||
import_started = has_element?(view, "[data-testid='import-progress-container']")
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started - check for progress container
|
||||
assert import_started
|
||||
end
|
||||
end
|
||||
|
||||
test "non-admin cannot start import", %{conn: conn} do
|
||||
# Member (own_data) user
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
# Router plug redirects non-admin users before LiveView loads
|
||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
|
||||
live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Should redirect to user profile page
|
||||
assert redirect_path =~ "/users/"
|
||||
# Should show permission error in flash
|
||||
assert error_message =~ "don't have permission"
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Create invalid CSV (missing required fields)
|
||||
invalid_csv = "invalid_header\nincomplete_row"
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, invalid_csv, "invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message (flash)
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
|
||||
# The error is handled correctly in production, but test framework has limitations
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
empty_csv = " "
|
||||
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
|
||||
File.write!(csv_path, empty_csv)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "empty.csv",
|
||||
content: empty_csv,
|
||||
size: byte_size(empty_csv),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("empty.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 3: Chunk Processing" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content}
|
||||
end
|
||||
|
||||
test "happy path: valid CSV processes all chunks and shows done status", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
# In test mode, chunks are processed synchronously and messages are sent via send/2
|
||||
# render(view) processes handle_info messages, so we call it multiple times
|
||||
# to ensure all messages are processed
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# Verify success count is shown
|
||||
html = render(view)
|
||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
||||
end
|
||||
|
||||
test "error handling: invalid CSV shows errors with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed with errors)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# Check that error list exists
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count > 0
|
||||
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
|
||||
|
||||
# Should show line numbers in errors (from service, not recalculated)
|
||||
# Line numbers should be 2, 3 (header is line 1)
|
||||
assert html =~ "2" or html =~ "3" or html =~ "line"
|
||||
end
|
||||
|
||||
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Generate CSV with 100 invalid rows (all missing email)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
large_invalid_csv = header <> Enum.join(invalid_rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
html = render(view)
|
||||
# Should show failed count == 100
|
||||
assert html =~ "100" or html =~ "failed"
|
||||
|
||||
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
|
||||
# The important thing is that processing completes without crashing
|
||||
# Import is done when import-results-panel exists
|
||||
end
|
||||
|
||||
test "chunk scheduling: progress updates show chunk processing", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# In test mode chunks run synchronously, so we may already be :done when we check.
|
||||
# Accept either progress container (if we caught :running) or results panel (if already :done).
|
||||
_html = render(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-progress-container']") or
|
||||
has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# Wait for final state and assert results panel is shown
|
||||
Process.sleep(500)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 4: Results UI" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read CSV with unknown custom field
|
||||
unknown_custom_field_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content,
|
||||
unknown_custom_field_csv: unknown_custom_field_csv}
|
||||
end
|
||||
|
||||
test "success rendering: valid CSV shows success count", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# Verify success count is shown
|
||||
html = render(view)
|
||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
||||
end
|
||||
|
||||
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed with errors)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# Check that error list exists
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count
|
||||
assert html =~ "Failed" or html =~ "failed"
|
||||
|
||||
# Should show error list with line numbers (from service, not recalculated)
|
||||
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
|
||||
end
|
||||
|
||||
test "warning rendering: CSV with unknown custom field shows warnings block", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
csv_path =
|
||||
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
|
||||
|
||||
File.write!(csv_path, csv_content)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "unknown_custom.csv",
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("unknown_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
html = render(view)
|
||||
# Should show warnings block (if warnings were generated)
|
||||
# Warnings are generated when unknown custom field columns are detected
|
||||
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
|
||||
|
||||
# If warnings exist, they should contain the column name
|
||||
if has_warnings do
|
||||
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
|
||||
html =~ "will be ignored"
|
||||
end
|
||||
|
||||
# Import should complete (either with or without warnings)
|
||||
# Verified by import-results-panel existence above
|
||||
end
|
||||
|
||||
test "A11y: file input has label", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Check for label associated with file input
|
||||
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
|
||||
html =~ ~r/<label[^>]*>.*CSV File/i
|
||||
end
|
||||
|
||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
html = render(view)
|
||||
# Check for aria-live attribute in status area
|
||||
assert html =~ ~r/aria-live=["']polite["']/i
|
||||
end
|
||||
|
||||
test "A11y: links have descriptive text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Check that links have descriptive text (not just "click here")
|
||||
# Template links should have text like "English Template" or "German Template"
|
||||
assert html =~ "English Template" or html =~ "German Template" or
|
||||
html =~ "English" or html =~ "German"
|
||||
|
||||
# Import page has link "Manage Member Data" and info text about "data field"
|
||||
assert html =~ "Manage Member Data" or html =~ "data field" or html =~ "Data field"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 5: Edge Cases" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Read CSV with BOM
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
# Check that import-results-panel exists (import completed successfully)
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
html = render(view)
|
||||
# Should succeed (BOM is stripped automatically)
|
||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
||||
# Should not show error about BOM
|
||||
refute html =~ "BOM" or html =~ "encoding"
|
||||
end
|
||||
|
||||
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show error with correct line number (line 4, not line 3)
|
||||
# The error should be on the line with invalid email, which is after the empty line
|
||||
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
|
||||
# Should show error message
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
|
||||
end
|
||||
|
||||
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Generate CSV with 1001 rows dynamically
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_csv, "too_many_rows.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
# Should show user-friendly error about row limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
|
||||
html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Create .txt file (not .csv)
|
||||
txt_content = "This is not a CSV file\nJust some text\n"
|
||||
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
|
||||
File.write!(txt_path, txt_content)
|
||||
|
||||
# Try to upload .txt file
|
||||
# Note: allow_upload is configured to accept only .csv, so this should fail
|
||||
# In tests, we can't easily simulate file type rejection, but we can check
|
||||
# that the UI shows appropriate help text
|
||||
html = render(view)
|
||||
# Should show CSV-only restriction in help text
|
||||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
||||
end
|
||||
|
||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Check that file input has accept attribute for CSV
|
||||
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
end
|
||||
|
||||
describe "create form" do
|
||||
test "creates new membership fee type", %{conn: conn} do
|
||||
test "creates new membership fee type", %{conn: conn, user: user} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
form_data = %{
|
||||
|
|
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|
||||
assert to == "/membership_fee_types"
|
||||
|
||||
# Verify type was created
|
||||
# Verify type was created (use actor so read is authorized)
|
||||
type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(name == "New Type")
|
||||
|> Ash.read_one!()
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert type != nil, "Expected membership fee type to be created"
|
||||
assert type.amount == Decimal.new("75.00")
|
||||
assert type.interval == :yearly
|
||||
end
|
||||
|
|
@ -140,7 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "amount change can be confirmed", %{conn: conn} do
|
||||
test "amount change can be confirmed", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -159,12 +160,17 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_submit()
|
||||
|
||||
# Amount should be updated
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should be updated (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("75.00")
|
||||
end
|
||||
|
||||
test "amount change can be cancelled", %{conn: conn} do
|
||||
test "amount change can be cancelled", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -178,8 +184,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> element("button[phx-click='cancel_amount_change']")
|
||||
|> render_click()
|
||||
|
||||
# Amount should remain unchanged
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should remain unchanged (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("50.00")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
end
|
||||
|
||||
@tag :skip
|
||||
# credo:disable-for-next-line Credo.Check.Design.TagTODO
|
||||
# TODO: Implement user initials in navbar avatar - see issue #170
|
||||
test "shows user initials in avatar", %{conn: conn} do
|
||||
# Setup: Create and login a user
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
|
||||
end
|
||||
|
||||
test "updates role name", %{conn: conn, role: role} do
|
||||
test "updates role name", %{conn: conn, role: role, actor: actor} do
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
|
||||
|
||||
attrs = %{
|
||||
|
|
@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(role.id, actor: actor)
|
||||
assert updated_role.name == "Updated Role Name"
|
||||
end
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{system_role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
assert updated_role.permission_set_name == "read_only"
|
||||
end
|
||||
end
|
||||
|
|
@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
end
|
||||
|
||||
@tag :slow
|
||||
test "deletes non-system role", %{conn: conn} do
|
||||
test "deletes non-system role", %{conn: conn, actor: actor} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, view, html} = live(conn, "/admin/roles")
|
||||
|
|
@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
# Verify deletion by checking database
|
||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
||||
Authorization.get_role(role.id)
|
||||
Authorization.get_role(role.id, actor: actor)
|
||||
end
|
||||
|
||||
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
|
||||
|
|
@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert render(view) =~ "System roles cannot be deleted"
|
||||
|
||||
# Role should still exist
|
||||
{:ok, _role} = Authorization.get_role(system_role.id)
|
||||
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -127,10 +127,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
# Use fixed date in 2024 to ensure 2023 is last completed
|
||||
# We need to manually set the date for the helper function
|
||||
|
|
@ -183,8 +185,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
|
||||
|
|
@ -222,8 +224,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles and fee type first (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
|
||||
|
|
@ -273,12 +275,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
|
||||
|
|
@ -300,12 +304,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
|
||||
|
|
@ -327,12 +333,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
|
||||
|
|
@ -354,12 +362,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
|
||||
|
|
@ -373,12 +383,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
# filter_unpaid_members should still work for backwards compatibility
|
||||
|
|
|
|||
|
|
@ -28,21 +28,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -73,7 +58,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "cycles table display" do
|
||||
test "displays all cycles for member", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
|
@ -95,7 +80,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "table columns show correct data", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
|
|
@ -124,7 +109,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
|
||||
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -132,20 +117,30 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
assert html =~ "Yearly Type"
|
||||
end
|
||||
|
||||
test "shows no type message when no type assigned", %{conn: conn} do
|
||||
member = create_member(%{})
|
||||
test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{
|
||||
conn: conn
|
||||
} do
|
||||
member = Mv.Fixtures.member_fixture(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
{:ok, view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Should show message about no type assigned
|
||||
assert html =~ "No membership fee type assigned" || html =~ "No type"
|
||||
|
||||
# Switch to membership fees tab: message and no Regenerate Cycles button
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "button[phx-click='regenerate_cycles']"),
|
||||
"Regenerate Cycles should be hidden when no membership fee type is assigned"
|
||||
end
|
||||
end
|
||||
|
||||
describe "status change actions" do
|
||||
test "mark as paid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
|
|
@ -176,7 +171,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "mark as suspended works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
|
|
@ -207,7 +202,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "mark as unpaid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
|
|
@ -240,7 +235,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "cycle regeneration" do
|
||||
test "manual regeneration button exists and can be clicked", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -266,7 +261,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "edge cases" do
|
||||
test "handles members without membership fee type gracefully", %{conn: conn} do
|
||||
# No fee type
|
||||
member = create_member(%{})
|
||||
member = Mv.Fixtures.member_fixture(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -274,4 +269,120 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
assert html =~ member.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Regenerate Cycles, Delete All Cycles, or Create Cycle buttons",
|
||||
%{
|
||||
conn: conn
|
||||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "button[phx-click='regenerate_cycles']")
|
||||
refute has_element?(view, "button[phx-click='delete_all_cycles']")
|
||||
refute has_element?(view, "button[phx-click='open_create_cycle_modal']")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Paid, Unpaid, Suspended, or Delete buttons in cycles table", %{
|
||||
conn: conn
|
||||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Row action buttons must not be present for read_only
|
||||
refute has_element?(view, "button[phx-click='mark_cycle_status']")
|
||||
refute has_element?(view, "button[phx-click='delete_cycle']")
|
||||
# Sanity: cycle row is present (read is allowed)
|
||||
assert has_element?(view, "tr[id='cycle-#{cycle.id}']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only cannot delete all cycles (policy enforced via Ash.destroy)" do
|
||||
@tag role: :read_only
|
||||
test "Ash.destroy returns Forbidden for read_only so handler would reject", %{
|
||||
current_user: read_only_user
|
||||
} do
|
||||
# The handler uses Ash.destroy per cycle, so if the handler were triggered
|
||||
# (e.g. via dev tools), the server would enforce policy and show an error.
|
||||
# This test verifies that Ash.destroy(cycle, actor: read_only_user) returns Forbidden.
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only cannot trigger regenerate_cycles (handler enforces can?)" do
|
||||
@tag role: :read_only
|
||||
test "read_only cannot create MembershipFeeCycle so regenerate_cycles handler would show flash error",
|
||||
%{current_user: read_only_user} do
|
||||
# The regenerate_cycles handler checks can?(actor, :create, MembershipFeeCycle) before
|
||||
# calling the generator. If a read_only user triggered the event (e.g. via DevTools),
|
||||
# the handler returns flash error and no new cycles are created.
|
||||
# This test verifies the condition the handler uses.
|
||||
refute MvWeb.Authorization.can?(read_only_user, :create, MembershipFeeCycle),
|
||||
"read_only must not be allowed to create MembershipFeeCycle so handler rejects regenerate_cycles"
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirm_delete_all_cycles handler (policy enforced)" do
|
||||
@tag role: :admin
|
||||
test "admin can delete all cycles via UI and cycles are removed", %{conn: conn} do
|
||||
# Use English locale so confirmation "Yes" matches gettext("Yes")
|
||||
conn = put_session(conn, :locale, "en")
|
||||
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_c1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_c2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='delete_all_cycles']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("input[phx-keyup='update_delete_all_confirmation']")
|
||||
|> render_keyup(%{"value" => "Yes"})
|
||||
|
||||
view
|
||||
|> element("button[phx-click='confirm_delete_all_cycles']")
|
||||
|> render_click()
|
||||
|
||||
_html = render(view)
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
remaining =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: system_actor)
|
||||
|
||||
assert remaining == [],
|
||||
"Expected all cycles to be deleted (handler enforces policy via Ash.destroy)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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