Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
This commit is contained in:
commit
03f27a5938
33 changed files with 2765 additions and 501 deletions
|
|
@ -51,6 +51,13 @@ config :mv,
|
||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||||
|
|
||||||
|
# CSV Import configuration
|
||||||
|
config :mv,
|
||||||
|
csv_import: [
|
||||||
|
max_file_size_mb: 10,
|
||||||
|
max_rows: 1000
|
||||||
|
]
|
||||||
|
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
url: [host: "localhost"],
|
url: [host: "localhost"],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Version:** 1.0
|
**Version:** 1.0
|
||||||
**Last Updated:** 2026-01-13
|
**Last Updated:** 2026-01-13
|
||||||
**Status:** In Progress (Backend Complete, UI Pending)
|
**Status:** In Progress (Backend Complete, UI Complete, Tests Pending)
|
||||||
**Related Documents:**
|
**Related Documents:**
|
||||||
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
|
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
|
||||||
|
|
||||||
|
|
@ -15,15 +15,15 @@
|
||||||
- ✅ Issue #4: Header Normalization + Per-Header Mapping
|
- ✅ Issue #4: Header Normalization + Per-Header Mapping
|
||||||
- ✅ Issue #5: Validation (Required Fields) + Error Formatting
|
- ✅ Issue #5: Validation (Required Fields) + Error Formatting
|
||||||
- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping)
|
- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping)
|
||||||
- ✅ Issue #11: Custom Field Import (Backend)
|
- ✅ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
|
||||||
|
- ✅ Issue #8: Authorization + Limits
|
||||||
|
- ✅ Issue #11: Custom Field Import (Backend + UI)
|
||||||
|
|
||||||
**In Progress / Pending:**
|
**In Progress / Pending:**
|
||||||
- ⏳ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results)
|
|
||||||
- ⏳ Issue #8: Authorization + Limits
|
|
||||||
- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures
|
- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures
|
||||||
- ⏳ Issue #10: Documentation Polish
|
- ⏳ Issue #10: Documentation Polish
|
||||||
|
|
||||||
**Latest Update:** Error-Capping in `process_chunk/4` implemented (2025-01-XX)
|
**Latest Update:** CSV Import UI fully implemented in GlobalSettingsLive with chunk processing, progress tracking, error display, and custom field support (2026-01-13)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -161,6 +161,13 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
||||||
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
|
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
|
||||||
- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields).
|
- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields).
|
||||||
- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import.
|
- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import.
|
||||||
|
- **Value Validation:** Custom field values are validated according to the custom field type:
|
||||||
|
- **string**: Any text value (trimmed)
|
||||||
|
- **integer**: Must be a valid integer (e.g., `42`, `-10`). Invalid values will cause a row error with the custom field name and reason.
|
||||||
|
- **boolean**: Accepts `true`, `false`, `1`, `0`, `yes`, `no`, `ja`, `nein` (case-insensitive). Invalid values will cause a row error.
|
||||||
|
- **date**: Must be in ISO-8601 format (YYYY-MM-DD, e.g., `2024-01-15`). Invalid values will cause a row error.
|
||||||
|
- **email**: Must be a valid email format (contains `@`, 5-254 characters, valid format). Invalid values will cause a row error.
|
||||||
|
- **Error Messages:** Custom field validation errors are included in the import error list with format: `custom_field: <name> – <reason>` (e.g., `custom_field: Alter – expected integer, got: abc`)
|
||||||
|
|
||||||
**Member Field Header Mapping:**
|
**Member Field Header Mapping:**
|
||||||
|
|
||||||
|
|
@ -496,36 +503,51 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
|
||||||
|
|
||||||
**Dependencies:** Issue #6
|
**Dependencies:** Issue #6
|
||||||
|
|
||||||
|
**Status:** ✅ **COMPLETED**
|
||||||
|
|
||||||
**Goal:** UI section with upload, progress, results, and template links.
|
**Goal:** UI section with upload, progress, results, and template links.
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
- [ ] Render import section only for admins
|
- [x] Render import section only for admins
|
||||||
- [ ] **Add prominent UI notice about custom fields:**
|
- [x] **Add prominent UI notice about custom fields:**
|
||||||
- Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
- Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
||||||
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
||||||
- Add link to custom fields management section
|
- Add link to custom fields management section
|
||||||
- [ ] Configure `allow_upload/3`:
|
- [x] Configure `allow_upload/3`:
|
||||||
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: false`
|
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: true` (auto-upload enabled for better UX)
|
||||||
- [ ] `handle_event("start_import", ...)`:
|
- [x] `handle_event("start_import", ...)`:
|
||||||
- Admin permission check
|
- Admin permission check
|
||||||
- Consume upload -> read file content
|
- Consume upload -> read file content
|
||||||
- Call `MemberCSV.prepare/2`
|
- Call `MemberCSV.prepare/2`
|
||||||
- Store `import_state` in assigns (chunks + column_map + metadata)
|
- Store `import_state` in assigns (chunks + column_map + metadata)
|
||||||
- Initialize progress assigns
|
- Initialize progress assigns
|
||||||
- `send(self(), {:process_chunk, 0})`
|
- `send(self(), {:process_chunk, 0})`
|
||||||
- [ ] `handle_info({:process_chunk, idx}, socket)`:
|
- [x] `handle_info({:process_chunk, idx}, socket)`:
|
||||||
- Fetch chunk from `import_state`
|
- Fetch chunk from `import_state`
|
||||||
- Call `MemberCSV.process_chunk/3`
|
- Call `MemberCSV.process_chunk/4` with error capping support
|
||||||
- Merge counts/errors into progress assigns (cap errors at 50 overall)
|
- Merge counts/errors into progress assigns (cap errors at 50 overall)
|
||||||
- Schedule next chunk (or finish and show results)
|
- Schedule next chunk (or finish and show results)
|
||||||
- [ ] Results UI:
|
- Async task processing with SQL sandbox support for tests
|
||||||
|
- [x] Results UI:
|
||||||
- Success count
|
- Success count
|
||||||
- Failure count
|
- Failure count
|
||||||
- Error list (line number + message + field)
|
- Error list (line number + message + field)
|
||||||
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
|
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
|
||||||
|
- Progress indicator during import
|
||||||
|
- Error truncation notice when errors exceed limit
|
||||||
|
|
||||||
**Template links:**
|
**Template links:**
|
||||||
- Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
|
- [x] Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
- [x] Upload area with drag & drop support
|
||||||
|
- [x] Template download links (EN/DE)
|
||||||
|
- [x] Progress tracking during import
|
||||||
|
- [x] Results display with success/error counts
|
||||||
|
- [x] Error list with line numbers and field information
|
||||||
|
- [x] Warning display for unknown custom field columns
|
||||||
|
- [x] Admin-only access control
|
||||||
|
- [x] Async chunk processing with proper error handling
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -533,19 +555,32 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
|
||||||
|
|
||||||
**Dependencies:** None (can be parallelized)
|
**Dependencies:** None (can be parallelized)
|
||||||
|
|
||||||
|
**Status:** ✅ **COMPLETED**
|
||||||
|
|
||||||
**Goal:** Ensure admin-only access and enforce limits.
|
**Goal:** Ensure admin-only access and enforce limits.
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
- [ ] Admin check in start import event handler
|
- [x] Admin check in start import event handler (via `Authorization.can?/3`)
|
||||||
- [ ] File size enforced in upload config
|
- [x] File size enforced in upload config (`max_file_size: 10MB`)
|
||||||
- [ ] Row limit enforced in `MemberCSV.prepare/2` (max_rows from config)
|
- [x] Row limit enforced in `MemberCSV.prepare/2` (max_rows: 1000, configurable via opts)
|
||||||
- [ ] Configuration:
|
- [x] Chunk size limit (200 rows per chunk)
|
||||||
```elixir
|
- [x] Error limit (50 errors per import)
|
||||||
config :mv, csv_import: [
|
- [x] UI-level authorization check (import section only visible to admins)
|
||||||
max_file_size_mb: 10,
|
- [x] Event-level authorization check (prevents unauthorized import attempts)
|
||||||
max_rows: 1000
|
|
||||||
]
|
**Implementation Notes:**
|
||||||
```
|
- File size limit: 10 MB (10,485,760 bytes) enforced via `allow_upload/3`
|
||||||
|
- Row limit: 1,000 rows (excluding header) enforced in `MemberCSV.prepare/2`
|
||||||
|
- Chunk size: 200 rows per chunk (configurable via opts)
|
||||||
|
- Error limit: 50 errors per import (configurable via `@max_errors`)
|
||||||
|
- Authorization uses `MvWeb.Authorization.can?/3` with `:create` permission on `Mv.Membership.Member`
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
- [x] Admin-only access enforced at UI and event level
|
||||||
|
- [x] File size limit enforced
|
||||||
|
- [x] Row count limit enforced
|
||||||
|
- [x] Chunk processing with size limits
|
||||||
|
- [x] Error capping implemented
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -589,7 +624,7 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
|
||||||
|
|
||||||
**Priority:** High (Core v1 Feature)
|
**Priority:** High (Core v1 Feature)
|
||||||
|
|
||||||
**Status:** ✅ **COMPLETED** (Backend Implementation)
|
**Status:** ✅ **COMPLETED** (Backend + UI Implementation)
|
||||||
|
|
||||||
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
|
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
|
||||||
|
|
||||||
|
|
@ -604,23 +639,26 @@ Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string c
|
||||||
- [x] Query existing custom fields during `prepare/2` to map custom field columns
|
- [x] Query existing custom fields during `prepare/2` to map custom field columns
|
||||||
- [x] Collect unknown custom field columns and add warning messages (don't fail import)
|
- [x] Collect unknown custom field columns and add warning messages (don't fail import)
|
||||||
- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4`
|
- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4`
|
||||||
- [x] Handle custom field type validation (string, integer, boolean, date, email)
|
- [x] Handle custom field type validation (string, integer, boolean, date, email) with proper error messages
|
||||||
- [x] Create `CustomFieldValue` records linked to members during import
|
- [x] Create `CustomFieldValue` records linked to members during import
|
||||||
- [ ] Update error messages to include custom field validation errors (if needed)
|
- [x] Validate custom field values and return structured errors with custom field name and reason
|
||||||
- [ ] Add UI help text explaining custom field requirements (pending Issue #7):
|
- [x] UI help text and link to custom field management (implemented in Issue #7)
|
||||||
|
- [x] Update error messages to include custom field validation errors (format: `custom_field: <name> – expected <type>, got: <value>`)
|
||||||
|
- [x] Add UI help text explaining custom field requirements (completed in Issue #7):
|
||||||
- "Custom fields must be created in Mila before importing"
|
- "Custom fields must be created in Mila before importing"
|
||||||
- "Use the custom field name as the CSV column header (same normalization as member fields)"
|
- "Use the custom field name as the CSV column header (same normalization as member fields)"
|
||||||
- Link to custom fields management section
|
- Link to custom fields management section
|
||||||
- [ ] Update CSV templates documentation to explain custom field columns (pending Issue #1)
|
- [x] Update CSV templates documentation to explain custom field columns (documented in Issue #1)
|
||||||
- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
|
- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
|
||||||
|
|
||||||
**Definition of Done:**
|
**Definition of Done:**
|
||||||
- [x] Custom field columns are recognized by name (with normalization)
|
- [x] Custom field columns are recognized by name (with normalization)
|
||||||
- [x] Warning messages shown for unknown custom field columns (import continues)
|
- [x] Warning messages shown for unknown custom field columns (import continues)
|
||||||
- [x] Custom field values are created and linked to members
|
- [x] Custom field values are created and linked to members
|
||||||
- [x] Type validation works for all custom field types
|
- [x] Type validation works for all custom field types (string, integer, boolean, date, email)
|
||||||
- [ ] UI clearly explains custom field requirements (pending Issue #7)
|
- [x] UI clearly explains custom field requirements (completed in Issue #7)
|
||||||
- [x] Tests cover custom field import scenarios (including warning for unknown names)
|
- [x] Tests cover custom field import scenarios (including warning for unknown names)
|
||||||
|
- [x] Error messages include custom field validation errors with proper formatting
|
||||||
|
|
||||||
**Implementation Notes:**
|
**Implementation Notes:**
|
||||||
- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts
|
- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts
|
||||||
|
|
|
||||||
92
docs/page-permission-route-coverage.md
Normal file
92
docs/page-permission-route-coverage.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Page Permission – Route and Test Coverage
|
||||||
|
|
||||||
|
This document lists all protected routes, which permission set may access them, and how they are covered by tests.
|
||||||
|
|
||||||
|
## Protected Routes (Router scope with CheckPagePermission in :browser)
|
||||||
|
|
||||||
|
| Route | own_data | read_only | normal_user | admin |
|
||||||
|
|-------|----------|-----------|-------------|-------|
|
||||||
|
| `/` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/members` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/members/new` | ✗ | ✗ | ✓ | ✓ |
|
||||||
|
| `/members/:id` | ✓ (linked only) | ✓ | ✓ | ✓ |
|
||||||
|
| `/members/:id/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||||
|
| `/members/:id/show/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||||
|
| `/users` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/users/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/users/:id` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/users/:id/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/settings` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/groups` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
|
||||||
|
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
|
||||||
|
|
||||||
|
## Public Paths (no permission check)
|
||||||
|
|
||||||
|
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
||||||
|
|
||||||
|
### Unit tests (plug called directly with mock conn)
|
||||||
|
|
||||||
|
- Static: own_data denied `/members`; read_only allowed `/members`; flash on denial.
|
||||||
|
- Dynamic: read_only allowed `/members/123`; normal_user allowed `/members/456/edit`; read_only denied `/members/123/edit`.
|
||||||
|
- read_only / normal_user: denied `/admin/roles`; read_only denied `/members/new`.
|
||||||
|
- Wildcard: admin allowed `/admin/roles`, `/members/999/edit`.
|
||||||
|
- Unauthenticated: nil user denied, redirect `/sign-in`.
|
||||||
|
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
|
||||||
|
- Error: no role, invalid permission_set_name → denied.
|
||||||
|
|
||||||
|
### Integration tests (full router, Mitglied = own_data)
|
||||||
|
|
||||||
|
**Denied (Mitglied gets 302 → `/users/:id`):**
|
||||||
|
|
||||||
|
- `/members`, `/members/new`, `/users`, `/users/new`, `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/membership_fee_types/new`, `/groups`, `/groups/new`, `/admin/roles`, `/admin/roles/new`
|
||||||
|
- `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (other user), `/users/:id/edit` (other), `/users/:id/show/edit` (other), `/membership_fee_types/:id/edit`, `/groups/:slug`, `/admin/roles/:id`, `/admin/roles/:id/edit`
|
||||||
|
|
||||||
|
**Allowed (Mitglied gets 200):**
|
||||||
|
|
||||||
|
- `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`
|
||||||
|
- `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` for linked member (plug unit tests; full-router tests for linked member skipped: session/LiveView constraints)
|
||||||
|
|
||||||
|
**Root:** `GET /` redirects Mitglied to profile (root not allowed for own_data).
|
||||||
|
|
||||||
|
All protected routes above are either covered by integration “denied” tests for Mitglied or by unit tests for the relevant permission set.
|
||||||
|
|
||||||
|
### Integration tests (full router, read_only = Vorstand/Buchhaltung)
|
||||||
|
|
||||||
|
**Allowed (200):** `/`, `/members`, `/members/:id`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
|
||||||
|
|
||||||
|
**Denied (302 → `/users/:id`):** `/members/new`, `/members/:id/edit`, `/members/:id/show/edit`, `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
|
||||||
|
|
||||||
|
### Integration tests (full router, normal_user = Kassenwart)
|
||||||
|
|
||||||
|
**Allowed (200):** `/`, `/members`, `/members/new`, `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
|
||||||
|
|
||||||
|
**Denied (302 → `/users/:id`):** `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
|
||||||
|
|
||||||
|
### Integration tests (full router, admin)
|
||||||
|
|
||||||
|
**Allowed (200):** All protected routes (sample covered: `/`, `/members`, `/users`, `/settings`, `/membership_fee_settings`, `/admin/roles`, `/members/:id`, `/admin/roles/:id`, `/groups/:slug`).
|
||||||
|
|
||||||
|
## Plug behaviour: reserved segments
|
||||||
|
|
||||||
|
The plug treats `"new"` as a reserved path segment so that patterns like `/members/:id` and `/groups/:slug` do not match `/members/new` or `/groups/new`. Thus `/groups/new` is only allowed when the permission set explicitly lists `/groups/new` (currently only admin).
|
||||||
|
|
||||||
|
## Role and member_id loading
|
||||||
|
|
||||||
|
The plug may reload the user's role (and optionally `member_id`) before checking page permission. Session/`load_from_session` can leave the role unloaded; the plug uses `Mv.Authorization.Actor.ensure_loaded/1` (and, when needed, loads `member_id`) so that permission checks always have the required data. No change to session loading is required; this is documented for clarity.
|
||||||
|
|
@ -2002,6 +2002,8 @@ Users and Members are separate entities that can be linked. Special rules:
|
||||||
- A user cannot link themselves to an existing member
|
- A user cannot link themselves to an existing member
|
||||||
- A user CAN create a new member and be directly linked to it (self-service)
|
- A user CAN create a new member and be directly linked to it (self-service)
|
||||||
|
|
||||||
|
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
||||||
|
|
||||||
### Approach: Separate Ash Actions
|
### Approach: Separate Ash Actions
|
||||||
|
|
||||||
We use **different Ash actions** to enforce different policies:
|
We use **different Ash actions** to enforce different policies:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ defmodule Mv.Accounts.User do
|
||||||
# the specialized :update_user action below.
|
# the specialized :update_user action below.
|
||||||
update :update do
|
update :update do
|
||||||
primary? true
|
primary? true
|
||||||
|
accept [:email]
|
||||||
|
|
||||||
# Required because custom validation functions (email validation, member relationship validation)
|
# Required because custom validation functions (email validation, member relationship validation)
|
||||||
# cannot be executed atomically. These validations need to query the database and perform
|
# cannot be executed atomically. These validations need to query the database and perform
|
||||||
|
|
@ -310,6 +311,14 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
|
||||||
|
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
|
||||||
|
policy action(:update_user) do
|
||||||
|
description "Only admins can update user with member link/unlink"
|
||||||
|
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
|
||||||
|
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||||
|
end
|
||||||
|
|
||||||
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role and permission set"
|
description "Check permissions from user's role and permission set"
|
||||||
|
|
|
||||||
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check: true when the actor's role has permission_set_name "admin".
|
||||||
|
|
||||||
|
Used to restrict actions (e.g. User.update_user for member link/unlink) to admins only.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts), do: "actor has admin permission set"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def match?(nil, _context, _opts), do: false
|
||||||
|
|
||||||
|
def match?(actor, _context, _opts) do
|
||||||
|
ps_name =
|
||||||
|
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
|
||||||
|
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
|
||||||
|
|
||||||
|
ps_name == "admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -118,12 +118,15 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
# Home page
|
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||||
"/",
|
# Own profile (sidebar links to /users/:id) and own user edit
|
||||||
# Own profile
|
"/users/:id",
|
||||||
"/profile",
|
"/users/:id/edit",
|
||||||
# Linked member detail (filtered by policy)
|
"/users/:id/show/edit",
|
||||||
"/members/:id"
|
# Linked member detail and edit (data access filtered by policy scope: :linked)
|
||||||
|
"/members/:id",
|
||||||
|
"/members/:id/edit",
|
||||||
|
"/members/:id/show/edit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -151,8 +154,10 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
"/profile",
|
"/users/:id",
|
||||||
|
"/users/:id/edit",
|
||||||
|
"/users/:id/show/edit",
|
||||||
# Member list
|
# Member list
|
||||||
"/members",
|
"/members",
|
||||||
# Member detail
|
# Member detail
|
||||||
|
|
@ -198,14 +203,17 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
"/profile",
|
"/users/:id",
|
||||||
|
"/users/:id/edit",
|
||||||
|
"/users/:id/show/edit",
|
||||||
"/members",
|
"/members",
|
||||||
# Create member
|
# Create member
|
||||||
"/members/new",
|
"/members/new",
|
||||||
"/members/:id",
|
"/members/:id",
|
||||||
# Edit member
|
# Edit member
|
||||||
"/members/:id/edit",
|
"/members/:id/edit",
|
||||||
|
"/members/:id/show/edit",
|
||||||
"/custom_field_values",
|
"/custom_field_values",
|
||||||
# Custom field value detail
|
# Custom field value detail
|
||||||
"/custom_field_values/:id",
|
"/custom_field_values/:id",
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,99 @@ defmodule Mv.Config do
|
||||||
def sql_sandbox? do
|
def sql_sandbox? do
|
||||||
Application.get_env(:mv, :sql_sandbox, false)
|
Application.get_env(:mv, :sql_sandbox, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the maximum file size for CSV imports in bytes.
|
||||||
|
|
||||||
|
Reads the `max_file_size_mb` value from the CSV import configuration
|
||||||
|
and converts it to bytes.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- Maximum file size in bytes (default: 10_485_760 bytes = 10 MB)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Config.csv_import_max_file_size_bytes()
|
||||||
|
10_485_760
|
||||||
|
"""
|
||||||
|
@spec csv_import_max_file_size_bytes() :: non_neg_integer()
|
||||||
|
def csv_import_max_file_size_bytes do
|
||||||
|
max_file_size_mb = get_csv_import_config(:max_file_size_mb, 10)
|
||||||
|
max_file_size_mb * 1024 * 1024
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the maximum number of rows allowed in CSV imports.
|
||||||
|
|
||||||
|
Reads the `max_rows` value from the CSV import configuration.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- Maximum number of rows (default: 1000)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Config.csv_import_max_rows()
|
||||||
|
1000
|
||||||
|
"""
|
||||||
|
@spec csv_import_max_rows() :: pos_integer()
|
||||||
|
def csv_import_max_rows do
|
||||||
|
get_csv_import_config(:max_rows, 1000)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the maximum file size for CSV imports in megabytes.
|
||||||
|
|
||||||
|
Reads the `max_file_size_mb` value from the CSV import configuration.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- Maximum file size in megabytes (default: 10)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Config.csv_import_max_file_size_mb()
|
||||||
|
10
|
||||||
|
"""
|
||||||
|
@spec csv_import_max_file_size_mb() :: pos_integer()
|
||||||
|
def csv_import_max_file_size_mb do
|
||||||
|
get_csv_import_config(:max_file_size_mb, 10)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function to get CSV import config values
|
||||||
|
defp get_csv_import_config(key, default) do
|
||||||
|
Application.get_env(:mv, :csv_import, [])
|
||||||
|
|> Keyword.get(key, default)
|
||||||
|
|> parse_and_validate_integer(default)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses and validates integer configuration values.
|
||||||
|
#
|
||||||
|
# Accepts:
|
||||||
|
# - Integer values (passed through)
|
||||||
|
# - String integers (e.g., "1000") - parsed to integer
|
||||||
|
# - Invalid values (e.g., "abc", nil) - falls back to default
|
||||||
|
#
|
||||||
|
# Always clamps the result to a minimum of 1 to ensure positive values.
|
||||||
|
#
|
||||||
|
# Note: We don't log warnings for unparseable values because:
|
||||||
|
# - These functions may be called frequently (e.g., on every request)
|
||||||
|
# - Logging would create excessive log spam
|
||||||
|
# - The fallback to default provides a safe behavior
|
||||||
|
# - Configuration errors should be caught during deployment/testing
|
||||||
|
defp parse_and_validate_integer(value, _default) when is_integer(value) do
|
||||||
|
max(1, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_and_validate_integer(value, default) when is_binary(value) do
|
||||||
|
case Integer.parse(value) do
|
||||||
|
{int, _remainder} -> max(1, int)
|
||||||
|
:error -> default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_and_validate_integer(_value, default) do
|
||||||
|
default
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
chunks: list(list({pos_integer(), map()})),
|
chunks: list(list({pos_integer(), map()})),
|
||||||
column_map: %{atom() => non_neg_integer()},
|
column_map: %{atom() => non_neg_integer()},
|
||||||
custom_field_map: %{String.t() => non_neg_integer()},
|
custom_field_map: %{String.t() => non_neg_integer()},
|
||||||
custom_field_lookup: %{String.t() => %{id: String.t(), value_type: atom()}},
|
custom_field_lookup: %{
|
||||||
|
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
|
||||||
|
},
|
||||||
warnings: list(String.t())
|
warnings: list(String.t())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +81,11 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
# Import FieldTypes for human-readable type labels
|
||||||
|
alias MvWeb.Translations.FieldTypes
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
@default_max_errors 50
|
@default_max_errors 50
|
||||||
@default_chunk_size 200
|
@default_chunk_size 200
|
||||||
|
|
@ -102,6 +109,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
- `opts` - Optional keyword list:
|
- `opts` - Optional keyword list:
|
||||||
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
||||||
- `:chunk_size` - Number of rows per chunk (default: 200)
|
- `:chunk_size` - Number of rows per chunk (default: 200)
|
||||||
|
- `:actor` - Actor for authorization (default: system actor for systemic operations)
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
|
|
||||||
|
|
@ -120,9 +128,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
def prepare(file_content, opts \\ []) do
|
def prepare(file_content, opts \\ []) do
|
||||||
max_rows = Keyword.get(opts, :max_rows, @default_max_rows)
|
max_rows = Keyword.get(opts, :max_rows, @default_max_rows)
|
||||||
chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size)
|
chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size)
|
||||||
|
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||||
|
|
||||||
with {:ok, headers, rows} <- CsvParser.parse(file_content),
|
with {:ok, headers, rows} <- CsvParser.parse(file_content),
|
||||||
{:ok, custom_fields} <- load_custom_fields(),
|
{:ok, custom_fields} <- load_custom_fields(actor),
|
||||||
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
|
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
|
||||||
:ok <- validate_row_count(rows, max_rows) do
|
:ok <- validate_row_count(rows, max_rows) do
|
||||||
chunks = chunk_rows(rows, maps, chunk_size)
|
chunks = chunk_rows(rows, maps, chunk_size)
|
||||||
|
|
@ -142,10 +151,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads all custom fields from the database
|
# Loads all custom fields from the database
|
||||||
defp load_custom_fields do
|
defp load_custom_fields(actor) do
|
||||||
custom_fields =
|
custom_fields =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
{:ok, custom_fields}
|
{:ok, custom_fields}
|
||||||
rescue
|
rescue
|
||||||
|
|
@ -158,7 +167,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
custom_fields
|
custom_fields
|
||||||
|> Enum.reduce(%{}, fn cf, acc ->
|
|> Enum.reduce(%{}, fn cf, acc ->
|
||||||
id_str = to_string(cf.id)
|
id_str = to_string(cf.id)
|
||||||
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type})
|
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type, name: cf.name})
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -182,8 +191,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
normalized != "" && not member_field?(normalized)
|
normalized != "" && not member_field?(normalized)
|
||||||
end)
|
end)
|
||||||
|> Enum.map(fn header ->
|
|> Enum.map(fn header ->
|
||||||
"Unknown column '#{header}' will be ignored. " <>
|
gettext(
|
||||||
"If this is a custom field, create it in Mila before importing."
|
"Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing.",
|
||||||
|
header: header
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||||
|
|
@ -224,17 +235,20 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
|
|
||||||
# Builds a row map from raw row values using column maps
|
# Builds a row map from raw row values using column maps
|
||||||
defp build_row_map(row_values, maps) do
|
defp build_row_map(row_values, maps) do
|
||||||
|
row_tuple = List.to_tuple(row_values)
|
||||||
|
tuple_size = tuple_size(row_tuple)
|
||||||
|
|
||||||
member_map =
|
member_map =
|
||||||
maps.member
|
maps.member
|
||||||
|> Enum.reduce(%{}, fn {field, index}, acc ->
|
|> Enum.reduce(%{}, fn {field, index}, acc ->
|
||||||
value = Enum.at(row_values, index, "")
|
value = if index < tuple_size, do: elem(row_tuple, index), else: ""
|
||||||
Map.put(acc, field, value)
|
Map.put(acc, field, value)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
custom_map =
|
custom_map =
|
||||||
maps.custom
|
maps.custom
|
||||||
|> Enum.reduce(%{}, fn {custom_field_id, index}, acc ->
|
|> Enum.reduce(%{}, fn {custom_field_id, index}, acc ->
|
||||||
value = Enum.at(row_values, index, "")
|
value = if index < tuple_size, do: elem(row_tuple, index), else: ""
|
||||||
Map.put(acc, custom_field_id, value)
|
Map.put(acc, custom_field_id, value)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -299,7 +313,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
custom_field_lookup = Keyword.get(opts, :custom_field_lookup, %{})
|
custom_field_lookup = Keyword.get(opts, :custom_field_lookup, %{})
|
||||||
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
|
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
|
||||||
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||||
actor = Keyword.fetch!(opts, :actor)
|
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||||
|
|
||||||
{inserted, failed, errors, _collected_error_count, truncated?} =
|
{inserted, failed, errors, _collected_error_count, truncated?} =
|
||||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
|
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
|
||||||
|
|
@ -508,32 +522,19 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
|
|
||||||
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
|
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
|
||||||
# Prepare custom field values for Ash
|
# Prepare custom field values for Ash
|
||||||
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
case prepare_custom_field_values(custom_attrs, custom_field_lookup) do
|
||||||
|
{:error, validation_errors} ->
|
||||||
|
# Custom field validation errors - return first error
|
||||||
|
first_error = List.first(validation_errors)
|
||||||
|
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
|
||||||
|
|
||||||
# Create member with custom field values
|
{:ok, custom_field_values} ->
|
||||||
member_attrs_with_cf =
|
create_member_with_custom_fields(
|
||||||
trimmed_member_attrs
|
trimmed_member_attrs,
|
||||||
|> Map.put(:custom_field_values, custom_field_values)
|
custom_field_values,
|
||||||
|
line_number,
|
||||||
# Only include custom_field_values if not empty
|
actor
|
||||||
final_attrs =
|
)
|
||||||
if Enum.empty?(custom_field_values) do
|
|
||||||
Map.delete(member_attrs_with_cf, :custom_field_values)
|
|
||||||
else
|
|
||||||
member_attrs_with_cf
|
|
||||||
end
|
|
||||||
|
|
||||||
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
|
||||||
{:ok, member} ->
|
|
||||||
{:ok, member}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{} = error} ->
|
|
||||||
# Extract email from final_attrs for better error messages
|
|
||||||
email = Map.get(final_attrs, :email) || Map.get(trimmed_member_attrs, :email)
|
|
||||||
{:error, format_ash_error(error, line_number, email)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
|
|
@ -541,71 +542,241 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prepares custom field values from row map for Ash
|
# Creates a member with custom field values, handling errors appropriately
|
||||||
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
defp create_member_with_custom_fields(
|
||||||
custom_attrs
|
trimmed_member_attrs,
|
||||||
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
custom_field_values,
|
||||||
|> Enum.map(fn {custom_field_id_str, value} ->
|
line_number,
|
||||||
case Map.get(custom_field_lookup, custom_field_id_str) do
|
actor
|
||||||
nil ->
|
) do
|
||||||
# Custom field not found, skip
|
# Create member with custom field values
|
||||||
nil
|
member_attrs_with_cf =
|
||||||
|
trimmed_member_attrs
|
||||||
|
|> Map.put(:custom_field_values, custom_field_values)
|
||||||
|
|
||||||
%{id: custom_field_id, value_type: value_type} ->
|
# Only include custom_field_values if not empty
|
||||||
%{
|
final_attrs =
|
||||||
"custom_field_id" => to_string(custom_field_id),
|
if Enum.empty?(custom_field_values) do
|
||||||
"value" => format_custom_field_value(value, value_type)
|
Map.delete(member_attrs_with_cf, :custom_field_values)
|
||||||
}
|
else
|
||||||
|
member_attrs_with_cf
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|> Enum.filter(&(&1 != nil))
|
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
||||||
|
{:ok, member} ->
|
||||||
|
{:ok, member}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Invalid{} = error} ->
|
||||||
|
# Extract email from final_attrs for better error messages
|
||||||
|
email = Map.get(final_attrs, :email) || Map.get(trimmed_member_attrs, :email)
|
||||||
|
{:error, format_ash_error(error, line_number, email)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp prepare_custom_field_values(_, _), do: []
|
# Prepares custom field values from row map for Ash
|
||||||
|
# Returns {:ok, [custom_field_value_maps]} or {:error, [validation_errors]}
|
||||||
|
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
||||||
|
{values, errors} =
|
||||||
|
custom_attrs
|
||||||
|
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
||||||
|
|> Enum.reduce({[], []}, fn {custom_field_id_str, value}, {acc_values, acc_errors} ->
|
||||||
|
process_single_custom_field(
|
||||||
|
custom_field_id_str,
|
||||||
|
value,
|
||||||
|
custom_field_lookup,
|
||||||
|
acc_values,
|
||||||
|
acc_errors
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Enum.empty?(errors) do
|
||||||
|
{:ok, Enum.reverse(values)}
|
||||||
|
else
|
||||||
|
{:error, Enum.reverse(errors)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_custom_field_values(_, _), do: {:ok, []}
|
||||||
|
|
||||||
|
# Processes a single custom field value and returns updated accumulator
|
||||||
|
defp process_single_custom_field(
|
||||||
|
custom_field_id_str,
|
||||||
|
value,
|
||||||
|
custom_field_lookup,
|
||||||
|
acc_values,
|
||||||
|
acc_errors
|
||||||
|
) do
|
||||||
|
# Trim value early and skip if empty
|
||||||
|
trimmed_value = if is_binary(value), do: String.trim(value), else: value
|
||||||
|
|
||||||
|
# Skip empty values (after trimming) - don't create CFV
|
||||||
|
if trimmed_value == "" or trimmed_value == nil do
|
||||||
|
{acc_values, acc_errors}
|
||||||
|
else
|
||||||
|
process_non_empty_custom_field(
|
||||||
|
custom_field_id_str,
|
||||||
|
trimmed_value,
|
||||||
|
custom_field_lookup,
|
||||||
|
acc_values,
|
||||||
|
acc_errors
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Processes a non-empty custom field value
|
||||||
|
defp process_non_empty_custom_field(
|
||||||
|
custom_field_id_str,
|
||||||
|
trimmed_value,
|
||||||
|
custom_field_lookup,
|
||||||
|
acc_values,
|
||||||
|
acc_errors
|
||||||
|
) do
|
||||||
|
case Map.get(custom_field_lookup, custom_field_id_str) do
|
||||||
|
nil ->
|
||||||
|
# Custom field not found, skip
|
||||||
|
{acc_values, acc_errors}
|
||||||
|
|
||||||
|
%{id: custom_field_id, value_type: value_type, name: custom_field_name} ->
|
||||||
|
case format_custom_field_value(trimmed_value, value_type, custom_field_name) do
|
||||||
|
{:ok, formatted_value} ->
|
||||||
|
value_map = %{
|
||||||
|
"custom_field_id" => to_string(custom_field_id),
|
||||||
|
"value" => formatted_value
|
||||||
|
}
|
||||||
|
|
||||||
|
{[value_map | acc_values], acc_errors}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{acc_values, [reason | acc_errors]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Formats a custom field value according to its type
|
# Formats a custom field value according to its type
|
||||||
# Uses _union_type and _union_value format as expected by Ash
|
# Uses _union_type and _union_value format as expected by Ash
|
||||||
defp format_custom_field_value(value, :string) when is_binary(value) do
|
# Returns {:ok, formatted_value} or {:error, error_message}
|
||||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
defp format_custom_field_value(value, :string, _custom_field_name) when is_binary(value) do
|
||||||
|
{:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(value, :integer) when is_binary(value) do
|
defp format_custom_field_value(value, :integer, custom_field_name) when is_binary(value) do
|
||||||
case Integer.parse(value) do
|
trimmed = String.trim(value)
|
||||||
{int_value, _} -> %{"_union_type" => "integer", "_union_value" => int_value}
|
|
||||||
:error -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
case Integer.parse(trimmed) do
|
||||||
|
{int_value, ""} ->
|
||||||
|
# Fully consumed - valid integer
|
||||||
|
{:ok, %{"_union_type" => "integer", "_union_value" => int_value}}
|
||||||
|
|
||||||
|
{_int_value, _remaining} ->
|
||||||
|
# Not fully consumed - invalid
|
||||||
|
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(value, :boolean) when is_binary(value) do
|
defp format_custom_field_value(value, :boolean, custom_field_name) when is_binary(value) do
|
||||||
bool_value =
|
trimmed = String.trim(value)
|
||||||
value
|
|
||||||
|> String.trim()
|
|
||||||
|> String.downcase()
|
|
||||||
|> case do
|
|
||||||
"true" -> true
|
|
||||||
"1" -> true
|
|
||||||
"yes" -> true
|
|
||||||
"ja" -> true
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
|
|
||||||
%{"_union_type" => "boolean", "_union_value" => bool_value}
|
case parse_boolean_value(trimmed) do
|
||||||
end
|
{:ok, bool_value} ->
|
||||||
|
{:ok, %{"_union_type" => "boolean", "_union_value" => bool_value}}
|
||||||
|
|
||||||
defp format_custom_field_value(value, :date) when is_binary(value) do
|
:error ->
|
||||||
case Date.from_iso8601(String.trim(value)) do
|
{:error,
|
||||||
{:ok, date} -> %{"_union_type" => "date", "_union_value" => date}
|
format_custom_field_error_with_details(
|
||||||
{:error, _} -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
custom_field_name,
|
||||||
|
:boolean,
|
||||||
|
trimmed,
|
||||||
|
gettext("(true/false/1/0/yes/no/ja/nein)")
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
defp format_custom_field_value(value, :date, custom_field_name) when is_binary(value) do
|
||||||
%{"_union_type" => "email", "_union_value" => String.trim(value)}
|
trimmed = String.trim(value)
|
||||||
|
|
||||||
|
case Date.from_iso8601(trimmed) do
|
||||||
|
{:ok, date} ->
|
||||||
|
{:ok, %{"_union_type" => "date", "_union_value" => date}}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:error,
|
||||||
|
format_custom_field_error_with_details(
|
||||||
|
custom_field_name,
|
||||||
|
:date,
|
||||||
|
trimmed,
|
||||||
|
gettext("(ISO-8601 format: YYYY-MM-DD)")
|
||||||
|
)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
defp format_custom_field_value(value, :email, custom_field_name) when is_binary(value) do
|
||||||
|
trimmed = String.trim(value)
|
||||||
|
|
||||||
|
# Use EctoCommons.EmailValidator for consistency with Member email validation
|
||||||
|
changeset =
|
||||||
|
{%{}, %{email: :string}}
|
||||||
|
|> Ecto.Changeset.cast(%{email: trimmed}, [:email])
|
||||||
|
|> EctoCommons.EmailValidator.validate_email(:email,
|
||||||
|
checks: Mv.Constants.email_validator_checks()
|
||||||
|
)
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
{:ok, %{"_union_type" => "email", "_union_value" => trimmed}}
|
||||||
|
else
|
||||||
|
{:error, format_custom_field_error(custom_field_name, :email, trimmed)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_custom_field_value(value, _type, _custom_field_name) when is_binary(value) do
|
||||||
# Default to string if type is unknown
|
# Default to string if type is unknown
|
||||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
{:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses a boolean value from a string, supporting multiple formats
|
||||||
|
defp parse_boolean_value(value) when is_binary(value) do
|
||||||
|
lower = String.downcase(value)
|
||||||
|
parse_boolean_value_lower(lower)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function with pattern matching for boolean values
|
||||||
|
defp parse_boolean_value_lower("true"), do: {:ok, true}
|
||||||
|
defp parse_boolean_value_lower("1"), do: {:ok, true}
|
||||||
|
defp parse_boolean_value_lower("yes"), do: {:ok, true}
|
||||||
|
defp parse_boolean_value_lower("ja"), do: {:ok, true}
|
||||||
|
defp parse_boolean_value_lower("false"), do: {:ok, false}
|
||||||
|
defp parse_boolean_value_lower("0"), do: {:ok, false}
|
||||||
|
defp parse_boolean_value_lower("no"), do: {:ok, false}
|
||||||
|
defp parse_boolean_value_lower("nein"), do: {:ok, false}
|
||||||
|
defp parse_boolean_value_lower(_), do: :error
|
||||||
|
|
||||||
|
# Generates a consistent error message for custom field validation failures
|
||||||
|
# Uses human-readable field type labels (e.g., "Number" instead of "integer")
|
||||||
|
defp format_custom_field_error(custom_field_name, value_type, value) do
|
||||||
|
type_label = FieldTypes.label(value_type)
|
||||||
|
|
||||||
|
gettext("custom_field: %{name} – expected %{type}, got: %{value}",
|
||||||
|
name: custom_field_name,
|
||||||
|
type: type_label,
|
||||||
|
value: value
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generates an error message with additional details (e.g., format hints)
|
||||||
|
defp format_custom_field_error_with_details(custom_field_name, value_type, value, details) do
|
||||||
|
type_label = FieldTypes.label(value_type)
|
||||||
|
|
||||||
|
gettext("custom_field: %{name} – expected %{type} %{details}, got: %{value}",
|
||||||
|
name: custom_field_name,
|
||||||
|
type: type_label,
|
||||||
|
details: details,
|
||||||
|
value: value
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trims all string values in member attributes
|
# Trims all string values in member attributes
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ defmodule MvWeb.Authorization do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Checks if user has permission for an action on a resource.
|
Checks if user has permission for an action on a resource.
|
||||||
|
|
@ -111,16 +112,9 @@ defmodule MvWeb.Authorization do
|
||||||
def can_access_page?(nil, _page_path), do: false
|
def can_access_page?(nil, _page_path), do: false
|
||||||
|
|
||||||
def can_access_page?(user, page_path) do
|
def can_access_page?(user, page_path) do
|
||||||
# Convert verified route to string if needed
|
# Delegate to plug logic so UI uses same rules (reserved "new", own/linked path checks).
|
||||||
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
|
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
|
||||||
|
CheckPagePermission.user_can_access_page?(user, page_path_str, router: MvWeb.Router)
|
||||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
|
|
||||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
|
||||||
permissions <- PermissionSets.get_permissions(ps_atom) do
|
|
||||||
page_matches?(permissions.pages, page_path_str)
|
|
||||||
else
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if scope allows access to record
|
# Check if scope allows access to record
|
||||||
|
|
@ -172,33 +166,6 @@ defmodule MvWeb.Authorization do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if page path matches any allowed pattern
|
|
||||||
defp page_matches?(allowed_pages, requested_path) do
|
|
||||||
Enum.any?(allowed_pages, fn pattern ->
|
|
||||||
cond do
|
|
||||||
pattern == "*" -> true
|
|
||||||
pattern == requested_path -> true
|
|
||||||
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
|
|
||||||
true -> false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Match dynamic route pattern
|
|
||||||
defp match_pattern?(pattern, path) do
|
|
||||||
pattern_segments = String.split(pattern, "/", trim: true)
|
|
||||||
path_segments = String.split(path, "/", trim: true)
|
|
||||||
|
|
||||||
if length(pattern_segments) == length(path_segments) do
|
|
||||||
Enum.zip(pattern_segments, path_segments)
|
|
||||||
|> Enum.all?(fn {pattern_seg, path_seg} ->
|
|
||||||
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract resource name from module
|
# Extract resource name from module
|
||||||
defp get_resource_name(resource) when is_atom(resource) do
|
defp get_resource_name(resource) when is_atom(resource) do
|
||||||
resource |> Module.split() |> List.last()
|
resource |> Module.split() |> List.last()
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
||||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
||||||
"""
|
"""
|
||||||
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
@spec get_last_completed_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||||
def get_last_completed_cycle(member, today \\ nil)
|
def get_last_completed_cycle(member, today \\ nil)
|
||||||
|
|
||||||
|
def get_last_completed_cycle(nil, _today), do: nil
|
||||||
|
|
||||||
def get_last_completed_cycle(%Member{} = member, today) do
|
def get_last_completed_cycle(%Member{} = member, today) do
|
||||||
today = today || Date.utc_today()
|
today = today || Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -174,9 +176,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
||||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
||||||
"""
|
"""
|
||||||
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
@spec get_current_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||||
def get_current_cycle(member, today \\ nil)
|
def get_current_cycle(member, today \\ nil)
|
||||||
|
|
||||||
|
def get_current_cycle(nil, _today), do: nil
|
||||||
|
|
||||||
def get_current_cycle(%Member{} = member, today) do
|
def get_current_cycle(%Member{} = member, today) do
|
||||||
today = today || Date.utc_today()
|
today = today || Date.utc_today()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,66 +50,69 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Hide table when form is visible --%>
|
<%!-- Hide table when form is visible --%>
|
||||||
<.table
|
<div :if={!@show_form} id="custom_fields">
|
||||||
:if={!@show_form}
|
<.table
|
||||||
id="custom_fields"
|
id="custom_fields_table"
|
||||||
rows={@streams.custom_fields}
|
rows={@streams.custom_fields}
|
||||||
row_click={
|
row_click={
|
||||||
fn {_id, custom_field} ->
|
fn {_id, custom_field} ->
|
||||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
|
||||||
|
|
||||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
|
||||||
{@field_type_label.(custom_field.value_type)}
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
|
||||||
{custom_field.description}
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:col
|
|
||||||
:let={{_id, custom_field}}
|
|
||||||
label={gettext("Required")}
|
|
||||||
class="max-w-[9.375rem] text-center"
|
|
||||||
>
|
>
|
||||||
<span :if={custom_field.required} class="text-base-content font-semibold">
|
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||||
{gettext("Required")}
|
|
||||||
</span>
|
|
||||||
<span :if={!custom_field.required} class="text-base-content/70">
|
|
||||||
{gettext("Optional")}
|
|
||||||
</span>
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:col
|
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||||
:let={{_id, custom_field}}
|
{@field_type_label.(custom_field.value_type)}
|
||||||
label={gettext("Show in overview")}
|
</:col>
|
||||||
class="max-w-[9.375rem] text-center"
|
|
||||||
>
|
|
||||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
|
||||||
{gettext("Yes")}
|
|
||||||
</span>
|
|
||||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
|
||||||
{gettext("No")}
|
|
||||||
</span>
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||||
<.link phx-click={
|
{custom_field.description}
|
||||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
</:col>
|
||||||
}>
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
<:col
|
||||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
:let={{_id, custom_field}}
|
||||||
{gettext("Delete")}
|
label={gettext("Required")}
|
||||||
</.link>
|
class="max-w-[9.375rem] text-center"
|
||||||
</:action>
|
>
|
||||||
</.table>
|
<span :if={custom_field.required} class="text-base-content font-semibold">
|
||||||
|
{gettext("Required")}
|
||||||
|
</span>
|
||||||
|
<span :if={!custom_field.required} class="text-base-content/70">
|
||||||
|
{gettext("Optional")}
|
||||||
|
</span>
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:col
|
||||||
|
:let={{_id, custom_field}}
|
||||||
|
label={gettext("Show in overview")}
|
||||||
|
class="max-w-[9.375rem] text-center"
|
||||||
|
>
|
||||||
|
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||||
|
{gettext("Yes")}
|
||||||
|
</span>
|
||||||
|
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||||
|
{gettext("No")}
|
||||||
|
</span>
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:action :let={{_id, custom_field}}>
|
||||||
|
<.link phx-click={
|
||||||
|
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||||
|
}>
|
||||||
|
{gettext("Edit")}
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
|
||||||
|
<:action :let={{_id, custom_field}}>
|
||||||
|
<.link phx-click={
|
||||||
|
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
||||||
|
}>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Delete Confirmation Modal --%>
|
<%!-- Delete Confirmation Modal --%>
|
||||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
### Limits
|
### Limits
|
||||||
|
|
||||||
- Maximum file size: 10 MB
|
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
|
||||||
- Maximum rows: 1,000 rows (excluding header)
|
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
|
||||||
- Processing: chunks of 200 rows
|
- Processing: chunks of 200 rows
|
||||||
- Errors: capped at 50 per import
|
- Errors: capped at 50 per import
|
||||||
|
|
||||||
|
|
@ -54,8 +54,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
# CSV Import configuration constants
|
# CSV Import configuration constants
|
||||||
# 10 MB
|
|
||||||
@max_file_size_bytes 10_485_760
|
|
||||||
@max_errors 50
|
@max_errors 50
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -76,13 +74,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:import_status, :idle)
|
|> assign(:import_status, :idle)
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|> assign(:max_errors, @max_errors)
|
|> 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()
|
|> assign_form()
|
||||||
# Configure file upload with auto-upload enabled
|
# Configure file upload with auto-upload enabled
|
||||||
# Files are uploaded automatically when selected, no need for manual trigger
|
# Files are uploaded automatically when selected, no need for manual trigger
|
||||||
|> allow_upload(:csv_file,
|
|> allow_upload(:csv_file,
|
||||||
accept: ~w(.csv),
|
accept: ~w(.csv),
|
||||||
max_entries: 1,
|
max_entries: 1,
|
||||||
max_file_size: @max_file_size_bytes,
|
max_file_size: Config.csv_import_max_file_size_bytes(),
|
||||||
auto_upload: true
|
auto_upload: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,16 +138,21 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||||
<.form_section title={gettext("Import Members (CSV)")}>
|
<.form_section title={gettext("Import Members (CSV)")}>
|
||||||
<div role="note" class="alert alert-info mb-4">
|
<div role="note" class="alert alert-info mb-4">
|
||||||
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">
|
<p class="text-sm mb-2">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Custom fields must be created in Mila before importing CSV files with custom field columns"
|
"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>
|
||||||
<p class="text-sm mt-2">
|
<p class="text-sm">
|
||||||
{gettext(
|
<.link
|
||||||
"Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
href="#custom_fields"
|
||||||
)}
|
class="link"
|
||||||
|
data-testid="custom-fields-link"
|
||||||
|
>
|
||||||
|
{gettext("Manage Memberdata")}
|
||||||
|
</.link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -200,7 +205,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
/>
|
/>
|
||||||
<label class="label" id="csv_file_help">
|
<label class="label" id="csv_file_help">
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
{gettext("CSV files only, maximum 10 MB")}
|
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -408,8 +413,11 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
# Processes CSV upload and starts import
|
# Processes CSV upload and starts import
|
||||||
defp process_csv_upload(socket) do
|
defp process_csv_upload(socket) do
|
||||||
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
|
||||||
with {:ok, content} <- consume_and_read_csv(socket),
|
with {:ok, content} <- consume_and_read_csv(socket),
|
||||||
{:ok, import_state} <- MemberCSV.prepare(content) do
|
{:ok, import_state} <-
|
||||||
|
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||||
start_import(socket, import_state)
|
start_import(socket, import_state)
|
||||||
else
|
else
|
||||||
{:error, reason} when is_binary(reason) ->
|
{:error, reason} when is_binary(reason) ->
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
require Jason
|
require Jason
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
@ -94,7 +95,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user do %>
|
<%= if @user && @can_manage_member_linking do %>
|
||||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||||
<p class="text-sm text-orange-800">
|
<p class="text-sm text-orange-800">
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||||
|
|
@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Member Linking Section -->
|
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
||||||
<div class="mt-6">
|
<%= if @can_manage_member_linking do %>
|
||||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
<div class="mt-6">
|
||||||
|
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||||
|
|
||||||
<%= if @user && @user.member && !@unlink_member do %>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<!-- Show linked member with unlink button -->
|
||||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-green-900">
|
<p class="font-medium text-green-900">
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="unlink_member"
|
|
||||||
class="btn btn-sm btn-error"
|
|
||||||
>
|
|
||||||
{gettext("Unlink Member")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= if @unlink_member do %>
|
|
||||||
<!-- Show unlink pending message -->
|
|
||||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<!-- Show member search/selection for unlinked users -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-change="search_members"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-window-keydown="member_dropdown_keydown"
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={gettext("Search for a member to link...")}
|
|
||||||
class="w-full input"
|
|
||||||
name="member_search"
|
|
||||||
disabled={@unlink_member}
|
|
||||||
aria-label={gettext("Search for member to link")}
|
|
||||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
|
|
||||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="unlink_member"
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
>
|
||||||
|
{gettext("Unlink Member")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
<%= if @unlink_member do %>
|
||||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
<!-- Show unlink pending message -->
|
||||||
|
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||||
<p class="text-sm text-yellow-800">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<!-- Show member search/selection for unlinked users -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-change="search_members"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-window-keydown="member_dropdown_keydown"
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={gettext("Search for a member to link...")}
|
||||||
|
class="w-full input"
|
||||||
|
name="member_search"
|
||||||
|
disabled={@unlink_member}
|
||||||
|
aria-label={gettext("Search for member to link")}
|
||||||
|
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
<%= if @selected_member_id && @selected_member_name do %>
|
<%= if length(@available_members) > 0 do %>
|
||||||
<div
|
<div
|
||||||
id="member-selected"
|
id="member-dropdown"
|
||||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
role="listbox"
|
||||||
>
|
aria-label={gettext("Available members")}
|
||||||
<p class="text-sm text-blue-800">
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
phx-click-away="hide_member_dropdown"
|
||||||
</p>
|
>
|
||||||
<p class="mt-1 text-xs text-blue-600">
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
{gettext("Save to confirm linking.")}
|
<div
|
||||||
</p>
|
id={"member-option-#{index}"}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||||
<% end %>
|
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||||
</div>
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
|
<div
|
||||||
|
id="member-selected"
|
||||||
|
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
|
{gettext("Save to confirm linking.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
|
|
@ -289,14 +294,19 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp mount_continue(user, params, socket) do
|
defp mount_continue(user, params, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||||
page_title = action <> " " <> gettext("User")
|
page_title = action <> " " <> gettext("User")
|
||||||
|
|
||||||
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||||
|
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(user: user)
|
|> assign(user: user)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||||
|> assign(:show_password_fields, false)
|
|> assign(:show_password_fields, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|
@ -329,9 +339,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||||
|
|
||||||
# Reload members if email changed (for email-match priority)
|
# Reload members if email changed (for email-match priority; only when member linking UI is shown)
|
||||||
socket =
|
socket =
|
||||||
if Map.has_key?(user_params, "email") do
|
if Map.has_key?(user_params, "email") and socket.assigns[:can_manage_member_linking] do
|
||||||
user_email = user_params["email"]
|
user_email = user_params["email"]
|
||||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
||||||
|
|
||||||
|
|
@ -480,20 +490,25 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp perform_member_link_action(socket, user, actor) do
|
defp perform_member_link_action(socket, user, actor) do
|
||||||
cond do
|
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
||||||
# Selected member ID takes precedence (new link)
|
if can?(actor, :destroy, Mv.Accounts.User) do
|
||||||
socket.assigns.selected_member_id ->
|
cond do
|
||||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
# Selected member ID takes precedence (new link)
|
||||||
actor: actor
|
socket.assigns.selected_member_id ->
|
||||||
)
|
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Unlink flag is set
|
# Unlink flag is set
|
||||||
socket.assigns[:unlink_member] ->
|
socket.assigns[:unlink_member] ->
|
||||||
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||||
|
|
||||||
# No changes to member relationship
|
# No changes to member relationship
|
||||||
true ->
|
true ->
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:ok, user}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -552,13 +567,28 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
defp assign_form(
|
||||||
|
%{
|
||||||
|
assigns: %{
|
||||||
|
user: user,
|
||||||
|
show_password_fields: show_password_fields,
|
||||||
|
can_manage_member_linking: can_manage_member_linking
|
||||||
|
}
|
||||||
|
} = socket
|
||||||
|
) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
# For existing users, use admin password action if password fields are shown
|
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
|
||||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
# Password change uses admin_set_password for both.
|
||||||
|
action =
|
||||||
|
cond do
|
||||||
|
show_password_fields -> :admin_set_password
|
||||||
|
can_manage_member_linking -> :update_user
|
||||||
|
true -> :update
|
||||||
|
end
|
||||||
|
|
||||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||||
else
|
else
|
||||||
# For new users, use password registration if password fields are shown
|
# For new users, use password registration if password fields are shown
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,18 @@ defmodule MvWeb.LiveHelpers do
|
||||||
## on_mount Hooks
|
## on_mount Hooks
|
||||||
- `:default` - Sets the user's locale from session (defaults to "de")
|
- `:default` - Sets the user's locale from session (defaults to "de")
|
||||||
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
|
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
|
||||||
|
- `:check_page_permission_on_params` - Attaches handle_params hook to enforce page permission on client-side navigation (push_patch)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Add to LiveView modules via:
|
Add to LiveView modules via:
|
||||||
```elixir
|
```elixir
|
||||||
on_mount {MvWeb.LiveHelpers, :default}
|
on_mount {MvWeb.LiveHelpers, :default}
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
on_mount {MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
|
|
@ -26,6 +29,40 @@ defmodule MvWeb.LiveHelpers do
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def on_mount(:check_page_permission_on_params, _params, _session, socket) do
|
||||||
|
{:cont,
|
||||||
|
Phoenix.LiveView.attach_hook(
|
||||||
|
socket,
|
||||||
|
:check_page_permission,
|
||||||
|
:handle_params,
|
||||||
|
&check_page_permission_handle_params/3
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_page_permission_handle_params(_params, uri, socket) do
|
||||||
|
path = uri |> URI.parse() |> Map.get(:path, "/") || "/"
|
||||||
|
|
||||||
|
if CheckPagePermission.public_path?(path) do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
user = socket.assigns[:current_user]
|
||||||
|
host = uri |> URI.parse() |> Map.get(:host) || "localhost"
|
||||||
|
|
||||||
|
if CheckPagePermission.user_can_access_page?(user, path, router: MvWeb.Router, host: host) do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
redirect_to = CheckPagePermission.redirect_target_for_user(user)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|
||||||
|
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_user_role_loaded(socket) do
|
defp ensure_user_role_loaded(socket) do
|
||||||
user = socket.assigns[:current_user]
|
user = socket.assigns[:current_user]
|
||||||
|
|
||||||
|
|
|
||||||
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
@moduledoc """
|
||||||
|
Plug that checks if the current user has permission to access the requested page.
|
||||||
|
|
||||||
|
Runs in the router pipeline before LiveView mounts. Uses PermissionSets page list
|
||||||
|
and matches the current route template (or request path) against allowed patterns.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Public paths (e.g. /auth, /register) are exempt and pass through.
|
||||||
|
2. Extracts page path from conn via `Phoenix.Router.route_info/4` (route template
|
||||||
|
like "/members/:id") or falls back to `conn.request_path`.
|
||||||
|
3. Gets current user from `conn.assigns[:current_user]`.
|
||||||
|
4. Gets user's permission_set_name from role and calls `PermissionSets.get_permissions/1`.
|
||||||
|
5. Matches requested path against allowed patterns (exact, dynamic `:param`, wildcard "*").
|
||||||
|
6. If unauthorized: redirects to "/sign-in" (no user) or "/users/:id" (user profile) with flash error and halts.
|
||||||
|
|
||||||
|
## Pattern Matching
|
||||||
|
|
||||||
|
- Exact: "/members" == "/members"
|
||||||
|
- Dynamic: "/members/:id" matches "/members/123"
|
||||||
|
- Wildcard: "*" matches everything (admin)
|
||||||
|
- Reserved: the segment "new" is never matched by `:id` or `:slug` (e.g. `/members/new` and `/groups/new` require an explicit page permission).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
alias Mv.Authorization.PermissionSets
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
if public_path?(conn.request_path) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
# Ensure role is loaded (load_from_session does not load it; required for permission check)
|
||||||
|
user =
|
||||||
|
conn.assigns[:current_user]
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|
||||||
|
conn = Plug.Conn.assign(conn, :current_user, user)
|
||||||
|
page_path = get_page_path(conn)
|
||||||
|
request_path = conn.request_path
|
||||||
|
|
||||||
|
if has_page_permission?(user, page_path, request_path) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
log_page_access_denied(user, page_path)
|
||||||
|
|
||||||
|
redirect_to = redirect_target(user)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> fetch_session()
|
||||||
|
|> fetch_flash()
|
||||||
|
|> put_flash(:error, "You don't have permission to access this page.")
|
||||||
|
|> redirect(to: redirect_to)
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the redirect URL for an unauthorized user (for LiveView push_redirect).
|
||||||
|
"""
|
||||||
|
def redirect_target_for_user(nil), do: "/sign-in"
|
||||||
|
|
||||||
|
def redirect_target_for_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
id = Map.get(user, :id) || Map.get(user, "id")
|
||||||
|
if id, do: "/users/#{to_string(id)}", else: "/sign-in"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_target_for_user(_), do: "/sign-in"
|
||||||
|
|
||||||
|
defp redirect_target(user), do: redirect_target_for_user(user)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the path is public (no auth/permission check).
|
||||||
|
Used by LiveView hook to skip redirect on sign-in etc.
|
||||||
|
"""
|
||||||
|
def public_path?(path) when is_binary(path) do
|
||||||
|
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
|
||||||
|
String.starts_with?(path, "/auth") or
|
||||||
|
String.starts_with?(path, "/confirm") or
|
||||||
|
String.starts_with?(path, "/password-reset")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_page_path(conn) do
|
||||||
|
router = conn.private[:phoenix_router]
|
||||||
|
get_page_path_from_router(router, conn.method, conn.request_path, conn.host)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the user is allowed to access the given request path.
|
||||||
|
Used by the plug and by LiveView on_mount/handle_params for client-side navigation.
|
||||||
|
|
||||||
|
Options: `:router` (default MvWeb.Router), `:host` (default "localhost").
|
||||||
|
"""
|
||||||
|
def user_can_access_page?(user, request_path, opts \\ []) do
|
||||||
|
router = Keyword.get(opts, :router, MvWeb.Router)
|
||||||
|
host = Keyword.get(opts, :host, "localhost")
|
||||||
|
page_path = get_page_path_from_router(router, "GET", request_path, host)
|
||||||
|
has_page_permission?(user, page_path, request_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_page_path_from_router(router, method, request_path, host) do
|
||||||
|
case Phoenix.Router.route_info(router, method, request_path, host) do
|
||||||
|
%{route: route} -> route
|
||||||
|
_ -> request_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp has_page_permission?(nil, _page_path, _request_path), do: false
|
||||||
|
|
||||||
|
defp has_page_permission?(user, page_path, request_path) do
|
||||||
|
with ps_name when is_binary(ps_name) <- permission_set_name_from_user(user),
|
||||||
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
|
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||||
|
page_matches?(permissions.pages, page_path, request_path, user)
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp permission_set_name_from_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
get_in(user, [Access.key(:role), Access.key(:permission_set_name)]) ||
|
||||||
|
get_in(user, [Access.key("role"), Access.key("permission_set_name")])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp permission_set_name_from_user(_), do: nil
|
||||||
|
|
||||||
|
defp user_id_from_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
id = Map.get(user, :id) || Map.get(user, "id")
|
||||||
|
if id, do: to_string(id), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_id_from_user(_), do: nil
|
||||||
|
|
||||||
|
# Reserved path segments that must not match a single :id param (e.g. /members/new, /users/new).
|
||||||
|
@reserved_id_segments ["new"]
|
||||||
|
|
||||||
|
# For "/users/:id" with own_data we only allow when the id in the path equals the current user's id.
|
||||||
|
# For "/members/:id" we reject when the segment is reserved (e.g. "new") so /members/new is not allowed.
|
||||||
|
defp page_matches?(allowed_pages, requested_path, request_path, user) do
|
||||||
|
Enum.any?(allowed_pages, fn pattern ->
|
||||||
|
pattern_match?(pattern, requested_path, request_path, user)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?("*", _requested_path, _request_path, _user), do: true
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern == "/users/:id" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern in ["/users/:id/edit", "/users/:id/show/edit"] do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern == "/members/:id" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments) and
|
||||||
|
members_show_allowed?(pattern, request_path, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern in ["/members/:id/edit", "/members/:id/show/edit"] do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
members_edit_allowed?(pattern, request_path, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, _user)
|
||||||
|
when pattern == "/groups/:slug" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_not_reserved(pattern, request_path, "slug", @reserved_id_segments)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, requested_path, _request_path, _user)
|
||||||
|
when pattern == requested_path do
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, _user) do
|
||||||
|
if String.contains?(pattern, ":") do
|
||||||
|
match_dynamic_route?(pattern, request_path)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_not_reserved(pattern, request_path, param_name, reserved)
|
||||||
|
when is_list(reserved) do
|
||||||
|
segments = String.split(request_path, "/", trim: true)
|
||||||
|
idx = param_index(pattern, param_name)
|
||||||
|
|
||||||
|
if idx < 0 do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
value = Enum.at(segments, idx)
|
||||||
|
value not in reserved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_equals(pattern, request_path, param_name, expected_value)
|
||||||
|
when is_binary(expected_value) do
|
||||||
|
segments = String.split(request_path, "/", trim: true)
|
||||||
|
idx = param_index(pattern, param_name)
|
||||||
|
|
||||||
|
if idx < 0 do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
value = Enum.at(segments, idx)
|
||||||
|
value == expected_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_equals(_, _, _, _), do: false
|
||||||
|
|
||||||
|
# For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved.
|
||||||
|
defp members_show_allowed?(pattern, request_path, user) do
|
||||||
|
if permission_set_name_from_user(user) == "own_data" do
|
||||||
|
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp members_edit_allowed?(pattern, request_path, user) do
|
||||||
|
if permission_set_name_from_user(user) == "own_data" do
|
||||||
|
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||||
|
else
|
||||||
|
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_member_id(user) when is_map(user) or is_struct(user) do
|
||||||
|
member_id = Map.get(user, :member_id) || Map.get(user, "member_id")
|
||||||
|
|
||||||
|
if is_nil(member_id) do
|
||||||
|
load_member_id_for_user(user)
|
||||||
|
else
|
||||||
|
to_string(member_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_member_id(_), do: nil
|
||||||
|
|
||||||
|
defp load_member_id_for_user(user) do
|
||||||
|
id = user_id_from_user(user)
|
||||||
|
|
||||||
|
if id do
|
||||||
|
case Ash.get(Mv.Accounts.User, id, load: [:member], domain: Mv.Accounts, authorize?: false) do
|
||||||
|
{:ok, loaded} when not is_nil(loaded.member_id) -> to_string(loaded.member_id)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp param_index(pattern, param_name) do
|
||||||
|
pattern
|
||||||
|
|> String.split("/", trim: true)
|
||||||
|
|> Enum.find_index(fn seg ->
|
||||||
|
String.starts_with?(seg, ":") and String.trim_leading(seg, ":") == param_name
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
nil -> -1
|
||||||
|
i -> i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp match_dynamic_route?(pattern, path) do
|
||||||
|
pattern_segments = String.split(pattern, "/", trim: true)
|
||||||
|
path_segments = String.split(path, "/", trim: true)
|
||||||
|
|
||||||
|
if length(pattern_segments) == length(path_segments) do
|
||||||
|
Enum.zip(pattern_segments, path_segments)
|
||||||
|
|> Enum.all?(fn {pattern_seg, path_seg} ->
|
||||||
|
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_page_access_denied(user, page_path) do
|
||||||
|
user_id =
|
||||||
|
if user do
|
||||||
|
Map.get(user, :id) || Map.get(user, "id") || "nil"
|
||||||
|
else
|
||||||
|
"nil"
|
||||||
|
end
|
||||||
|
|
||||||
|
role_name =
|
||||||
|
if user do
|
||||||
|
get_in(user, [Access.key(:role), Access.key(:name)]) ||
|
||||||
|
get_in(user, [Access.key("role"), Access.key("name")]) || "nil"
|
||||||
|
else
|
||||||
|
"nil"
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info("""
|
||||||
|
Page access denied:
|
||||||
|
User: #{user_id}
|
||||||
|
Role: #{role_name}
|
||||||
|
Page: #{page_path}
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.Router do
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :load_from_session
|
plug :load_from_session
|
||||||
plug :set_locale
|
plug :set_locale
|
||||||
|
plug MvWeb.Plugs.CheckPagePermission
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
|
@ -48,7 +49,8 @@ defmodule MvWeb.Router do
|
||||||
ash_authentication_live_session :authentication_required,
|
ash_authentication_live_session :authentication_required,
|
||||||
on_mount: [
|
on_mount: [
|
||||||
{MvWeb.LiveUserAuth, :live_user_required},
|
{MvWeb.LiveUserAuth, :live_user_required},
|
||||||
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
{MvWeb.LiveHelpers, :ensure_user_role_loaded},
|
||||||
|
{MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||||
] do
|
] do
|
||||||
live "/", MemberLive.Index, :index
|
live "/", MemberLive.Index, :index
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1982,16 +1982,6 @@ msgstr " (Datenfeld: %{field})"
|
||||||
msgid "CSV File"
|
msgid "CSV File"
|
||||||
msgstr "CSV Datei"
|
msgstr "CSV Datei"
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "CSV files only, maximum 10 MB"
|
|
||||||
msgstr "Nur CSV Dateien, maximal 10 MB"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
|
||||||
msgstr "Individuelle Datenfelder müssen zuerst in Mila angelegt werden bevor das Importieren von diesen Feldern mit CSV Dateien mölich ist."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Download CSV templates:"
|
msgid "Download CSV templates:"
|
||||||
|
|
@ -2122,11 +2112,6 @@ msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)"
|
||||||
msgid "Summary"
|
msgid "Summary"
|
||||||
msgstr "Zusammenfassung"
|
msgstr "Zusammenfassung"
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
|
||||||
msgstr "Verwenden Sie den Namen des benutzerdefinierten Feldes als CSV-Spaltenüberschrift (gleiche Normalisierung wie bei Mitgliedsfeldern)"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Warnings"
|
msgid "Warnings"
|
||||||
|
|
@ -2276,14 +2261,14 @@ 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üfen Sie Ihre Berechtigungen."
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
msgstr "Mitglied bearbeiten"
|
msgstr "Mitglied hinzufügen"
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to remove member: %{error}"
|
msgid "Failed to remove member: %{error}"
|
||||||
msgstr "Rolle konnte nicht gelöscht werden: %{error}"
|
msgstr "Mitglied konnte nicht entfernt werden: %{error}"
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2334,3 +2319,58 @@ msgstr "%{name} entfernen"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Some members could not be added: %{errors}"
|
msgid "Some members could not be added: %{errors}"
|
||||||
msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}"
|
msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "CSV files only, maximum %{size} MB"
|
||||||
|
msgstr "Nur CSV Dateien, maximal %{size} MB"
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
msgstr "(ISO-8601 Format: JJJJ-MM-TT)"
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(true/false/1/0/yes/no/ja/nein)"
|
||||||
|
msgstr "(true/false/1/0/yes/no/ja/nein)"
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "custom_field: %{name} – expected %{type} %{details}, got: %{value}"
|
||||||
|
msgstr "Datenfeld: %{name} – erwartet %{type} %{details}, erhalten: %{value}"
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
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."
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Custom Fields in CSV Import"
|
||||||
|
#~ msgstr "Benutzerdefinierte Felder"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
|
||||||
|
#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwenden Sie den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Manage Custom Fields"
|
||||||
|
#~ msgstr "Benutzerdefinierte Felder verwalten"
|
||||||
|
|
|
||||||
|
|
@ -1983,16 +1983,6 @@ msgstr ""
|
||||||
msgid "CSV File"
|
msgid "CSV File"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "CSV files only, maximum 10 MB"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Download CSV templates:"
|
msgid "Download CSV templates:"
|
||||||
|
|
@ -2123,11 +2113,6 @@ msgstr ""
|
||||||
msgid "Summary"
|
msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Warnings"
|
msgid "Warnings"
|
||||||
|
|
@ -2335,3 +2320,43 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Some members could not be added: %{errors}"
|
msgid "Some members could not be added: %{errors}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "CSV files only, maximum %{size} MB"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(true/false/1/0/yes/no/ja/nein)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "custom_field: %{name} – expected %{type} %{details}, got: %{value}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -1981,16 +1981,6 @@ msgstr ""
|
||||||
msgid "CSV File"
|
msgid "CSV File"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "CSV files only, maximum 10 MB"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom fields must be created in Mila before importing CSV files with custom field columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Download CSV templates:"
|
msgid "Download CSV templates:"
|
||||||
|
|
@ -2121,11 +2111,6 @@ msgstr ""
|
||||||
msgid "Summary"
|
msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Warnings"
|
msgid "Warnings"
|
||||||
|
|
@ -2273,3 +2258,58 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Could not load data fields. Please check your permissions."
|
msgid "Could not load data fields. Please check your permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "CSV files only, maximum %{size} MB"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(true/false/1/0/yes/no/ja/nein)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "custom_field: %{name} – expected %{type} %{details}, got: %{value}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
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/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Custom Fields in CSV Import"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Manage Custom Fields"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
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} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -118,7 +119,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -163,9 +164,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
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} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -186,7 +188,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -231,9 +233,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
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} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -254,7 +257,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,10 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
test "includes correct pages" do
|
test "includes correct pages" do
|
||||||
permissions = PermissionSets.get_permissions(:own_data)
|
permissions = PermissionSets.get_permissions(:own_data)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
# Root "/" is not allowed for own_data (Mitglied is redirected to profile)
|
||||||
assert "/profile" in permissions.pages
|
refute "/" in permissions.pages
|
||||||
|
# Profile is at /users/:id, not a separate /profile route
|
||||||
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -229,7 +231,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
permissions = PermissionSets.get_permissions(:read_only)
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
assert "/" in permissions.pages
|
||||||
assert "/profile" in permissions.pages
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members" in permissions.pages
|
assert "/members" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
assert "/custom_field_values" in permissions.pages
|
assert "/custom_field_values" in permissions.pages
|
||||||
|
|
@ -333,7 +335,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
permissions = PermissionSets.get_permissions(:normal_user)
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
assert "/" in permissions.pages
|
||||||
assert "/profile" in permissions.pages
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members" in permissions.pages
|
assert "/members" in permissions.pages
|
||||||
assert "/members/new" in permissions.pages
|
assert "/members/new" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule Mv.Membership.Import.MemberCSVTest do
|
defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.Import.MemberCSV
|
alias Mv.Membership.Import.MemberCSV
|
||||||
|
|
||||||
|
|
@ -35,11 +35,10 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "prepare/2" do
|
describe "prepare/2" do
|
||||||
test "function exists and accepts file_content and opts" do
|
test "accepts file_content and opts and returns tagged tuple" do
|
||||||
file_content = "email\njohn@example.com"
|
file_content = "email\njohn@example.com"
|
||||||
opts = []
|
opts = []
|
||||||
|
|
||||||
# This will fail until the function is implemented
|
|
||||||
result = MemberCSV.prepare(file_content, opts)
|
result = MemberCSV.prepare(file_content, opts)
|
||||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||||
end
|
end
|
||||||
|
|
@ -65,11 +64,6 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
|
|
||||||
assert {:error, _reason} = MemberCSV.prepare(file_content, opts)
|
assert {:error, _reason} = MemberCSV.prepare(file_content, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "function has documentation" do
|
|
||||||
# Check that @doc exists by reading the module
|
|
||||||
assert function_exported?(MemberCSV, :prepare, 2)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "process_chunk/4" do
|
describe "process_chunk/4" do
|
||||||
|
|
@ -78,7 +72,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
%{actor: system_actor}
|
%{actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "function exists and accepts chunk_rows_with_lines, column_map, custom_field_map, and opts",
|
test "accepts chunk_rows_with_lines, column_map, custom_field_map, and opts and returns tagged tuple",
|
||||||
%{
|
%{
|
||||||
actor: actor
|
actor: actor
|
||||||
} do
|
} do
|
||||||
|
|
@ -87,7 +81,6 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
custom_field_map = %{}
|
custom_field_map = %{}
|
||||||
opts = [actor: actor]
|
opts = [actor: actor]
|
||||||
|
|
||||||
# This will fail until the function is implemented
|
|
||||||
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||||
end
|
end
|
||||||
|
|
@ -231,7 +224,11 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
custom_field_map = %{to_string(custom_field.id) => 1}
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
custom_field_lookup = %{
|
custom_field_lookup = %{
|
||||||
to_string(custom_field.id) => %{id: custom_field.id, value_type: custom_field.value_type}
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
@ -332,11 +329,6 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
assert chunk_result.errors == []
|
assert chunk_result.errors == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "function has documentation" do
|
|
||||||
# Check that @doc exists by reading the module
|
|
||||||
assert function_exported?(MemberCSV, :process_chunk, 4)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "error capping collects exactly 50 errors", %{actor: actor} do
|
test "error capping collects exactly 50 errors", %{actor: actor} do
|
||||||
# Create 50 rows with invalid emails
|
# Create 50 rows with invalid emails
|
||||||
chunk_rows_with_lines =
|
chunk_rows_with_lines =
|
||||||
|
|
@ -611,15 +603,300 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "module documentation" do
|
describe "custom field import" do
|
||||||
test "module has @moduledoc" do
|
setup do
|
||||||
# Check that the module exists and has documentation
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
assert Code.ensure_loaded?(MemberCSV)
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Try to get the module documentation
|
test "creates member with valid integer custom field value", %{actor: actor} do
|
||||||
{:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(MemberCSV)
|
# Create integer custom field
|
||||||
assert is_binary(moduledoc)
|
{:ok, custom_field} =
|
||||||
assert String.length(moduledoc) > 0
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Alter",
|
||||||
|
value_type: :integer
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
chunk_rows_with_lines = [
|
||||||
|
{2,
|
||||||
|
%{
|
||||||
|
member: %{email: "withage@example.com"},
|
||||||
|
custom: %{to_string(custom_field.id) => "25"}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
column_map = %{email: 0}
|
||||||
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
|
custom_field_lookup = %{
|
||||||
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
||||||
|
assert {:ok, chunk_result} =
|
||||||
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
|
|
||||||
|
assert chunk_result.inserted == 1
|
||||||
|
assert chunk_result.failed == 0
|
||||||
|
|
||||||
|
# Verify member and custom field value were created
|
||||||
|
members = Mv.Membership.list_members!(actor: actor)
|
||||||
|
member = Enum.find(members, &(&1.email == "withage@example.com"))
|
||||||
|
assert member != nil
|
||||||
|
|
||||||
|
{:ok, member_with_cf} = Ash.load(member, :custom_field_values, actor: actor)
|
||||||
|
assert length(member_with_cf.custom_field_values) == 1
|
||||||
|
cfv = List.first(member_with_cf.custom_field_values)
|
||||||
|
assert cfv.custom_field_id == custom_field.id
|
||||||
|
assert cfv.value.value == 25
|
||||||
|
assert cfv.value.type == :integer
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid integer custom field value", %{actor: actor} do
|
||||||
|
# Create integer custom field
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Alter",
|
||||||
|
value_type: :integer
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
chunk_rows_with_lines = [
|
||||||
|
{2,
|
||||||
|
%{
|
||||||
|
member: %{email: "invalidage@example.com"},
|
||||||
|
custom: %{to_string(custom_field.id) => "abc"}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
column_map = %{email: 0}
|
||||||
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
|
custom_field_lookup = %{
|
||||||
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
||||||
|
assert {:ok, chunk_result} =
|
||||||
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
|
|
||||||
|
assert chunk_result.inserted == 0
|
||||||
|
assert chunk_result.failed == 1
|
||||||
|
assert length(chunk_result.errors) == 1
|
||||||
|
|
||||||
|
error = List.first(chunk_result.errors)
|
||||||
|
assert error.csv_line_number == 2
|
||||||
|
assert error.message =~ "custom_field: Alter"
|
||||||
|
assert error.message =~ "Number"
|
||||||
|
assert error.message =~ "abc"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid date custom field value", %{actor: actor} do
|
||||||
|
# Create date custom field
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Geburtstag",
|
||||||
|
value_type: :date
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
chunk_rows_with_lines = [
|
||||||
|
{3,
|
||||||
|
%{
|
||||||
|
member: %{email: "invaliddate@example.com"},
|
||||||
|
custom: %{to_string(custom_field.id) => "not-a-date"}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
column_map = %{email: 0}
|
||||||
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
|
custom_field_lookup = %{
|
||||||
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
||||||
|
assert {:ok, chunk_result} =
|
||||||
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
|
|
||||||
|
assert chunk_result.inserted == 0
|
||||||
|
assert chunk_result.failed == 1
|
||||||
|
assert length(chunk_result.errors) == 1
|
||||||
|
|
||||||
|
error = List.first(chunk_result.errors)
|
||||||
|
assert error.csv_line_number == 3
|
||||||
|
assert error.message =~ "custom_field: Geburtstag"
|
||||||
|
assert error.message =~ "Date"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid email custom field value", %{actor: actor} do
|
||||||
|
# Create email custom field
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Work Email",
|
||||||
|
value_type: :email
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
chunk_rows_with_lines = [
|
||||||
|
{4,
|
||||||
|
%{
|
||||||
|
member: %{email: "invalidemailcf@example.com"},
|
||||||
|
custom: %{to_string(custom_field.id) => "not-an-email"}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
column_map = %{email: 0}
|
||||||
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
|
custom_field_lookup = %{
|
||||||
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
||||||
|
assert {:ok, chunk_result} =
|
||||||
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
|
|
||||||
|
assert chunk_result.inserted == 0
|
||||||
|
assert chunk_result.failed == 1
|
||||||
|
assert length(chunk_result.errors) == 1
|
||||||
|
|
||||||
|
error = List.first(chunk_result.errors)
|
||||||
|
assert error.csv_line_number == 4
|
||||||
|
assert error.message =~ "custom_field: Work Email"
|
||||||
|
assert error.message =~ "E-Mail"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid boolean custom field value", %{actor: actor} do
|
||||||
|
# Create boolean custom field
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Is Active",
|
||||||
|
value_type: :boolean
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
chunk_rows_with_lines = [
|
||||||
|
{5,
|
||||||
|
%{
|
||||||
|
member: %{email: "invalidbool@example.com"},
|
||||||
|
custom: %{to_string(custom_field.id) => "maybe"}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
column_map = %{email: 0}
|
||||||
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
||||||
|
|
||||||
|
custom_field_lookup = %{
|
||||||
|
to_string(custom_field.id) => %{
|
||||||
|
id: custom_field.id,
|
||||||
|
value_type: custom_field.value_type,
|
||||||
|
name: custom_field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
||||||
|
|
||||||
|
assert {:ok, chunk_result} =
|
||||||
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
||||||
|
|
||||||
|
assert chunk_result.inserted == 0
|
||||||
|
assert chunk_result.failed == 1
|
||||||
|
assert length(chunk_result.errors) == 1
|
||||||
|
|
||||||
|
error = List.first(chunk_result.errors)
|
||||||
|
assert error.csv_line_number == 5
|
||||||
|
assert error.message =~ "custom_field: Is Active"
|
||||||
|
# Error message should indicate boolean/Yes-No validation failure
|
||||||
|
assert String.contains?(error.message, "Yes/No") ||
|
||||||
|
String.contains?(error.message, "true/false") ||
|
||||||
|
String.contains?(error.message, "boolean")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "prepare/2 with custom fields" do
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
# Create a custom field
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Membership Number",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
%{actor: system_actor, custom_field: custom_field}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes custom field in custom_field_map when header matches", %{
|
||||||
|
custom_field: custom_field
|
||||||
|
} do
|
||||||
|
# CSV with custom field column
|
||||||
|
csv_content = "email;Membership Number\njohn@example.com;12345"
|
||||||
|
|
||||||
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||||
|
|
||||||
|
# Check that custom field is mapped
|
||||||
|
assert Map.has_key?(import_state.custom_field_map, to_string(custom_field.id))
|
||||||
|
assert import_state.column_map[:email] == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes warning for unknown custom field column", %{custom_field: _custom_field} do
|
||||||
|
# CSV with unknown custom field column (not matching any existing custom field)
|
||||||
|
csv_content = "email;NichtExistierend\njohn@example.com;value"
|
||||||
|
|
||||||
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||||
|
|
||||||
|
# Check that warning is present
|
||||||
|
assert import_state.warnings != []
|
||||||
|
warning = List.first(import_state.warnings)
|
||||||
|
assert warning =~ "NichtExistierend"
|
||||||
|
assert warning =~ "ignored"
|
||||||
|
assert warning =~ "custom field"
|
||||||
|
|
||||||
|
# Check that unknown column is not in custom_field_map
|
||||||
|
assert import_state.custom_field_map == %{}
|
||||||
|
# Member import should still succeed
|
||||||
|
assert import_state.column_map[:email] == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "import succeeds even with unknown custom field columns", %{custom_field: _custom_field} do
|
||||||
|
# CSV with unknown custom field column
|
||||||
|
csv_content = "email;UnknownField\njohn@example.com;value"
|
||||||
|
|
||||||
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||||
|
|
||||||
|
# Import state should be valid
|
||||||
|
assert import_state.column_map[:email] == 0
|
||||||
|
assert import_state.chunks != []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,39 @@ defmodule MvWeb.AuthorizationTest do
|
||||||
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
|
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "read_only can access own profile /users/:id only" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123/edit") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/other-id") == false
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/other-id/edit") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user can access own profile /users/:id only" do
|
||||||
|
normal_user = %{
|
||||||
|
id: "normal-456",
|
||||||
|
role: %{permission_set_name: "normal_user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/normal-456") == true
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/normal-456/edit") == true
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/other-id") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reserved segment 'new' is not matched by :id" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/new") == false
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/groups/new") == false
|
||||||
|
end
|
||||||
|
|
||||||
test "returns false for nil user" do
|
test "returns false for nil user" do
|
||||||
assert Authorization.can_access_page?(nil, "/members") == false
|
assert Authorization.can_access_page?(nil, "/members") == false
|
||||||
assert Authorization.can_access_page?(nil, "/admin/roles") == false
|
assert Authorization.can_access_page?(nil, "/admin/roles") == false
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_last_completed_cycle/2" do
|
describe "get_last_completed_cycle/2" do
|
||||||
|
test "returns nil when member is nil" do
|
||||||
|
assert MembershipFeeHelpers.get_last_completed_cycle(nil) == nil
|
||||||
|
assert MembershipFeeHelpers.get_last_completed_cycle(nil, Date.utc_today()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "returns last completed cycle for member", %{actor: actor} do
|
test "returns last completed cycle for member", %{actor: actor} do
|
||||||
# Create test data
|
# Create test data
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -184,6 +189,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_current_cycle/2" do
|
describe "get_current_cycle/2" do
|
||||||
|
test "returns nil when member is nil" do
|
||||||
|
assert MembershipFeeHelpers.get_current_cycle(nil) == nil
|
||||||
|
assert MembershipFeeHelpers.get_current_cycle(nil, Date.utc_today()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "returns current cycle for member", %{actor: actor} do
|
test "returns current cycle for member", %{actor: actor} do
|
||||||
fee_type =
|
fee_type =
|
||||||
Mv.MembershipFees.MembershipFeeType
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
|
||||||
73
test/mv_web/live/global_settings_live_config_test.exs
Normal file
73
test/mv_web/live/global_settings_live_config_test.exs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for GlobalSettingsLive that modify global Application configuration.
|
||||||
|
|
||||||
|
These tests run with `async: false` to prevent race conditions when
|
||||||
|
modifying global Application environment variables (Application.put_env).
|
||||||
|
This follows the same pattern as Mv.ConfigTest.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
# Helper function to upload CSV file in tests
|
||||||
|
defp upload_csv_file(view, csv_content, filename) 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 "CSV Import - Configuration Tests" 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 "configured row limit is enforced", %{conn: conn} do
|
||||||
|
# Business rule: CSV import respects configured row limits
|
||||||
|
# Test that a custom limit (500) is enforced, not just the default (1000)
|
||||||
|
original_config = Application.get_env(:mv, :csv_import, [])
|
||||||
|
|
||||||
|
try do
|
||||||
|
Application.put_env(:mv, :csv_import, max_rows: 500)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
|
# Generate CSV with 501 rows (exceeding custom limit of 500)
|
||||||
|
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||||
|
|
||||||
|
rows =
|
||||||
|
for i <- 1..501 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_custom.csv")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> form("#csv-upload-form", %{})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
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"
|
||||||
|
after
|
||||||
|
# Restore original config
|
||||||
|
Application.put_env(:mv, :csv_import, original_config)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -110,7 +110,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
# Check for custom fields notice text
|
# Check for custom fields notice text
|
||||||
assert html =~ "Custom fields" or html =~ "custom field"
|
assert html =~ "Use the data field name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin user sees template download links", %{conn: conn} do
|
test "admin user sees template download links", %{conn: conn} do
|
||||||
|
|
@ -158,15 +158,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin user does not see import section", %{conn: conn} do
|
test "non-admin user does not see import section", %{conn: conn} do
|
||||||
# Create non-admin user (member role)
|
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||||
|
assert to == "/users/#{member_user.id}"
|
||||||
# Import section should not be visible
|
|
||||||
refute html =~ "Import Members" or html =~ "CSV Import" or
|
|
||||||
(html =~ "Import" and html =~ "CSV")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -236,15 +233,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin cannot start import", %{conn: conn} do
|
test "non-admin cannot start import", %{conn: conn} do
|
||||||
# Create non-admin user
|
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||||
|
assert to == "/users/#{member_user.id}"
|
||||||
# Since non-admin shouldn't see the section, we check that import section is not visible
|
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Import Members" or html =~ "CSV Import" or html =~ "start_import"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
# User must have admin role (or normal_user) to access /membership_fee_types pages
|
||||||
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
authenticated_conn = conn_with_password_user(conn, user)
|
authenticated_conn = conn_with_password_user(conn, user)
|
||||||
%{conn: authenticated_conn, user: user}
|
%{conn: authenticated_conn, user: user}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|> Ash.create!(actor: admin_user)
|
|> Ash.create!(actor: admin_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member. Requires actor (e.g. admin_user from setup); no fallback so
|
||||||
# Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil
|
# missing-actor bugs are not masked in tests.
|
||||||
defp create_member(attrs, actor) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
|
|
@ -39,8 +39,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
attrs = Map.merge(default_attrs, attrs)
|
||||||
effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor()
|
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor)
|
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
|
|
||||||
describe "profile navigation" do
|
describe "profile navigation" do
|
||||||
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# User needs a role with page permission for "/" (e.g. admin)
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, view, _html} = live(conn, "/")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
|
@ -21,9 +21,18 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
assert_redirected(view, "/users/#{user.id}")
|
assert_redirected(view, "/users/#{user.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "profile navigation shows correct user data", %{conn: conn} do
|
test "profile navigation shows correct user data", %{conn: conn, actor: actor} do
|
||||||
# Setup: Create and login a user
|
# User with password (from create_test_user) and admin role so they can access "/"
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = create_test_user(%{email: "test@example.com"})
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
user = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor)
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
|
|
||||||
# Navigate to profile
|
# Navigate to profile
|
||||||
|
|
@ -40,8 +49,8 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
|
|
||||||
describe "sidebar" do
|
describe "sidebar" do
|
||||||
test "renders profile button with correct attributes", %{conn: conn} do
|
test "renders profile button with correct attributes", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# User needs a role with page permission for "/"
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, _view, html} = live(conn, "/")
|
{:ok, _view, html} = live(conn, "/")
|
||||||
|
|
||||||
|
|
@ -85,16 +94,27 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
|
# Assign role so user can access "/" (page permission)
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user_with_role} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
user_with_role = Ash.load!(user_with_role, :role, domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Login user via OIDC
|
# Login user via OIDC
|
||||||
conn = sign_in_user_via_oidc(conn, user)
|
conn = sign_in_user_via_oidc(conn, user_with_role)
|
||||||
|
|
||||||
# Navigate to home and click profile
|
# Navigate to home and click profile
|
||||||
{:ok, view, _html} = live(conn, "/")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
view |> element("a", "Profil") |> render_click()
|
view |> element("a", "Profil") |> render_click()
|
||||||
|
|
||||||
# Verify we're on the correct profile page with OIDC specific information
|
# Verify we're on the correct profile page with OIDC specific information
|
||||||
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
{:ok, _profile_view, html} = live(conn, "/users/#{user_with_role.id}")
|
||||||
assert html =~ to_string(user.email)
|
assert html =~ to_string(user_with_role.email)
|
||||||
# Password auth should be disabled for OIDC users
|
# Password auth should be disabled for OIDC users
|
||||||
assert html =~ "Not enabled"
|
assert html =~ "Not enabled"
|
||||||
end
|
end
|
||||||
|
|
@ -103,14 +123,10 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
actor: actor
|
actor: actor
|
||||||
} do
|
} do
|
||||||
# Create password user
|
# Users need a role with page permission for "/"
|
||||||
password_user =
|
password_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
create_test_user(%{
|
|
||||||
email: "password2@example.com",
|
|
||||||
password: "test_password123"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create OIDC user
|
# Create OIDC user and assign admin role
|
||||||
user_info = %{
|
user_info = %{
|
||||||
"sub" => "oidc_789",
|
"sub" => "oidc_789",
|
||||||
"preferred_username" => "oidc@example.com"
|
"preferred_username" => "oidc@example.com"
|
||||||
|
|
@ -129,6 +145,17 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, oidc_user_with_role} =
|
||||||
|
oidc_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
oidc_user_with_role =
|
||||||
|
Ash.load!(oidc_user_with_role, :role, domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Test with password user
|
# Test with password user
|
||||||
conn_password = conn_with_password_user(conn, password_user)
|
conn_password = conn_with_password_user(conn, password_user)
|
||||||
{:ok, view_password, _html} = live(conn_password, "/")
|
{:ok, view_password, _html} = live(conn_password, "/")
|
||||||
|
|
@ -136,16 +163,17 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
assert_redirected(view_password, "/users/#{password_user.id}")
|
assert_redirected(view_password, "/users/#{password_user.id}")
|
||||||
|
|
||||||
# Test with OIDC user
|
# Test with OIDC user
|
||||||
conn_oidc = sign_in_user_via_oidc(conn, oidc_user)
|
conn_oidc = sign_in_user_via_oidc(conn, oidc_user_with_role)
|
||||||
{:ok, view_oidc, _html} = live(conn_oidc, "/")
|
{:ok, view_oidc, _html} = live(conn_oidc, "/")
|
||||||
view_oidc |> element("a", "Profil") |> render_click()
|
view_oidc |> element("a", "Profil") |> render_click()
|
||||||
assert_redirected(view_oidc, "/users/#{oidc_user.id}")
|
assert_redirected(view_oidc, "/users/#{oidc_user_with_role.id}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "authenticated views" do
|
describe "authenticated views" do
|
||||||
|
# User must have a role with page permission to access /members, /users, etc.
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, conn: conn, user: user}
|
{:ok, conn: conn, user: user}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -441,18 +441,11 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
|
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
|
||||||
{conn, _user} = create_non_admin_user(conn, actor)
|
{conn, user} = create_non_admin_user(conn, actor)
|
||||||
|
|
||||||
# Non-admin should be redirected or see error
|
# Non-admin (no role or non-admin role) is redirected by CheckPagePermission plug
|
||||||
# Note: Authorization is checked via can_access_page? which returns false
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/admin/roles")
|
||||||
# The page might still mount but show no content or redirect
|
assert to == "/users/#{user.id}"
|
||||||
# For now, we just verify the page doesn't work as expected for non-admin
|
|
||||||
{:ok, _view, html} = live(conn, "/admin/roles")
|
|
||||||
|
|
||||||
# Non-admin should not see "New Role" button (can? returns false)
|
|
||||||
# But the button might still be in HTML, just hidden or disabled
|
|
||||||
# We verify that the page loads but admin features are restricted
|
|
||||||
assert html =~ "Listing Roles" || html =~ "Roles"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
|
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
|
||||||
|
|
|
||||||
934
test/mv_web/plugs/check_page_permission_test.exs
Normal file
934
test/mv_web/plugs/check_page_permission_test.exs
Normal file
|
|
@ -0,0 +1,934 @@
|
||||||
|
defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the CheckPagePermission plug.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
defp conn_with_user(path, user) do
|
||||||
|
build_conn(:get, path)
|
||||||
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
|
||||||
|
|> Plug.Conn.assign(:current_user, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp conn_without_user(path) do
|
||||||
|
build_conn(:get, path)
|
||||||
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "static routes" do
|
||||||
|
test "user with permission for \"/members\" can access (conn not halted)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user without permission for \"/members\" is denied (conn halted, redirected to user profile)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flash error message present after denial" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
|
"You don't have permission to access this page."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "dynamic routes" do
|
||||||
|
test "user with \"/members/:id\" permission can access \"/members/123\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user with linked member can access /members/:id/edit (plug direct call)" do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
|
# Simulate user with linked member (struct may not have member_id after session load)
|
||||||
|
user_with_member = %{user_with_member | member_id: member.id}
|
||||||
|
|
||||||
|
assert CheckPagePermission.user_can_access_page?(
|
||||||
|
user_with_member,
|
||||||
|
"/members/#{member.id}/edit"
|
||||||
|
),
|
||||||
|
"plug must allow own_data user with linked member to access member edit"
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn_with_user("/members/#{member.id}/edit", user_with_member)
|
||||||
|
|> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user with linked member can access /members/:id/show/edit (plug direct call)" do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
|
user_with_member = %{user_with_member | member_id: member.id}
|
||||||
|
|
||||||
|
assert CheckPagePermission.user_can_access_page?(
|
||||||
|
user_with_member,
|
||||||
|
"/members/#{member.id}/show/edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn_with_user("/members/#{member.id}/show/edit", user_with_member)
|
||||||
|
|> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only and normal_user denied on admin routes" do
|
||||||
|
test "read_only cannot access /admin/roles" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user cannot access /admin/roles" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only cannot access /members/new" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/new", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "wildcard" do
|
||||||
|
test "admin with \"*\" permission can access any page" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can access \"/members/999/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = conn_with_user("/members/999/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unauthenticated user" do
|
||||||
|
test "nil current_user is denied and redirected to \"/sign-in\"" do
|
||||||
|
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
|
"You don't have permission to access this page."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "public paths" do
|
||||||
|
test "unauthenticated user can access /auth/sign-in (no redirect)" do
|
||||||
|
conn = conn_without_user("/auth/sign-in") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unauthenticated user can access /register" do
|
||||||
|
conn = conn_without_user("/register") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error handling" do
|
||||||
|
test "user with no role is denied" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
user_without_role = %{user | role: nil}
|
||||||
|
conn = conn_with_user("/members", user_without_role) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with invalid permission_set_name is denied" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
bad_role = %{user.role | permission_set_name: "invalid_set"}
|
||||||
|
user_bad_role = %{user | role: bad_role}
|
||||||
|
conn = conn_with_user("/members", user_bad_role) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Integration: dispatch through full router (endpoint) so pipeline and load_from_session run.
|
||||||
|
# These tests ensure a Mitglied (own_data) user is denied on every forbidden path.
|
||||||
|
describe "integration: Mitglied (own_data) denied on all forbidden paths via full router" do
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members redirects to user profile with error flash", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) =~
|
||||||
|
"don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
assert user.role.permission_set_name == "own_data",
|
||||||
|
"setup must provide Mitglied (own_data) user"
|
||||||
|
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types/new redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dynamic routes need a valid path segment; use a real UUID from fixtures.
|
||||||
|
describe "integration: Mitglied denied on dynamic forbidden paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id/show/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/show/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id (unlinked member show) redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/edit redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/show/edit redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}/show/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
type =
|
||||||
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|
||||||
|
|> List.first()
|
||||||
|
|
||||||
|
if type do
|
||||||
|
conn = get(conn, "/membership_fee_types/#{type.id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups/:slug redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
assert redirected_to(get(conn, "/groups/#{slug}")) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: Mitglied (own_data) can access allowed paths via full router" do
|
||||||
|
@tag role: :member
|
||||||
|
test "GET / redirects to user profile (root not allowed for own_data)", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)"
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, user_after_update} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
|
user_with_member =
|
||||||
|
user_after_update
|
||||||
|
|> Ash.load!([:role], domain: Mv.Accounts)
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|> Map.put(:member_id, member.id)
|
||||||
|
|
||||||
|
conn = conn_with_password_user(conn, user_with_member)
|
||||||
|
|
||||||
|
conn = get(conn, "/members/#{member.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id/show/edit (linked member show edit) returns 200 when user has linked member",
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, user_after_update} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
|
user_with_member =
|
||||||
|
user_after_update
|
||||||
|
|> Ash.load!([:role], domain: Mv.Accounts)
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|> Map.put(:member_id, member.id)
|
||||||
|
|
||||||
|
conn = conn_with_password_user(conn, user_with_member)
|
||||||
|
|
||||||
|
conn = get(conn, "/members/#{member.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error).
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
|
||||||
|
user =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|> case do
|
||||||
|
{:ok, u} -> Ash.load!(u, :role, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
{:error, _} -> user
|
||||||
|
end
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> MvWeb.ConnCase.conn_with_password_user(user)
|
||||||
|
|> get("/members/#{member.id}")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# read_only (Vorstand/Buchhaltung): allowed /, /members, /members/:id, /groups, /groups/:slug
|
||||||
|
describe "integration: read_only (Vorstand/Buchhaltung) allowed paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: read_only denied paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
type =
|
||||||
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|
||||||
|
|> List.first()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug,
|
||||||
|
fee_type_id: type && type.id}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id (other user) redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
role_id: _role_id
|
||||||
|
} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/groups/#{slug}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug
|
||||||
|
describe "integration: normal_user (Kassenwart) allowed paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/new returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id/edit returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: normal_user denied paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
other_user_id: other_user.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
other_user_id: id
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/groups/#{slug}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: admin can access all protected routes via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /users returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /settings returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /membership_fee_settings returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /admin/roles returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /admin/roles/:id returns 200", %{conn: conn, role_id: id} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -175,6 +175,18 @@ defmodule MvWeb.ConnCase do
|
||||||
authenticated_conn = conn_with_password_user(conn, member_user)
|
authenticated_conn = conn_with_password_user(conn, member_user)
|
||||||
{authenticated_conn, member_user}
|
{authenticated_conn, member_user}
|
||||||
|
|
||||||
|
:read_only ->
|
||||||
|
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
|
||||||
|
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, read_only_user)
|
||||||
|
{authenticated_conn, read_only_user}
|
||||||
|
|
||||||
|
:normal_user ->
|
||||||
|
# Kassenwart: can read/update members, groups; cannot access users/settings/admin
|
||||||
|
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, normal_user)
|
||||||
|
{authenticated_conn, normal_user}
|
||||||
|
|
||||||
:unauthenticated ->
|
:unauthenticated ->
|
||||||
# No authentication for unauthenticated tests
|
# No authentication for unauthenticated tests
|
||||||
{conn, nil}
|
{conn, nil}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue