Compare commits

..

44 commits

Author SHA1 Message Date
Renovate Bot
fa5afba6ba chore(deps): update renovate/renovate docker tag to v42.95
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:51:42 +01:00
0c313824fb Merge pull request 'chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2' (#391) from renovate/ghcr.io-sebadob-rauthy-0.x into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #391
2026-02-03 19:51:09 +01:00
Renovate Bot
f45ae66f18 chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:49:48 +01:00
c2bafe4acf Merge pull request 'Apply UI Authorization to Existing LiveViews closes #400' (#403) from feature/400_ui_authorization into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #403
2026-02-03 17:30:15 +01:00
cbc9376b7b Tests: data-testid selectors, scoped delete, sidebar testid
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
Member/User auth tests use data-testid and #row-id selectors.
Sidebar auth tests assert on data-testid=sidebar-administration.
Sidebar test expects data-testid in expanded-menu-group markup.
2026-02-03 17:16:15 +01:00
ee6bfbacbb User LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:13 +01:00
a4b13cef49 Member LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:11 +01:00
286972964d CoreComponents: allow data-testid on button
Include data-testid in button rest for test selectors.
2026-02-03 17:16:10 +01:00
c36812bf3f Authorization: document can_access_page? nil-safety
Doc and example for nil user returning false.
2026-02-03 17:16:09 +01:00
2ddd22078d Sidebar: use PagePaths, add testid for Administration
Gate menu items via PagePaths; add data-testid=sidebar-administration
for stable tests. menu_group accepts optional testid attr.
2026-02-03 17:16:08 +01:00
9e8910344e Add MvWeb.PagePaths for central sidebar/page paths
Single source for path strings used by Sidebar and can_access_page?.
Keep in sync with router when routes change.
2026-02-03 17:16:07 +01:00
1426ef1d38
Add sidebar authorization tests
All checks were successful
continuous-integration/drone/push Build is passing
Assert menu visibility per role: admin, read_only, normal_user,
own_data, nil user, user without role.
2026-02-03 16:56:52 +01:00
f779fd61e0
Gate sidebar menu items by can_access_page?
Members, Fee Types and Administration subitems only shown when user
has page permission. Add admin_menu_visible? helper. Sidebar test
uses admin user so menu items render.
2026-02-03 16:56:52 +01:00
cc9e530d80
Add User LiveView authorization tests
Covers admin, read_only, member, normal_user for Index and Show.
Asserts New User / Edit / Delete visibility and redirect for non-admin.
2026-02-03 16:56:51 +01:00
2f67c7099d
Apply UI authorization to User LiveViews (Index and Show)
Gate New User button, Edit and Delete links with can?/3.
Edit button on User Show visible only when user can update the user.
2026-02-03 16:56:51 +01:00
5e361ba400
Add Member LiveView authorization tests
Covers read_only, normal_user, admin, own_data for Index and Show.
Asserts New Member / Edit / Delete visibility and redirect for Mitglied.
2026-02-03 16:56:51 +01:00
505e31653a
Apply UI authorization to Member LiveViews (Index and Show)
Gate New Member button, Edit and Delete links with can?/3.
Edit button on Member Show visible only when user can update the member.
2026-02-03 16:56:51 +01:00
d3ad7c5013 Merge pull request 'Member Email Validation for Linked Members closes #397' (#399) from feature/397_emailsync_permission into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #399
2026-02-03 16:35:40 +01:00
131904f172
Test: assert on error field :email instead of message string
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-02-03 16:07:47 +01:00
47b6a16177
Doc: Actor maybe_load_role comment; ActorIsAdmin system user = admin 2026-02-03 16:07:39 +01:00
60a4181255
Validation: error message admin or linked user; resolve_actor fallback 2026-02-03 16:07:26 +01:00
4e6b7305b6
Doc: Loader auth-independent for link checks; email-sync rule rationale 2026-02-03 16:07:13 +01:00
4ea31f0f37 Add email-change permission validation for linked members
All checks were successful
continuous-integration/drone/push Build is passing
Only admins or the linked user may change a linked member's email.
- New validation EmailChangePermission (uses Actor.admin?, Loader.get_linked_user).
- Register on Member update_member; docs and gettext.
2026-02-03 14:35:32 +01:00
ad02f8914f Use EmailSync.Loader.get_linked_user in EmailNotUsedByOtherUser
Remove duplicate get_linked_user_id; reuse Loader for linked user lookup.
2026-02-03 14:35:08 +01:00
3d46ba655f Add Actor.permission_set_name/1 and admin?/1 for consistent capability checks
- Actor.permission_set_name(actor) returns role's permission set (supports nil role load).
- Actor.admin?(actor) returns true for system user or admin permission set.
- ActorIsAdmin policy check delegates to Actor.admin?/1.
2026-02-03 14:34:24 +01:00
c998d14b95 Merge pull request 'Implements custom field CSV import closes #338' (#395) from feature/338_import_custom_fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #395
2026-02-02 17:05:29 +01:00
960506d16a refactoring
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-02 16:56:07 +01:00
aef3aa299f fix test
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-02 15:04:07 +01:00
b21c3df7ef refactoring 2026-02-02 14:34:12 +01:00
71db9cf3c1 formatting
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 13:54:27 +01:00
9e27de84cb Merge branch 'main' into feature/338_import_custom_fields
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 13:46:05 +01:00
c56ca68922 docs: update docs
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 13:42:24 +01:00
f5591c392a i18n: add translation 2026-02-02 13:42:16 +01:00
aab5666f46 Merge pull request 'Adds config for import limits closes #336' (#394) from feature/336_import_auth into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #394
2026-02-02 13:15:22 +01:00
12715f3d85 refactoring 2026-02-02 13:07:08 +01:00
86a3c4e50e tests: add tests for import 2026-02-02 13:07:00 +01:00
3f8797c356 feat: import custom fields via CSV 2026-02-02 11:42:07 +01:00
ce6240133d i18n: update translations
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-02-02 10:23:49 +01:00
4997819c73 feat: validate config 2026-02-02 10:22:21 +01:00
b6d53d2826 refactor: add test to seperate async false module 2026-02-02 10:22:05 +01:00
e74154581c feat: changes UI info based on config for limits 2026-02-02 10:10:02 +01:00
d61a939deb formatting
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 09:50:47 +01:00
3f551c5f8d feat: add configs for impor tlimits
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 09:49:13 +01:00
9fd617e45a tests: add tests for config 2026-02-02 09:48:37 +01:00
33 changed files with 1885 additions and 342 deletions

View file

@ -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"],

View file

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

View file

@ -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

View file

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

View file

@ -25,6 +25,7 @@ defmodule Mv.Membership.Member do
- Postal code format: exactly 5 digits (German format) - Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date - Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users - Email uniqueness: prevents conflicts with unlinked users
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
## Full-Text Search ## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically Members have a `search_vector` attribute (tsvector) that is automatically
@ -381,6 +382,9 @@ defmodule Mv.Membership.Member do
# Validates that member email is not already used by another (unlinked) user # Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Only admins or the linked user may change a linked member's email (prevents breaking sync)
validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
# Prevent linking to a user that already has a member # Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking # This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member # if the target user is already linked to a different member

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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,8 +522,33 @@ 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}}
{:ok, custom_field_values} ->
create_member_with_custom_fields(
trimmed_member_attrs,
custom_field_values,
line_number,
actor
)
end
end
rescue
e ->
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
end
# Creates a member with custom field values, handling errors appropriately
defp create_member_with_custom_fields(
trimmed_member_attrs,
custom_field_values,
line_number,
actor
) do
# Create member with custom field values # Create member with custom field values
member_attrs_with_cf = member_attrs_with_cf =
trimmed_member_attrs trimmed_member_attrs
@ -536,76 +575,208 @@ defmodule Mv.Membership.Import.MemberCSV do
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}} {:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
end end
end end
rescue
e ->
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
end
# Prepares custom field values from row map for Ash # 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 defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
{values, errors} =
custom_attrs custom_attrs
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end) |> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|> Enum.map(fn {custom_field_id_str, value} -> |> 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 case Map.get(custom_field_lookup, custom_field_id_str) do
nil -> nil ->
# Custom field not found, skip # Custom field not found, skip
nil {acc_values, acc_errors}
%{id: custom_field_id, value_type: value_type} -> %{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), "custom_field_id" => to_string(custom_field_id),
"value" => format_custom_field_value(value, value_type) "value" => formatted_value
} }
end
end)
|> Enum.filter(&(&1 != nil))
end
defp prepare_custom_field_values(_, _), do: [] {[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

View file

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

View file

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

View file

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

View file

@ -97,12 +97,13 @@ defmodule MvWeb.CoreComponents do
<.button navigate={~p"/"}>Home</.button> <.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button> <.button disabled={true}>Disabled</.button>
""" """
attr :rest, :global, include: ~w(href navigate patch method) attr :rest, :global, include: ~w(href navigate patch method data-testid)
attr :variant, :string, values: ~w(primary) attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true slot :inner_block, required: true
def button(%{rest: rest} = assigns) do def button(assigns) do
rest = assigns.rest
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))

View file

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

View file

@ -50,9 +50,9 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
<%!-- Hide table when form is visible --%> <%!-- Hide table when form is visible --%>
<div :if={!@show_form} id="custom_fields">
<.table <.table
:if={!@show_form} id="custom_fields_table"
id="custom_fields"
rows={@streams.custom_fields} rows={@streams.custom_fields}
row_click={ row_click={
fn {_id, custom_field} -> fn {_id, custom_field} ->
@ -105,11 +105,14 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</:action> </:action>
<:action :let={{_id, custom_field}}> <:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}> <.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")} {gettext("Delete")}
</.link> </.link>
</:action> </:action>
</.table> </.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">

View file

@ -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) ->

View file

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

View file

@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)} {MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1> </h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> <%= if can?(@current_user, :update, @member) do %>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit Member")} {gettext("Edit Member")}
</.button> </.button>
<% end %>
</div> </div>
<%!-- Tab Navigation --%> <%!-- Tab Navigation --%>

View file

@ -2,13 +2,20 @@
<.header> <.header>
{gettext("Listing Users")} {gettext("Listing Users")}
<:actions> <:actions>
<.button variant="primary" navigate={~p"/users/new"}> <%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
<.icon name="hero-plus" /> {gettext("New User")} <.icon name="hero-plus" /> {gettext("New User")}
</.button> </.button>
<% end %>
</:actions> </:actions>
</.header> </.header>
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}> <.table
id="users"
rows={@users}
row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
>
<:col <:col
:let={user} :let={user}
label={ label={
@ -62,16 +69,23 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link> <.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div> </div>
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link> <%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action> </:action>
<:action :let={user}> <:action :let={user}>
<%= if can?(@current_user, :destroy, user) do %>
<.link <.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")} phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
data-testid="user-delete"
> >
{gettext("Delete")} {gettext("Delete")}
</.link> </.link>
<% end %>
</:action> </:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -41,9 +41,15 @@ defmodule MvWeb.UserLive.Show do
<.icon name="hero-arrow-left" /> <.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span> <span class="sr-only">{gettext("Back to users list")}</span>
</.button> </.button>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}> <%= if can?(@current_user, :update, @user) do %>
<.button
variant="primary"
navigate={~p"/users/#{@user}/edit?return_to=show"}
data-testid="user-edit"
>
<.icon name="hero-pencil-square" /> {gettext("Edit User")} <.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button> </.button>
<% end %>
</:actions> </:actions>
</.header> </.header>

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

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

View file

@ -1980,16 +1980,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:"
@ -2120,11 +2110,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"
@ -2272,3 +2257,48 @@ msgstr "Nicht berechtigt."
#, 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 "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/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/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind."

View file

@ -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 #, elixir-autogen, elixir-format
msgid "Warnings" msgid "Warnings"
@ -2273,3 +2258,48 @@ 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
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 ""
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr ""

View file

@ -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,48 @@ 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/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Only administrators or the linked user can change the email for members linked to users"

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

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