Merge remote-tracking branch 'origin/main' into sidebar

This commit is contained in:
Simon 2026-01-12 14:15:12 +01:00
commit e7515b5450
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
83 changed files with 8084 additions and 1276 deletions

View file

@ -4,7 +4,7 @@ name: check
services:
- name: postgres
image: docker.io/library/postgres:17.7
image: docker.io/library/postgres:18.1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -57,7 +57,7 @@ steps:
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:17.7
image: docker.io/library/postgres:18.1
commands:
# Wait for postgres to become available
- |
@ -166,7 +166,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:42.44
image: renovate/renovate:42.71
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.45.0
just 1.46.0

View file

@ -49,7 +49,7 @@ config :spark,
config :mv,
ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime],
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees]
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
# Configures the endpoint
config :mv, MvWeb.Endpoint,

View file

@ -33,7 +33,7 @@ services:
restart: unless-stopped
db-prod:
image: postgres:17.7-alpine
image: postgres:18.1-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services:
db:
image: postgres:17.7-alpine
image: postgres:18.1-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -29,7 +29,7 @@ services:
rauthy:
container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.33.1
image: ghcr.io/sebadob/rauthy:0.33.4
environment:
- LOCAL_TEST=true
- SMTP_URL=mailcrab

View file

@ -0,0 +1,666 @@
# CSV Member Import v1 - Implementation Plan
**Version:** 1.0
**Date:** 2025-01-XX
**Status:** Ready for Implementation
**Related Documents:**
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
---
## Table of Contents
- [Overview & Scope](#overview--scope)
- [UX Flow](#ux-flow)
- [CSV Specification](#csv-specification)
- [Technical Design Notes](#technical-design-notes)
- [Implementation Issues](#implementation-issues)
- [Rollout & Risks](#rollout--risks)
---
## Overview & Scope
### What We're Building
A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features.
**Core Functionality (v1 Minimal):**
- Upload CSV file via LiveView file upload
- Parse CSV with bilingual header support for core member fields (English/German)
- Auto-detect delimiter (`;` or `,`) using header recognition
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
- Validate each row (required field: `email`)
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
- Display import results: success count, error count, and error details
- Provide static CSV templates (EN/DE)
**Key Constraints (v1):**
- ✅ **Admin-only feature**
- ✅ **No upsert** (create only)
- ✅ **No deduplication** (duplicate emails fail and show as errors)
- ✅ **No mapping wizard** (fixed header mapping via bilingual variants)
- ✅ **No background jobs** (progress via LiveView `handle_info`)
- ✅ **Best-effort import** (row-by-row, no rollback)
- ✅ **UI-only error display** (no error CSV export)
- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200)
### Out of Scope (v1)
**Deferred to Future Versions:**
- ❌ Upsert/update existing members
- ❌ Advanced deduplication strategies
- ❌ Column mapping wizard UI
- ❌ Background job processing (Oban/GenStage)
- ❌ Transactional all-or-nothing import
- ❌ Error CSV export/download
- ❌ Batch validation preview before import
- ❌ Dynamic template generation
- ❌ Import history/audit log
- ❌ Import templates for other entities
---
## UX Flow
### Access & Location
**Entry Point:**
- **Location:** Global Settings page (`/settings`)
- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section
- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route)
### User Journey
1. **Navigate to Global Settings**
2. **Access Import Section**
- **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning)
- Upload area (drag & drop or file picker)
- Template download links (English / German)
- Help text explaining CSV format and custom field requirements
3. **Ensure Custom Fields Exist (if importing custom fields)**
- Navigate to Custom Fields section and create required custom fields
- Note the name/identifier for each custom field (used as CSV header)
4. **Download Template (Optional)**
5. **Prepare CSV File**
- Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`)
6. **Upload CSV**
7. **Start Import**
- Runs server-side via LiveView messages (may take up to ~30 seconds for large files)
- Warning messages if custom field columns reference non-existent custom fields (columns will be ignored)
8. **View Results**
- Success count
- Error count
- First 50 errors, each with:
- **CSV line number** (header is line 1, first data record begins at line 2)
- Error message
- Field name (if applicable)
### Error Handling
- **File too large:** Flash error before upload starts
- **Too many rows:** Flash error before import starts
- **Invalid CSV format:** Error shown in results
- **Partial success:** Results show both success and error counts
---
## CSV Specification
### Delimiter
**Recommended:** Semicolon (`;`)
**Supported:** `;` and `,`
**Auto-Detection (Header Recognition):**
- Remove UTF-8 BOM *first*
- Extract header record and try parsing with both delimiters
- For each delimiter, count how many recognized headers are present (via normalized variants)
- Choose delimiter with higher recognition; prefer `;` if tied
- If neither yields recognized headers, default to `;`
### Quoting Rules
- Fields may be quoted with double quotes (`"`)
- Escaped quotes: `""` inside quoted field represents a single `"`
- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.)
### Column Headers
**v1 Supported Fields:**
**Core Member Fields:**
- `first_name` / `Vorname` (optional)
- `last_name` / `Nachname` (optional)
- `email` / `E-Mail` (required)
- `street` / `Straße` (optional)
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
- `city` / `Stadt` (optional)
**Custom Fields:**
- 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).
- **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.
**Member Field Header Mapping:**
| Canonical Field | English Variants | German Variants |
|---|---|---|
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
**Header Normalization (used consistently for both input headers AND mapping variants):**
- Trim whitespace
- Convert to lowercase
- Normalize Unicode: `ß``ss` (e.g., `Straße``strasse`)
- Replace hyphens/whitespace with underscores: `E-Mail``e_mail`, `phone number``phone_number`
- Collapse multiple underscores: `e__mail``e_mail`
- Case-insensitive matching
**Unknown columns:** ignored (no error)
**Required fields:** `email`
**Custom Field Columns:**
- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug)
- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement)
- Unknown custom field columns (non-existent names) will be ignored with a warning message
### CSV Template Files
**Location:**
- `priv/static/templates/member_import_en.csv`
- `priv/static/templates/member_import_de.csv`
**Content:**
- Header row with required + common optional fields
- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration)
- One example row
- Uses semicolon delimiter (`;`)
- UTF-8 encoding **with BOM** (Excel compatibility)
**Template Access:**
- Templates are static files in `priv/static/templates/`
- Served at:
- `/templates/member_import_en.csv`
- `/templates/member_import_de.csv`
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
### File Limits
- **Max file size:** 10 MB
- **Max rows:** 1,000 rows (excluding header)
- **Processing:** chunks of 200 (via LiveView messages)
- **Encoding:** UTF-8 (BOM handled)
---
## Technical Design Notes
### Architecture Overview
```
┌─────────────────┐
│ LiveView UI │ (GlobalSettingsLive or component)
│ - Upload area │
│ - Progress │
│ - Results │
└────────┬────────┘
│ prepare
┌─────────────────────────────┐
│ Import Service │ (Mv.Membership.Import.MemberCSV)
│ - parse + map + limit checks│ -> returns import_state
│ - process_chunk(chunk) │ -> returns chunk results
└────────┬────────────────────┘
│ create
┌─────────────────┐
│ Ash Resource │ (Mv.Membership.Member)
│ - Create │
└─────────────────┘
```
### Technology Stack
- **Phoenix LiveView:** file upload via `allow_upload/3`
- **NimbleCSV:** CSV parsing (add explicit dependency if missing)
- **Ash Resource:** member creation via `Membership.create_member/1`
- **Gettext:** bilingual UI/error messages
### Module Structure
**New Modules:**
- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling
- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling
- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields)
**Modified Modules:**
- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages
### Data Flow
1. **Upload:** LiveView receives file via `allow_upload`
2. **Consume:** `consume_uploaded_entries/3` reads file content
3. **Prepare:** `MemberCSV.prepare/2`
- Strip BOM
- Detect delimiter (header recognition)
- Parse header + rows
- Map headers to canonical fields (core member fields)
- **Query existing custom fields and map custom field columns by name** (using same normalization as member fields)
- **Warn about unknown custom field columns** (non-existent names will be ignored with warning)
- Early abort if required headers missing
- Row count check
- Return `import_state` containing chunks, column_map, and custom_field_map
4. **Process:** LiveView drives chunk processing via `handle_info`
- For each chunk: validate + create member + create custom field values + collect errors
5. **Results:** LiveView shows progress + final summary
### Types & Key Consistency
- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers**
- **Header mapping:** operates on normalized strings; mapping table variants are normalized once
- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`)
### Error Model
```elixir
%{
csv_line_number: 5, # physical line number in the CSV file
field: :email, # optional
message: "is not a valid email"
}
```
### CSV Line Numbers (Important)
To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped.
**Design decision:** the parser returns rows as:
```elixir
rows :: [{csv_line_number :: pos_integer(), row_map :: map()}]
```
Downstream logic must **not** recompute line numbers from row indexes.
### Authorization
**Enforcement points:**
1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)`
2. **UI level:** render import section only for admin users
3. **Static templates:** public assets (no authorization needed)
Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible.
### Safety Limits
- File size enforced by `allow_upload` (`max_file_size`)
- Row count enforced in `MemberCSV.prepare/2` before processing starts
- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling)
---
## Implementation Issues
### Issue #1: CSV Specification & Static Template Files
**Dependencies:** None
**Goal:** Define CSV contract and add static templates.
**Tasks:**
- [ ] Finalize header mapping variants
- [ ] Document normalization rules
- [ ] Document delimiter detection strategy
- [ ] Create templates in `priv/static/templates/` (UTF-8 with BOM)
- [ ] Document template URLs and how to link them from LiveView
- [ ] Document line number semantics (physical CSV line numbers)
**Definition of Done:**
- [ ] Templates open cleanly in Excel/LibreOffice
- [ ] CSV spec section complete
---
### Issue #2: Import Service Module Skeleton
**Dependencies:** None
**Goal:** Create service API and error types.
**API (recommended):**
- `prepare/2` — parse + map + limit checks, returns import_state
- `process_chunk/3` — process one chunk (pure-ish), returns per-chunk results
**Tasks:**
- [ ] Create `lib/mv/membership/import/member_csv.ex`
- [ ] Define public function: `prepare/2 (file_content, opts \\ [])`
- [ ] Define public function: `process_chunk/3 (chunk_rows_with_lines, column_map, opts \\ [])`
- [ ] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
- [ ] Document module + API
---
### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
**Dependencies:** Issue #2
**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling.
**Tasks:**
- [ ] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
- [ ] Create `lib/mv/membership/import/csv_parser.ex`
- [ ] Implement `strip_bom/1` and apply it **before** any header handling
- [ ] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
- [ ] Detect delimiter via header recognition (try `;` and `,`)
- [ ] Parse CSV and return:
- `headers :: [String.t()]`
- `rows :: [{csv_line_number, [String.t()]}]` or directly `[{csv_line_number, row_map}]`
- [ ] Skip completely empty records (but preserve correct physical line numbers)
- [ ] Return `{:ok, headers, rows}` or `{:error, reason}`
**Definition of Done:**
- [ ] BOM handling works (Excel exports)
- [ ] Delimiter detection works reliably
- [ ] Rows carry correct `csv_line_number`
---
### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection)
**Dependencies:** Issue #3
**Goal:** Map each header individually to canonical fields (normalized comparison).
**Tasks:**
- [ ] Create `lib/mv/membership/import/header_mapper.ex`
- [ ] Implement `normalize_header/1`
- [ ] Normalize mapping variants once and compare normalized strings
- [ ] Build `column_map` (canonical field -> column index)
- [ ] **Early abort if required headers missing** (`email`)
- [ ] Ignore unknown columns (member fields only)
- [ ] **Separate custom field column detection** (by name, with normalization)
**Definition of Done:**
- [ ] English/German headers map correctly
- [ ] Missing required columns fails fast
---
### Issue #5: Validation (Required Fields) + Error Formatting
**Dependencies:** Issue #4
**Goal:** Validate each row and return structured, translatable errors.
**Tasks:**
- [ ] Implement `validate_row/3 (row_map, csv_line_number, opts)`
- [ ] Required field presence (`email`)
- [ ] Email format validation (EctoCommons.EmailValidator)
- [ ] Trim values before validation
- [ ] Gettext-backed error messages
---
### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing)
**Dependencies:** Issue #5
**Goal:** Create members and capture errors per row with correct CSV line numbers.
**Tasks:**
- [ ] Implement `process_chunk/3` in service:
- Input: `[{csv_line_number, row_map}]`
- Validate + create sequentially
- Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks)
- [ ] Implement Ash error formatter helper:
- Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}`
- Prefer field-level errors where possible (attach `field` atom)
- Handle unique email constraint error as user-friendly message
- [ ] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser.
---
### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
**Dependencies:** Issue #6
**Goal:** UI section with upload, progress, results, and template links.
**Tasks:**
- [ ] Render import section only for admins
- [ ] **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"
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
- Add link to custom fields management section
- [ ] Configure `allow_upload/3`:
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: false`
- [ ] `handle_event("start_import", ...)`:
- Admin permission check
- Consume upload -> read file content
- Call `MemberCSV.prepare/2`
- Store `import_state` in assigns (chunks + column_map + metadata)
- Initialize progress assigns
- `send(self(), {:process_chunk, 0})`
- [ ] `handle_info({:process_chunk, idx}, socket)`:
- Fetch chunk from `import_state`
- Call `MemberCSV.process_chunk/3`
- Merge counts/errors into progress assigns (cap errors at 50 overall)
- Schedule next chunk (or finish and show results)
- [ ] Results UI:
- Success count
- Failure count
- Error list (line number + message + field)
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
**Template links:**
- Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
---
### Issue #8: Authorization + Limits
**Dependencies:** None (can be parallelized)
**Goal:** Ensure admin-only access and enforce limits.
**Tasks:**
- [ ] Admin check in start import event handler
- [ ] File size enforced in upload config
- [ ] Row limit enforced in `MemberCSV.prepare/2` (max_rows from config)
- [ ] Configuration:
```elixir
config :mv, csv_import: [
max_file_size_mb: 10,
max_rows: 1000
]
```
---
### Issue #9: End-to-End LiveView Tests + Fixtures
**Dependencies:** Issue #7 and #8
**Tasks:**
- [ ] Fixtures:
- valid EN/DE (core fields only)
- valid with custom fields
- invalid
- unknown custom field name (non-existent, should show warning)
- too many rows (1,001)
- BOM + `;` delimiter fixture
- fixture with empty line(s) to validate correct line numbers
- [ ] LiveView tests:
- admin sees section, non-admin does not
- upload + start import
- success + error rendering
- row limit + file size errors
- custom field import success
- custom field import warning (non-existent name, column ignored)
---
### Issue #10: Documentation Polish (Inline Help Text + Docs)
**Dependencies:** Issue #9
**Tasks:**
- [ ] UI help text + translations
- [ ] CHANGELOG entry
- [ ] Ensure moduledocs/docs
---
### Issue #11: Custom Field Import
**Dependencies:** Issue #6 (Persistence)
**Priority:** High (Core v1 Feature)
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
**Important Requirements:**
- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message
- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies)
- Custom field values are validated according to the custom field type (string, integer, boolean, date, email)
- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues
**Tasks:**
- [ ] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
- [ ] Query existing custom fields during `prepare/2` to map custom field columns
- [ ] Collect unknown custom field columns and add warning messages (don't fail import)
- [ ] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/3`
- [ ] Handle custom field type validation (string, integer, boolean, date, email)
- [ ] Create `CustomFieldValue` records linked to members during import
- [ ] Update error messages to include custom field validation errors
- [ ] Add UI help text explaining custom field requirements:
- "Custom fields must be created in Mila before importing"
- "Use the custom field name as the CSV column header (same normalization as member fields)"
- Link to custom fields management section
- [ ] Update CSV templates documentation to explain custom field columns
- [ ] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
**Definition of Done:**
- [ ] Custom field columns are recognized by name (with normalization)
- [ ] Warning messages shown for unknown custom field columns (import continues)
- [ ] Custom field values are created and linked to members
- [ ] Type validation works for all custom field types
- [ ] UI clearly explains custom field requirements
- [ ] Tests cover custom field import scenarios (including warning for unknown names)
---
## Rollout & Risks
### Rollout Strategy
- Dev → Staging → Production (with anonymized real-world CSV tests)
### Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|---|---:|---:|---|
| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` |
| Encoding issues | Medium | Medium | BOM stripping, templates with BOM |
| Invalid CSV format | Medium | High | Clear errors + templates |
| Duplicate emails | Low | High | Ash constraint error -> user-friendly message |
| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing |
| Admin access bypass | High | Low | Event-level auth + UI hiding |
| Data corruption | High | Low | Per-row validation + best-effort |
---
## Appendix
### Module File Structure
```
lib/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv.ex # prepare + process_chunk
│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling
│ └── header_mapper.ex # normalization + header mapping
└── mv_web/
└── live/
└── global_settings_live.ex # add import section + LV message loop
priv/
└── static/
└── templates/
├── member_import_en.csv
└── member_import_de.csv
test/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv_test.exs
│ ├── csv_parser_test.exs
│ └── header_mapper_test.exs
└── fixtures/
├── member_import_en.csv
├── member_import_de.csv
├── member_import_invalid.csv
├── member_import_large.csv
└── member_import_empty_lines.csv
```
### Example Usage (LiveView)
```elixir
def handle_event("start_import", _params, socket) do
assert_admin!(socket.assigns.current_user)
[{_name, content}] =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case Mv.Membership.Import.MemberCSV.prepare(content) do
{:ok, import_state} ->
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []})
|> assign(:importing?, true)
send(self(), {:process_chunk, 0})
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
def handle_info({:process_chunk, idx}, socket) do
%{chunks: chunks, column_map: column_map} = socket.assigns.import_state
case Enum.at(chunks, idx) do
nil ->
{:noreply, assign(socket, importing?: false)}
chunk_rows_with_lines ->
{:ok, chunk_result} =
Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map)
socket = merge_progress(socket, chunk_result) # caps errors at 50 overall
send(self(), {:process_chunk, idx + 1})
{:noreply, socket}
end
end
```
---
**End of Implementation Plan**

View file

@ -93,8 +93,8 @@ Five predefined roles stored in the `roles` table:
Control CRUD operations on:
- User (credentials, profile)
- Member (member data)
- Property (custom field values)
- PropertyType (custom field definitions)
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- Role (role management)
**4. Page-Level Permissions**
@ -111,7 +111,7 @@ Three scope levels for permissions:
- **:own** - Only records where `record.id == user.id` (for User resource)
- **:linked** - Only records linked to user via relationships
- Member: `member.user_id == user.id`
- Property: `property.member.user_id == user.id`
- CustomFieldValue: `custom_field_value.member.user_id == user.id`
- **:all** - All records, no filtering
**6. Special Cases**
@ -414,7 +414,7 @@ defmodule Mv.Authorization.PermissionSets do
## Permission Sets
1. **own_data** - Default for "Mitglied" role
- Can only access own user data and linked member/properties
- Can only access own user data and linked member/custom field values
- Cannot create new members or manage system
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
@ -423,11 +423,11 @@ defmodule Mv.Authorization.PermissionSets do
3. **normal_user** - For "Kassenwart" role
- Create/Read/Update members (no delete), full CRUD on properties
- Cannot manage property types or users
- Cannot manage custom fields or users
4. **admin** - For "Admin" role
- Unrestricted access to all resources
- Can manage users, roles, property types
- Can manage users, roles, custom fields
## Usage
@ -500,12 +500,12 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :read, scope: :linked, granted: true},
%{resource: "Member", action: :update, scope: :linked, granted: true},
# Property: Can read/update properties of linked member
%{resource: "Property", action: :read, scope: :linked, granted: true},
%{resource: "Property", action: :update, scope: :linked, granted: true},
# CustomFieldValue: Can read/update custom field values of linked member
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
# PropertyType: Can read all (needed for forms)
%{resource: "PropertyType", action: :read, scope: :all, granted: true}
# CustomField: Can read all (needed for forms)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/", # Home page
@ -525,17 +525,17 @@ defmodule Mv.Authorization.PermissionSets do
# Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true},
# Property: Can read all properties
%{resource: "Property", action: :read, scope: :all, granted: true},
# CustomFieldValue: Can read all custom field values
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# PropertyType: Can read all
%{resource: "PropertyType", action: :read, scope: :all, granted: true}
# CustomField: Can read all
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
"/members", # Member list
"/members/:id", # Member detail
"/properties", # Property overview
"/custom_field_values" # Custom field values overview
"/profile" # Own profile
]
}
@ -554,14 +554,14 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :update, scope: :all, granted: true},
# Note: destroy intentionally omitted for safety
# Property: Full CRUD
%{resource: "Property", action: :read, scope: :all, granted: true},
%{resource: "Property", action: :create, scope: :all, granted: true},
%{resource: "Property", action: :update, scope: :all, granted: true},
%{resource: "Property", action: :destroy, scope: :all, granted: true},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# PropertyType: Read only (admin manages definitions)
%{resource: "PropertyType", action: :read, scope: :all, granted: true}
# CustomField: Read only (admin manages definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
@ -569,9 +569,9 @@ defmodule Mv.Authorization.PermissionSets do
"/members/new", # Create member
"/members/:id",
"/members/:id/edit", # Edit member
"/properties",
"/properties/new",
"/properties/:id/edit",
"/custom_field_values",
"/custom_field_values/new",
"/custom_field_values/:id/edit",
"/profile"
]
}
@ -592,17 +592,17 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :update, scope: :all, granted: true},
%{resource: "Member", action: :destroy, scope: :all, granted: true},
# Property: Full CRUD
%{resource: "Property", action: :read, scope: :all, granted: true},
%{resource: "Property", action: :create, scope: :all, granted: true},
%{resource: "Property", action: :update, scope: :all, granted: true},
%{resource: "Property", action: :destroy, scope: :all, granted: true},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# PropertyType: Full CRUD (admin manages custom field definitions)
%{resource: "PropertyType", action: :read, scope: :all, granted: true},
%{resource: "PropertyType", action: :create, scope: :all, granted: true},
%{resource: "PropertyType", action: :update, scope: :all, granted: true},
%{resource: "PropertyType", action: :destroy, scope: :all, granted: true},
# CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
# Role: Full CRUD (admin manages roles)
%{resource: "Role", action: :read, scope: :all, granted: true},
@ -677,9 +677,9 @@ Quick reference table showing what each permission set allows:
| **User** (all) | - | - | - | R, C, U, D |
| **Member** (linked) | R, U | - | - | - |
| **Member** (all) | - | R | R, C, U | R, C, U, D |
| **Property** (linked) | R, U | - | - | - |
| **Property** (all) | - | R | R, C, U, D | R, C, U, D |
| **PropertyType** (all) | R | R | R | R, C, U, D |
| **CustomFieldValue** (linked) | R, U | - | - | - |
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D |
**Legend:** R=Read, C=Create, U=Update, D=Destroy
@ -715,7 +715,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user_id == actor.id
- Property: property.member.user_id == actor.id (traverses relationship!)
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
## Error Handling
@ -802,8 +802,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Member.user_id == actor.id (direct relationship)
{:filter, expr(user_id == ^actor.id)}
"Property" ->
# Property.member.user_id == actor.id (traverse through member!)
"CustomFieldValue" ->
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
{:filter, expr(member.user_id == ^actor.id)}
_ ->
@ -832,7 +832,7 @@ end
**Key Design Decisions:**
1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id`
1. **Resource-Specific :linked Scope:** CustomFieldValue needs to traverse `member` relationship to check `user_id`
2. **Error Handling:** All errors log for debugging but return generic forbidden to user
3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings
4. **Pure Function:** No side effects, deterministic, easily testable
@ -966,21 +966,21 @@ end
*Email editing has additional validation (see Special Cases)
### Property Resource Policies
### CustomFieldValue Resource Policies
**Location:** `lib/mv/membership/property.ex`
**Location:** `lib/mv/membership/custom_field_value.ex`
**Special Case:** Users can access properties of their linked member.
**Special Case:** Users can access custom field values of their linked member.
```elixir
defmodule Mv.Membership.Property do
defmodule Mv.Membership.CustomFieldValue do
use Ash.Resource, ...
policies do
# SPECIAL CASE: Users can access properties of their linked member
# SPECIAL CASE: Users can access custom field values of their linked member
# Note: This traverses the member relationship!
policy action_type([:read, :update]) do
description "Users can access properties of their linked member"
description "Users can access custom field values of their linked member"
authorize_if expr(member.user_id == ^actor(:id))
end
@ -1010,18 +1010,18 @@ end
| Create | ❌ | ❌ | ✅ | ❌ | ✅ |
| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ |
### PropertyType Resource Policies
### CustomField Resource Policies
**Location:** `lib/mv/membership/property_type.ex`
**Location:** `lib/mv/membership/custom_field.ex`
**No Special Cases:** All users can read, only admin can write.
```elixir
defmodule Mv.Membership.PropertyType do
defmodule Mv.Membership.CustomField do
use Ash.Resource, ...
policies do
# All authenticated users can read property types (needed for forms)
# All authenticated users can read custom fields (needed for forms)
# Write operations are admin-only
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
@ -1308,12 +1308,12 @@ end
- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles`
**Vorstand (read_only):**
- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile`
- ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/profile`
- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles`
**Kassenwart (normal_user):**
- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile`
- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new`
- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/profile`
- ❌ Cannot access: `/admin/roles`, `/admin/custom_fields/new`
**Admin:**
- ✅ Can access: `*` (all pages, including `/admin/roles`)
@ -1479,9 +1479,9 @@ defmodule MvWeb.Authorization do
# Direct relationship: member.user_id
Map.get(record, :user_id) == user.id
"Property" ->
# Need to traverse: property.member.user_id
# Note: In UI, property should have member preloaded
"CustomFieldValue" ->
# Need to traverse: custom_field_value.member.user_id
# Note: In UI, custom_field_value should have member preloaded
case Map.get(record, :member) do
%{user_id: member_user_id} -> member_user_id == user.id
_ -> false
@ -1569,7 +1569,7 @@ end
<span>Admin</span>
<ul>
<li><.link navigate="/admin/roles">Roles</.link></li>
<li><.link navigate="/admin/property_types">Property Types</.link></li>
<li><.link navigate="/admin/custom_fields">Custom Fields</.link></li>
</ul>
</div>
<% end %>
@ -2409,8 +2409,8 @@ The `HasPermission` check extracts resource names via `Module.split() |> List.la
|------------|------------------------|
| `Mv.Accounts.User` | "User" |
| `Mv.Membership.Member` | "Member" |
| `Mv.Membership.Property` | "Property" |
| `Mv.Membership.PropertyType` | "PropertyType" |
| `Mv.Membership.CustomFieldValue` | "CustomFieldValue" |
| `Mv.Membership.CustomField` | "CustomField" |
| `Mv.Authorization.Role` | "Role" |
These strings must match exactly in `PermissionSets` module.
@ -2450,7 +2450,7 @@ These strings must match exactly in `PermissionSets` module.
**Integration:**
- [ ] One complete user journey per role
- [ ] Cross-resource scenarios (e.g., Member -> Property)
- [ ] Cross-resource scenarios (e.g., Member -> CustomFieldValue)
- [ ] Special cases in context (e.g., linked member email during full edit flow)
### Useful Commands

View file

@ -53,7 +53,7 @@ This document defines the implementation plan for the **MVP (Phase 1)** of the R
Hardcoded in `Mv.Authorization.PermissionSets` module:
1. **own_data** - User can only access their own data (default for "Mitglied")
2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung")
2. **read_only** - Read access to all members/custom field values (for "Vorstand", "Buchhaltung")
3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
4. **admin** - Unrestricted access including user/role management (for "Admin")
@ -77,7 +77,7 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
- ✅ Hardcoded PermissionSets module with 4 permission sets
- ✅ Role database table and CRUD interface
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
- ✅ Policies on all resources (Member, User, Property, PropertyType, Role)
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
- ✅ Page-level permissions via Phoenix Plug
- ✅ UI authorization helpers for conditional rendering
- ✅ Special case: Member email validation for linked users
@ -228,32 +228,32 @@ Create the core `PermissionSets` module that defines all four permission sets wi
- Resources:
- User: read/update :own
- Member: read/update :linked
- Property: read/update :linked
- PropertyType: read :all
- CustomFieldValue: read/update :linked
- CustomField: read :all
- Pages: `["/", "/profile", "/members/:id"]`
**2. read_only (Vorstand, Buchhaltung):**
- Resources:
- User: read :own, update :own
- Member: read :all
- Property: read :all
- PropertyType: read :all
- Pages: `["/", "/members", "/members/:id", "/properties"]`
- CustomFieldValue: read :all
- CustomField: read :all
- Pages: `["/", "/members", "/members/:id", "/custom_field_values"]`
**3. normal_user (Kassenwart):**
- Resources:
- User: read/update :own
- Member: read/create/update :all (no destroy for safety)
- Property: read/create/update/destroy :all
- PropertyType: read :all
- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]`
- CustomFieldValue: read/create/update/destroy :all
- CustomField: read :all
- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/custom_field_values", "/custom_field_values/new", "/custom_field_values/:id/edit"]`
**4. admin:**
- Resources:
- User: read/update/destroy :all
- Member: read/create/update/destroy :all
- Property: read/create/update/destroy :all
- PropertyType: read/create/update/destroy :all
- CustomFieldValue: read/create/update/destroy :all
- CustomField: read/create/update/destroy :all
- Role: read/create/update/destroy :all
- Pages: `["*"]` (wildcard = all pages)
@ -276,10 +276,10 @@ Create the core `PermissionSets` module that defines all four permission sets wi
**Permission Content Tests:**
- `:own_data` allows User read/update with scope :own
- `:own_data` allows Member/Property read/update with scope :linked
- `:read_only` allows Member/Property read with scope :all
- `:read_only` does NOT allow Member/Property create/update/destroy
- `:normal_user` allows Member/Property full CRUD with scope :all
- `:own_data` allows Member/CustomFieldValue read/update with scope :linked
- `:read_only` allows Member/CustomFieldValue read with scope :all
- `:read_only` does NOT allow Member/CustomFieldValue create/update/destroy
- `:normal_user` allows Member/CustomFieldValue full CRUD with scope :all
- `:admin` allows everything with scope :all
- `:admin` has wildcard page permission "*"
@ -387,7 +387,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
- `:own``{:filter, expr(id == ^actor.id)}`
- `:linked` → resource-specific logic:
- Member: `{:filter, expr(user_id == ^actor.id)}`
- Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
- CustomFieldValue: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
6. Handle errors gracefully:
- No actor → `{:error, :no_actor}`
- No role → `{:error, :no_role}`
@ -401,7 +401,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
- [ ] Check module implements `Ash.Policy.Check` behavior
- [ ] `match?/3` correctly evaluates permissions from PermissionSets
- [ ] Scope filters work correctly (:all, :own, :linked)
- [ ] `:linked` scope handles Member and Property differently
- [ ] `:linked` scope handles Member and CustomFieldValue differently
- [ ] Errors are handled gracefully (no crashes)
- [ ] Authorization failures are logged
- [ ] Module is well-documented
@ -425,7 +425,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
**Scope Application Tests - :linked:**
- Actor with scope :linked can access Member where member.user_id == actor.id
- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!)
- Actor with scope :linked can access CustomFieldValue where custom_field_value.member.user_id == actor.id (relationship traversal!)
- Actor with scope :linked cannot access unlinked member
- Query correctly filters based on user_id relationship
@ -581,7 +581,7 @@ Add authorization policies to the User resource. Special case: Users can always
---
#### Issue #9: Property Resource Policies
#### Issue #9: CustomFieldValue Resource Policies
**Size:** M (2 days)
**Dependencies:** #6 (HasPermission check)
@ -590,20 +590,20 @@ Add authorization policies to the User resource. Special case: Users can always
**Description:**
Add authorization policies to the Property resource. Properties are linked to members, which are linked to users.
Add authorization policies to the CustomFieldValue resource. CustomFieldValues are linked to members, which are linked to users.
**Tasks:**
1. Open `lib/mv/membership/property.ex`
1. Open `lib/mv/membership/custom_field_value.ex`
2. Add `policies` block
3. Add special policy: Allow user to read/update properties of their linked member
3. Add special policy: Allow user to read/update custom field values of their linked member
```elixir
policy action_type([:read, :update]) do
authorize_if expr(member.user_id == ^actor(:id))
end
```
4. Add general policy: Check HasPermission
5. Ensure Property preloads :member relationship for scope checks
5. Ensure CustomFieldValue preloads :member relationship for scope checks
6. Preload :role relationship for actor
**Policy Order:**
@ -620,27 +620,27 @@ Add authorization policies to the Property resource. Properties are linked to me
**Test Strategy (TDD):**
**Linked Properties Tests (:own_data):**
- User can read properties of their linked member
- User can update properties of their linked member
- User cannot read properties of unlinked members
- Verify relationship traversal works (property.member.user_id)
**Linked CustomFieldValues Tests (:own_data):**
- User can read custom field values of their linked member
- User can update custom field values of their linked member
- User cannot read custom field values of unlinked members
- Verify relationship traversal works (custom_field_value.member.user_id)
**Read-Only Tests:**
- User with :read_only can read all properties
- User with :read_only cannot create/update properties
- User with :read_only can read all custom field values
- User with :read_only cannot create/update custom field values
**Normal User Tests:**
- User with :normal_user can CRUD properties
- User with :normal_user can CRUD custom field values
**Admin Tests:**
- Admin can perform all operations
**Test File:** `test/mv/membership/property_policies_test.exs`
**Test File:** `test/mv/membership/custom_field_value_policies_test.exs`
---
#### Issue #10: PropertyType Resource Policies
#### Issue #10: CustomField Resource Policies
**Size:** S (1 day)
**Dependencies:** #6 (HasPermission check)
@ -649,11 +649,11 @@ Add authorization policies to the Property resource. Properties are linked to me
**Description:**
Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all.
Add authorization policies to the CustomField resource. CustomFields are admin-managed, but readable by all.
**Tasks:**
1. Open `lib/mv/membership/property_type.ex`
1. Open `lib/mv/membership/custom_field.ex`
2. Add `policies` block
3. Add read policy: All authenticated users can read (scope :all)
4. Add write policies: Only admin can create/update/destroy
@ -661,27 +661,27 @@ Add authorization policies to the PropertyType resource. PropertyTypes are admin
**Acceptance Criteria:**
- [ ] All users can read property types
- [ ] Only admin can create/update/destroy property types
- [ ] All users can read custom fields
- [ ] Only admin can create/update/destroy custom fields
- [ ] Policies tested
**Test Strategy (TDD):**
**Read Access (All Roles):**
- User with :own_data can read all property types
- User with :read_only can read all property types
- User with :normal_user can read all property types
- User with :admin can read all property types
- User with :own_data can read all custom fields
- User with :read_only can read all custom fields
- User with :normal_user can read all custom fields
- User with :admin can read all custom fields
**Write Access (Admin Only):**
- Non-admin cannot create property type (Forbidden)
- Non-admin cannot update property type (Forbidden)
- Non-admin cannot destroy property type (Forbidden)
- Admin can create property type
- Admin can update property type
- Admin can destroy property type
- Non-admin cannot create custom field (Forbidden)
- Non-admin cannot update custom field (Forbidden)
- Non-admin cannot destroy custom field (Forbidden)
- Admin can create custom field
- Admin can update custom field
- Admin can destroy custom field
**Test File:** `test/mv/membership/property_type_policies_test.exs`
**Test File:** `test/mv/membership/custom_field_policies_test.exs`
---
@ -924,7 +924,7 @@ Create helper functions for UI-level authorization checks. These will be used in
```
5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission)
6. All functions handle nil user gracefully (return false)
7. Implement resource-specific scope checking (Member vs Property for :linked)
7. Implement resource-specific scope checking (Member vs CustomFieldValue for :linked)
8. Add comprehensive `@doc` with template examples
9. Import helper in `mv_web.ex` `html_helpers` section
@ -957,9 +957,9 @@ Create helper functions for UI-level authorization checks. These will be used in
**can?/3 with Record Struct - Scope :linked:**
- User can update linked Member (member.user_id == user.id)
- User cannot update unlinked Member
- User can update Property of linked Member (property.member.user_id == user.id)
- User cannot update Property of unlinked Member
- Scope checking is resource-specific (Member vs Property)
- User can update CustomFieldValue of linked Member (custom_field_value.member.user_id == user.id)
- User cannot update CustomFieldValue of unlinked Member
- Scope checking is resource-specific (Member vs CustomFieldValue)
**can_access_page?/2:**
- User with page in list can access (returns true)
@ -1046,7 +1046,7 @@ Update Role management LiveViews to use authorization helpers for conditional re
**Description:**
Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering.
Update all existing LiveViews (Member, User, CustomFieldValue, CustomField) to use authorization helpers for conditional rendering.
**Tasks:**
@ -1061,10 +1061,10 @@ Update all existing LiveViews (Member, User, Property, PropertyType) to use auth
- Show: Only show other users if admin, always show own profile
- Edit: Only allow editing own profile or admin editing anyone
3. **Property LiveViews:**
3. **CustomFieldValue LiveViews:**
- Similar to Member (hide create/edit/delete based on permissions)
4. **PropertyType LiveViews:**
4. **CustomField LiveViews:**
- All users can view
- Only admin can create/edit/delete
@ -1110,13 +1110,13 @@ Update all existing LiveViews (Member, User, Property, PropertyType) to use auth
- Vorstand: Sees "Home", "Members" (read-only), "Profile"
- Kassenwart: Sees "Home", "Members", "Properties", "Profile"
- Buchhaltung: Sees "Home", "Members" (read-only), "Profile"
- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile"
- Admin: Sees "Home", "Members", "Custom Field Values", "Custom Fields", "Admin", "Profile"
**Test Files:**
- `test/mv_web/live/member_live_authorization_test.exs`
- `test/mv_web/live/user_live_authorization_test.exs`
- `test/mv_web/live/property_live_authorization_test.exs`
- `test/mv_web/live/property_type_live_authorization_test.exs`
- `test/mv_web/live/custom_field_value_live_authorization_test.exs`
- `test/mv_web/live/custom_field_live_authorization_test.exs`
- `test/mv_web/components/navbar_authorization_test.exs`
---
@ -1192,7 +1192,7 @@ Write comprehensive integration tests that follow complete user journeys for eac
4. Can edit any member (except email if linked - see special case)
5. Cannot delete member
6. Can manage properties
7. Cannot manage property types (read-only)
7. Cannot manage custom fields (read-only)
8. Cannot access /admin/roles
**Buchhaltung Journey:**
@ -1266,7 +1266,7 @@ Write comprehensive integration tests that follow complete user journeys for eac
│ │ │
┌────▼─────┐ ┌──────▼──────┐ │
│ Issue #9 │ │ Issue #10 │ │
Property │ │ PropType │ │
CustomFieldValue │ │ CustomField │ │
│ Policies │ │ Policies │ │
└────┬─────┘ └──────┬──────┘ │
│ │ │
@ -1384,8 +1384,8 @@ test/
├── mv/membership/
│ ├── member_policies_test.exs # Issue #7
│ ├── member_email_validation_test.exs # Issue #12
│ ├── property_policies_test.exs # Issue #9
│ └── property_type_policies_test.exs # Issue #10
│ ├── custom_field_value_policies_test.exs # Issue #9
│ └── custom_field_policies_test.exs # Issue #10
├── mv_web/
│ ├── authorization_test.exs # Issue #14
│ ├── plugs/
@ -1395,8 +1395,8 @@ test/
│ ├── role_live_authorization_test.exs # Issue #15
│ ├── member_live_authorization_test.exs # Issue #16
│ ├── user_live_authorization_test.exs # Issue #16
│ ├── property_live_authorization_test.exs # Issue #16
│ └── property_type_live_authorization_test.exs # Issue #16
│ ├── custom_field_value_live_authorization_test.exs # Issue #16
│ └── custom_field_live_authorization_test.exs # Issue #16
├── integration/
│ ├── mitglied_journey_test.exs # Issue #17
│ ├── vorstand_journey_test.exs # Issue #17

View file

@ -201,7 +201,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro
**Resource Level (MVP):**
- Controls create, read, update, destroy actions on resources
- Resources: Member, User, Property, PropertyType, Role
- Resources: Member, User, CustomFieldValue, CustomField, Role
**Page Level (MVP):**
- Controls access to LiveView pages
@ -280,7 +280,7 @@ Contains:
Each Permission Set contains:
**Resources:** List of resource permissions
- resource: "Member", "User", "Property", etc.
- resource: "Member", "User", "CustomFieldValue", etc.
- action: :read, :create, :update, :destroy
- scope: :own, :linked, :all
- granted: true/false

View file

@ -17,6 +17,10 @@ defmodule Mv.Accounts.User do
# When a member is deleted, set the user's member_id to NULL
# This allows users to continue existing even if their linked member is removed
reference :member, on_delete: :nilify
# When a role is deleted, prevent deletion if users are assigned to it
# This protects critical roles from accidental deletion
reference :role, on_delete: :restrict
end
end
@ -357,6 +361,12 @@ defmodule Mv.Accounts.User do
# This automatically creates a `member_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
belongs_to :member, Mv.Membership.Member
# 1:1 relationship - User belongs to a Role
# This automatically creates a `role_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
belongs_to :role, Mv.Authorization.Role
end
identities do

View file

@ -5,7 +5,7 @@ defmodule Mv.Membership.Member do
## Overview
Members are the core entity in the membership management system. Each member
can have:
- Personal information (name, email, phone, address)
- Personal information (name, email, address)
- Optional link to a User account (1:1 relationship)
- Dynamic custom field values via CustomField system
- Full-text searchable profile
@ -20,9 +20,8 @@ defmodule Mv.Membership.Member do
- `has_one :user` - Optional authentication account link
## Validations
- Required: first_name, last_name, email
- Required: email (all other fields are optional)
- Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
@ -31,7 +30,7 @@ defmodule Mv.Membership.Member do
Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, contact fields,
and all custom field values. Custom field values are automatically included in
the search vector with weight 'C' (same as phone_number, city, etc.).
the search vector with weight 'C' (same as city, etc.).
"""
use Ash.Resource,
domain: Mv.Membership,
@ -41,6 +40,8 @@ defmodule Mv.Membership.Member do
import Ash.Expr
require Logger
alias Mv.Membership.Helpers.VisibilityConfig
# Module constants
@member_search_limit 10
@ -343,9 +344,7 @@ defmodule Mv.Membership.Member do
validations do
# Required fields are covered by allow_nil? false
# First name and last name must not be empty
validate present(:first_name)
validate present(:last_name)
# Email is required
validate present(:email)
# Email uniqueness check for all actions that change the email attribute
@ -396,11 +395,6 @@ defmodule Mv.Membership.Member do
where: [present([:join_date, :exit_date])],
message: "cannot be before join date"
# Phone number format (only if set)
validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/),
where: [present(:phone_number)],
message: "is not a valid phone number"
# Postal code format (only if set)
validate match(:postal_code, ~r/^\d{5}$/),
where: [present(:postal_code)],
@ -453,12 +447,12 @@ defmodule Mv.Membership.Member do
uuid_v7_primary_key :id
attribute :first_name, :string do
allow_nil? false
allow_nil? true
constraints min_length: 1
end
attribute :last_name, :string do
allow_nil? false
allow_nil? true
constraints min_length: 1
end
@ -474,10 +468,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254
end
attribute :phone_number, :string do
allow_nil? true
end
attribute :join_date, :date do
allow_nil? true
end
@ -612,18 +602,21 @@ defmodule Mv.Membership.Member do
"""
@spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do
# exit_date defaults to false (hidden) instead of true
default_visibility = if field == :exit_date, do: false, else: true
case Mv.Membership.get_settings() do
{:ok, settings} ->
visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys)
normalized_config = normalize_visibility_config(visibility_config)
normalized_config = VisibilityConfig.normalize(visibility_config)
# Get value from normalized config, default to true
Map.get(normalized_config, field, true)
# Get value from normalized config, use field-specific default
Map.get(normalized_config, field, default_visibility)
{:error, _} ->
# If settings can't be loaded, default to visible
true
# If settings can't be loaded, use field-specific default
default_visibility
end
end
@ -968,29 +961,6 @@ defmodule Mv.Membership.Member do
defp error_type(error) when is_atom(error), do: error
defp error_type(_), do: :unknown
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.
@ -1073,7 +1043,6 @@ defmodule Mv.Membership.Member do
expr(
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(phone_number, ^query) or
contains(email, ^query) or
contains(city, ^query)
)

View file

@ -57,6 +57,9 @@ defmodule Mv.Membership do
# Settings should be created via seed script
define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
define :update_single_member_field_visibility,
action: :update_single_member_field_visibility
end
end
@ -89,7 +92,10 @@ defmodule Mv.Membership do
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!(domain: __MODULE__)
|> then(fn settings -> {:ok, settings} end)
@ -183,4 +189,42 @@ defmodule Mv.Membership do
})
|> Ash.update(domain: __MODULE__)
end
@doc """
Atomically updates a single field in the member field visibility configuration.
This action uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
preferred method for updating individual field visibility settings.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_single_member_field_visibility(settings, field: "street", show_in_overview: false)
iex> updated.member_field_visibility["street"]
false
"""
def update_single_member_field_visibility(settings,
field: field,
show_in_overview: show_in_overview
) do
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__)
end
end

View file

@ -91,6 +91,16 @@ defmodule Mv.Membership.Setting do
accept [:member_field_visibility]
end
update :update_single_member_field_visibility do
description "Atomically updates a single field in the member_field_visibility JSONB map"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end
update :update_membership_fee_settings do
description "Updates the membership fee configuration"
require_atomic? false

View file

@ -0,0 +1,164 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
@moduledoc """
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
This change uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios.
## Arguments
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
%{},
arguments: %{field: "street", show_in_overview: false}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview) do
add_after_action(changeset, field, show_in_overview)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :member_field_visibility,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, arg_name) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview) do
# Use after_action to execute atomic SQL update
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Use PostgreSQL jsonb_set for atomic update
# jsonb_set(target, path, new_value, create_missing?)
# path is an array: ['field_name']
# new_value must be JSON: to_jsonb(boolean)
sql = """
UPDATE settings
SET member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
)
WHERE id = $3
RETURNING member_field_visibility
"""
# Convert UUID string to binary for PostgreSQL
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
{:ok, %{rows: [[updated_jsonb] | _]}} ->
updated_visibility = normalize_jsonb_result(updated_jsonb)
# Update the settings struct with the new visibility
updated_settings = %{settings | member_field_visibility: updated_visibility}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Failed to update visibility"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
# Convert atom keys to strings if needed
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
# Not a map after decode
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -0,0 +1,31 @@
defmodule Mv.Authorization do
@moduledoc """
Ash Domain for authorization and role management.
## Resources
- `Role` - User roles that reference permission sets
## Public API
The domain exposes these main actions:
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
## Admin Interface
The domain is configured with AshAdmin for management UI.
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
admin do
show? true
end
resources do
resource Mv.Authorization.Role do
define :create_role, action: :create_role
define :list_roles, action: :read
define :get_role, action: :read, get_by: [:id]
define :update_role, action: :update_role
define :destroy_role, action: :destroy
end
end
end

View file

@ -0,0 +1,239 @@
defmodule Mv.Authorization.Checks.HasPermission do
@moduledoc """
Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
This check:
1. Reads the actor's role and permission_set_name
2. Looks up permissions from PermissionSets.get_permissions/1
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Usage in Ash Resource
policies do
policy action_type(:read) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
## Scope Behavior
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!)
## Error Handling
Returns `false` for:
- Missing actor
- Actor without role
- Invalid permission_set_name
- No matching permission found
All errors result in Forbidden (policy fails).
## Examples
# In a resource policy
policies do
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
"""
use Ash.Policy.Check
require Ash.Query
import Ash.Expr
alias Mv.Authorization.PermissionSets
require Logger
@impl true
def describe(_opts) do
"checks if actor has permission via their role's permission set"
end
@impl true
def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) ->
log_auth_failure(actor, resource, action, "no actor")
{:ok, false}
is_nil(action) ->
log_auth_failure(
actor,
resource,
action,
"authorizer subject shape unsupported (no action)"
)
{:ok, false}
true ->
strict_check_with_permissions(actor, resource, action)
end
end
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(
permissions.resources,
resource_name,
action,
actor,
resource_name
) do
:authorized -> {:ok, true}
{:filter, _} -> {:ok, :unknown}
false -> {:ok, false}
end
else
%{role: nil} ->
log_auth_failure(actor, resource, action, "no role assigned")
{:ok, false}
%{role: %{permission_set_name: nil}} ->
log_auth_failure(actor, resource, action, "role has no permission_set_name")
{:ok, false}
{:error, :invalid_permission_set} ->
log_auth_failure(actor, resource, action, "invalid permission_set_name")
{:ok, false}
_ ->
log_auth_failure(actor, resource, action, "missing data")
{:ok, false}
end
end
@impl true
def auto_filter(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) -> nil
is_nil(action) -> nil
true -> auto_filter_with_permissions(actor, resource, action)
end
end
# Helper function to reduce nesting depth
defp auto_filter_with_permissions(actor, resource, action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(
permissions.resources,
resource_name,
action,
actor,
resource_name
) do
:authorized -> nil
{:filter, filter_expr} -> filter_expr
false -> nil
end
else
_ -> nil
end
end
# Helper to extract action from authorizer
defp get_action_from_authorizer(authorizer) do
case authorizer.subject do
%{action: %{name: action}} -> action
%{action: action} when is_atom(action) -> action
_ -> nil
end
end
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
# Find matching permission and apply scope
defp check_permission(resource_perms, resource_name, action, actor, resource_name_for_logging) do
case Enum.find(resource_perms, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end) do
nil ->
log_auth_failure(actor, resource_name_for_logging, action, "no matching permission found")
false
perm ->
apply_scope(perm.scope, actor, resource_name)
end
end
# Scope: all - No filtering, access to all records
defp apply_scope(:all, _actor, _resource) do
:authorized
end
# Scope: own - Filter to records where record.id == actor.id
# Used for User resource (users can access their own user record)
defp apply_scope(:own, actor, _resource) do
{:filter, expr(id == ^actor.id)}
end
# Scope: linked - Filter based on user relationship (resource-specific!)
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member has_one :user → filter by user.id == actor.id
{:filter, expr(user.id == ^actor.id)}
"CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user
# Traverse: custom_field_value.member.user.id == actor.id
{:filter, expr(member.user.id == ^actor.id)}
_ ->
# Fallback for other resources: try user relationship first, then user_id
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
end
end
# Log authorization failures for debugging (lazy evaluation)
defp log_auth_failure(actor, resource, action, reason) do
Logger.debug(fn ->
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
resource_name = get_resource_name_for_logging(resource)
"""
Authorization failed:
Actor: #{actor_id}
Resource: #{resource_name}
Action: #{inspect(action)}
Reason: #{reason}
"""
end)
end
# Helper to extract resource name for logging (handles both atoms and strings)
defp get_resource_name_for_logging(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
defp get_resource_name_for_logging(resource) when is_binary(resource) do
resource
end
defp get_resource_name_for_logging(_resource) do
"unknown"
end
end

View file

@ -0,0 +1,294 @@
defmodule Mv.Authorization.PermissionSets do
@moduledoc """
Defines the four hardcoded permission sets for the application.
Each permission set specifies:
- Resource permissions (what CRUD operations on which resources)
- Page permissions (which LiveView pages can be accessed)
- Scopes (own, linked, all)
## Permission Sets
1. **own_data** - Default for "Mitglied" role
- Can only access own user data and linked member/custom field values
- Cannot create new members or manage system
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
- Can read all member data
- Cannot create, update, or delete
3. **normal_user** - For "Kassenwart" role
- Create/Read/Update members (no delete for safety), full CRUD on custom field values
- Cannot manage custom fields or users
4. **admin** - For "Admin" role
- Unrestricted access to all resources
- Can manage users, roles, custom fields
## Usage
# Get permissions for a role's permission set
permissions = PermissionSets.get_permissions(:admin)
# Check if a permission set name is valid
PermissionSets.valid_permission_set?("read_only") # => true
# Convert string to atom safely
{:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
## Performance
All functions are pure and intended to be constant-time. Permission lookups
are very fast (typically < 1 microsecond in practice) as they are simple
pattern matches and map lookups with no database queries or external calls.
"""
@type scope :: :own | :linked | :all
@type action :: :read | :create | :update | :destroy
@type resource_permission :: %{
resource: String.t(),
action: action(),
scope: scope(),
granted: boolean()
}
@type permission_set :: %{
resources: [resource_permission()],
pages: [String.t()]
}
@doc """
Returns the list of all valid permission set names.
## Examples
iex> PermissionSets.all_permission_sets()
[:own_data, :read_only, :normal_user, :admin]
"""
@spec all_permission_sets() :: [atom()]
def all_permission_sets do
[:own_data, :read_only, :normal_user, :admin]
end
@doc """
Returns permissions for the given permission set.
## Examples
iex> permissions = PermissionSets.get_permissions(:admin)
iex> Enum.any?(permissions.resources, fn p ->
...> p.resource == "User" and p.action == :destroy
...> end)
true
iex> PermissionSets.get_permissions(:invalid)
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
"""
@spec get_permissions(atom()) :: permission_set()
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
raise ArgumentError,
"invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}"
end
def get_permissions(:own_data) do
%{
resources: [
# User: Can always read/update own credentials
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read/update linked member
%{resource: "Member", action: :read, scope: :linked, granted: true},
%{resource: "Member", action: :update, scope: :linked, granted: true},
# CustomFieldValue: Can read/update custom field values of linked member
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
# CustomField: Can read all (needed for forms)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
# Home page
"/",
# Own profile
"/profile",
# Linked member detail (filtered by policy)
"/members/:id"
]
}
end
def get_permissions(:read_only) do
%{
resources: [
# User: Can read/update own credentials only
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true},
# CustomFieldValue: Can read all custom field values
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# CustomField: Can read all
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
# Own profile
"/profile",
# Member list
"/members",
# Member detail
"/members/:id",
# Custom field values overview
"/custom_field_values",
# Custom field value detail
"/custom_field_values/:id"
]
}
end
def get_permissions(:normal_user) do
%{
resources: [
# User: Can read/update own credentials only
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Full CRUD except destroy (safety)
%{resource: "Member", action: :read, scope: :all, granted: true},
%{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
# Note: destroy intentionally omitted for safety
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Read only (admin manages definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
# Own profile
"/profile",
"/members",
# Create member
"/members/new",
"/members/:id",
# Edit member
"/members/:id/edit",
"/custom_field_values",
# Custom field value detail
"/custom_field_values/:id",
"/custom_field_values/new",
"/custom_field_values/:id/edit"
]
}
end
def get_permissions(:admin) do
%{
resources: [
# User: Full management including other users
%{resource: "User", action: :read, scope: :all, granted: true},
%{resource: "User", action: :create, scope: :all, granted: true},
%{resource: "User", action: :update, scope: :all, granted: true},
%{resource: "User", action: :destroy, scope: :all, granted: true},
# Member: Full CRUD
%{resource: "Member", action: :read, scope: :all, granted: true},
%{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
%{resource: "Member", action: :destroy, scope: :all, granted: true},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
# Role: Full CRUD (admin manages roles)
%{resource: "Role", action: :read, scope: :all, granted: true},
%{resource: "Role", action: :create, scope: :all, granted: true},
%{resource: "Role", action: :update, scope: :all, granted: true},
%{resource: "Role", action: :destroy, scope: :all, granted: true}
],
pages: [
# Wildcard: Admin can access all pages
"*"
]
}
end
def get_permissions(invalid) do
raise ArgumentError,
"invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}"
end
@doc """
Checks if a permission set name (string or atom) is valid.
## Examples
iex> PermissionSets.valid_permission_set?("admin")
true
iex> PermissionSets.valid_permission_set?(:read_only)
true
iex> PermissionSets.valid_permission_set?("invalid")
false
"""
@spec valid_permission_set?(any()) :: boolean()
def valid_permission_set?(name) when is_binary(name) do
case permission_set_name_to_atom(name) do
{:ok, _atom} -> true
{:error, _} -> false
end
end
def valid_permission_set?(name) when is_atom(name) do
name in all_permission_sets()
end
def valid_permission_set?(_), do: false
@doc """
Converts a permission set name string to atom safely.
## Examples
iex> PermissionSets.permission_set_name_to_atom("admin")
{:ok, :admin}
iex> PermissionSets.permission_set_name_to_atom("invalid")
{:error, :invalid_permission_set}
"""
@spec permission_set_name_to_atom(String.t()) ::
{:ok, atom()} | {:error, :invalid_permission_set}
def permission_set_name_to_atom(name) when is_binary(name) do
atom = String.to_existing_atom(name)
if valid_permission_set?(atom) do
{:ok, atom}
else
{:error, :invalid_permission_set}
end
rescue
ArgumentError -> {:error, :invalid_permission_set}
end
end

View file

@ -0,0 +1,142 @@
defmodule Mv.Authorization.Role do
@moduledoc """
Represents a user role that references a permission set.
Roles are stored in the database and link users to permission sets.
Each role has a `permission_set_name` that references one of the four
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
## Fields
- `name` - Unique role name (e.g., "Vorstand", "Admin")
- `description` - Human-readable description of the role
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
## Relationships
- `has_many :users` - Users assigned to this role
## Validations
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
- `name` must be unique
- System roles cannot be deleted (enforced via validation)
## Examples
# Create a new role
{:ok, role} = Mv.Authorization.create_role(%{
name: "Vorstand",
description: "Board member with read access",
permission_set_name: "read_only"
})
# List all roles
{:ok, roles} = Mv.Authorization.list_roles()
"""
use Ash.Resource,
domain: Mv.Authorization,
data_layer: AshPostgres.DataLayer
postgres do
table "roles"
repo Mv.Repo
references do
# Prevent deletion of roles that are assigned to users
reference :users, on_delete: :restrict
end
end
code_interface do
define :create_role
define :list_roles, action: :read
define :update_role
define :destroy_role, action: :destroy
end
actions do
defaults [:read]
create :create_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Note: In Ash 3.0, require_atomic? is not available for create actions
# Custom validations will still work
end
update :update_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
destroy :destroy do
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
end
validations do
validate one_of(
:permission_set_name,
Mv.Authorization.PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
),
message:
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context ->
if changeset.data.is_system_role do
{:error,
field: :is_system_role,
message:
"Cannot delete system role. System roles are required for the application to function."}
else
:ok
end
end,
on: [:destroy]
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
attribute :permission_set_name, :string do
allow_nil? false
public? true
end
attribute :is_system_role, :boolean do
allow_nil? false
default false
public? true
end
timestamps()
end
relationships do
has_many :users, Mv.Accounts.User do
destination_attribute :role_id
end
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name,
:last_name,
:email,
:phone_number,
:join_date,
:exit_date,
:notes,

View file

@ -0,0 +1,49 @@
defmodule Mv.Helpers.TypeParsers do
@moduledoc """
Helper functions for parsing various input types to common Elixir types.
Provides safe parsing functions for common type conversions, especially useful
when dealing with form data or external APIs.
"""
@doc """
Parses various input types to boolean.
Handles: booleans, strings ("true"/"false"), integers (1/0), and other values (defaults to false).
## Parameters
- `value` - The value to parse (boolean, string, integer, or other)
## Returns
A boolean value
## Examples
iex> parse_boolean(true)
true
iex> parse_boolean("true")
true
iex> parse_boolean("false")
false
iex> parse_boolean(1)
true
iex> parse_boolean(0)
false
iex> parse_boolean(nil)
false
"""
@spec parse_boolean(any()) :: boolean()
def parse_boolean(value) when is_boolean(value), do: value
def parse_boolean("true"), do: true
def parse_boolean("false"), do: false
def parse_boolean(1), do: true
def parse_boolean(0), do: false
def parse_boolean(_), do: false
end

View file

@ -0,0 +1,55 @@
defmodule Mv.Membership.Helpers.VisibilityConfig do
@moduledoc """
Helper functions for normalizing member field visibility configuration.
Handles conversion between string keys (from JSONB) and atom keys (Elixir convention).
JSONB in PostgreSQL converts atom keys to string keys when storing.
This module provides functions to normalize these back to atoms for Elixir usage.
"""
@doc """
Normalizes visibility config map keys from strings to atoms.
JSONB in PostgreSQL converts atom keys to string keys when storing.
This function converts them back to atoms for Elixir usage.
## Parameters
- `config` - A map with either string or atom keys
## Returns
A map with atom keys (where possible)
## Examples
iex> normalize(%{"first_name" => true, "email" => false})
%{first_name: true, email: false}
iex> normalize(%{first_name: true, email: false})
%{first_name: true, email: false}
iex> normalize(%{"invalid_field" => true})
%{}
"""
@spec normalize(map()) :: map()
def normalize(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
def normalize(_), do: %{}
end

View file

@ -89,6 +89,9 @@ defmodule MvWeb do
# Core UI components
import MvWeb.CoreComponents
# Authorization helpers
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
# Common modules used in templates
alias Phoenix.LiveView.JS
alias MvWeb.Layouts

206
lib/mv_web/authorization.ex Normal file
View file

@ -0,0 +1,206 @@
defmodule MvWeb.Authorization do
@moduledoc """
UI-level authorization helpers for LiveView templates.
These functions check if the current user has permission to perform actions
or access pages. They use the same PermissionSets module as the backend policies,
ensuring UI and backend authorization are consistent.
## Usage in Templates
<!-- Conditional button rendering -->
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.link patch={~p"/members/new"}>New Member</.link>
<% end %>
<!-- Record-level check -->
<%= if can?(@current_user, :update, @member) do %>
<.button>Edit</.button>
<% end %>
<!-- Page access check -->
<%= if can_access_page?(@current_user, "/admin/roles") do %>
<.link navigate="/admin/roles">Manage Roles</.link>
<% end %>
## Performance
All checks are pure function calls using the hardcoded PermissionSets module.
No database queries, < 1 microsecond per check.
"""
alias Mv.Authorization.PermissionSets
@doc """
Checks if user has permission for an action on a resource.
This function has two variants:
1. Resource atom: Checks if user has permission for action on resource type
2. Record struct: Checks if user has permission for action on specific record (with scope checking)
## Examples
# Resource-level check (atom)
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can?(admin, :create, Mv.Membership.Member)
true
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can?(mitglied, :create, Mv.Membership.Member)
false
# Record-level check (struct with scope)
iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}}
iex> member = %Member{id: "member-456", user: %User{id: "user-123"}}
iex> can?(user, :update, member)
true
"""
@spec can?(map() | nil, atom(), atom() | struct()) :: boolean()
def can?(nil, _action, _resource), do: false
def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
resource_name = get_resource_name(resource)
Enum.any?(permissions.resources, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end)
else
_ -> false
end
end
def can?(user, action, %resource{} = record) when is_atom(action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
resource_name = get_resource_name(resource)
# Find matching permission
matching_perm =
Enum.find(permissions.resources, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end)
case matching_perm do
nil -> false
perm -> check_scope(perm.scope, user, record, resource_name)
end
else
_ -> false
end
end
@doc """
Checks if user can access a specific page.
## Examples
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can_access_page?(admin, "/admin/roles")
true
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can_access_page?(mitglied, "/members")
false
"""
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
boolean()
def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do
# Convert verified route to string if needed
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
page_matches?(permissions.pages, page_path_str)
else
_ -> false
end
end
# Check if scope allows access to record
defp check_scope(:all, _user, _record, _resource_name), do: true
defp check_scope(:own, user, record, _resource_name) do
record.id == user.id
end
defp check_scope(:linked, user, record, resource_name) do
case resource_name do
"Member" -> check_member_linked(user, record)
"CustomFieldValue" -> check_custom_field_value_linked(user, record)
_ -> check_fallback_linked(user, record)
end
end
defp check_member_linked(user, record) do
# Member has_one :user (inverse of User belongs_to :member)
# Check if member.user.id == user.id (user must be preloaded)
case Map.get(record, :user) do
%{id: user_id} -> user_id == user.id
_ -> false
end
end
defp check_custom_field_value_linked(user, record) do
# Need to traverse: custom_field_value.member.user.id
# Note: In UI, custom_field_value should have member.user preloaded
case Map.get(record, :member) do
%{user: %{id: member_user_id}} -> member_user_id == user.id
_ -> false
end
end
defp check_fallback_linked(user, record) do
# Fallback: try user_id or user relationship
case Map.get(record, :user_id) do
nil -> check_user_relationship_linked(user, record)
user_id -> user_id == user.id
end
end
defp check_user_relationship_linked(user, record) do
# Try user relationship
case Map.get(record, :user) do
%{id: user_id} -> user_id == user.id
_ -> false
end
end
# Check if page path matches any allowed pattern
defp page_matches?(allowed_pages, requested_path) do
Enum.any?(allowed_pages, fn pattern ->
cond do
pattern == "*" -> true
pattern == requested_path -> true
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
true -> false
end
end)
end
# Match dynamic route pattern
defp match_pattern?(pattern, path) do
pattern_segments = String.split(pattern, "/", trim: true)
path_segments = String.split(path, "/", trim: true)
if length(pattern_segments) == length(path_segments) do
Enum.zip(pattern_segments, path_segments)
|> Enum.all?(fn {pattern_seg, path_seg} ->
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
end)
else
false
end
end
# Extract resource name from module
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
end

View file

@ -692,7 +692,7 @@ defmodule MvWeb.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: "size-4"
attr :rest, :global, include: ~w[aria-hidden]
attr :rest, :global, include: ~w(aria-hidden)
def icon(%{name: "hero-" <> _} = assigns) do
~H"""

View file

@ -3,6 +3,7 @@ defmodule MvWeb.Layouts.Navbar do
Navbar that is used in the rootlayout shown on every page
"""
use MvWeb, :html
import MvWeb.Authorization
attr :current_user, :map,
required: true,
@ -22,12 +23,26 @@ defmodule MvWeb.Layouts.Navbar do
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
<li>
<details>
<summary>{gettext("Settings")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/settings">{gettext("Global Settings")}</.link>
</li>
<%= if can_access_page?(@current_user, ~p"/admin/roles") do %>
<li>
<.link navigate={~p"/admin/roles"}>{gettext("Roles")}</.link>
</li>
<% end %>
</ul>
</details>
</li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<details>
<summary>{gettext("Contributions")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<ul class="bg-base-200 rounded-t-none p-2 z-10">
<li>
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
</li>

View file

@ -78,6 +78,12 @@ defmodule MvWeb.AuthController do
end
end
# Catch-all clause for any other error types
defp handle_rauthy_failure(conn, reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
end
# Handle generic AuthenticationFailed errors
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do

View file

@ -0,0 +1,59 @@
defmodule MvWeb.Helpers.FieldTypeFormatter do
@moduledoc """
Helper functions for formatting field types for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
"""
alias MvWeb.Translations.FieldTypes
@doc """
Formats an Ash type for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
## Parameters
- `type` - An atom or module representing the field type
## Returns
A human-readable string representation of the type
## Examples
iex> format(:string)
"String"
iex> format(Ash.Type.String)
"String"
iex> format(Ash.Type.Date)
"Date"
"""
@spec format(atom() | module()) :: String.t()
def format(type) when is_atom(type) do
type_string = to_string(type)
if String.contains?(type_string, "Ash.Type.") do
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
|> then(fn type_name ->
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError -> FieldTypes.label(:string)
end
end)
else
FieldTypes.label(type)
end
end
def format(type) do
to_string(type)
end
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Helpers.MemberHelpers do
@moduledoc """
Helper functions for member-related operations in the web layer.
Provides utilities for formatting and displaying member information.
"""
alias Mv.Membership.Member
@doc """
Returns a display name for a member.
Combines first_name and last_name if available, otherwise falls back to email.
This ensures that members without names still have a meaningful display name.
## Examples
iex> member = %Member{first_name: "John", last_name: "Doe", email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"John Doe"
iex> member = %Member{first_name: nil, last_name: nil, email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"john@example.com"
iex> member = %Member{first_name: "John", last_name: nil, email: "john@example.com"}
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
"John"
"""
def display_name(%Member{} = member) do
name_parts =
[member.first_name, member.last_name]
|> Enum.reject(&blank?/1)
|> Enum.map_join(" ", &String.trim/1)
if name_parts == "" do
member.email
else
name_parts
end
end
@doc """
Checks if a value is blank (nil, empty string, or only whitespace).
## Examples
iex> MvWeb.Helpers.MemberHelpers.blank?(nil)
true
iex> MvWeb.Helpers.MemberHelpers.blank?("")
true
iex> MvWeb.Helpers.MemberHelpers.blank?(" ")
true
iex> MvWeb.Helpers.MemberHelpers.blank?("John")
false
"""
def blank?(nil), do: true
def blank?(""), do: true
def blank?(value) when is_binary(value), do: String.trim(value) == ""
def blank?(_), do: false
end

View file

@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """
Formats a decimal amount as currency string.

View file

@ -18,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
use MvWeb, :live_component
alias MvWeb.Translations.MemberFields
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
@ -66,7 +68,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Columns")}
button_label={gettext("Show/Hide Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
@ -153,12 +155,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do
MvWeb.Translations.MemberFields.label(field)
MemberFields.label(field)
end
defp format_field_label(field) when is_binary(field) do
case safe_to_existing_atom(field) do
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom)
{:ok, atom} -> MemberFields.label(atom)
:error -> fallback_label(field)
end
end

View file

@ -36,7 +36,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
<.mockup_warning />
<.header>
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
{gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
<:subtitle>
{gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span>

View file

@ -115,7 +115,7 @@ defmodule MvWeb.ContributionTypeLive.Index do
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
"Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>

View file

@ -26,12 +26,12 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to custom field overview")}
aria-label={gettext("Back to settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
</h3>
</div>
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field")}
{gettext("Save Data Field")}
</.button>
</div>
</.form>

View file

@ -17,158 +17,170 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H"""
<div id={@id}>
<.form_section title={gettext("Custom Fields")}>
<div class="flex">
<p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")}
</p>
<div class="ml-auto">
<.button
class="ml-auto"
variant="primary"
phx-click="new_custom_field"
phx-target={@myself}
>
<.icon name="hero-plus" /> {gettext("New Custom Field")}
</.button>
</div>
</div>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{@field_type_label.(custom_field.value_type)}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<:col
:let={{_id, custom_field}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
<div id={@id} class="mt-8">
<div class="flex">
<p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")}
</p>
<div class="ml-auto">
<.button
class="ml-auto"
variant="primary"
phx-click="new_custom_field"
phx-target={@myself}
>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<.icon name="hero-plus" /> {gettext("New Data Field")}
</.button>
</div>
</div>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{@field_type_label.(custom_field.value_type)}
</:col>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="mt-2 text-sm">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<:col
:let={{_id, custom_field}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.required} class="text-base-content font-semibold">
{gettext("Required")}
</span>
<span :if={!custom_field.required} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col
:let={{_id, custom_field}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="mt-2 text-sm">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
</div>
</div>
</dialog>
</.form_section>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</div>
"""
end
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
@ -179,6 +191,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket
end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok,
socket
|> assign(assigns)
@ -193,6 +212,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("new_custom_field", _params, socket) do
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply,
socket
|> assign(:show_form, true)
@ -204,6 +228,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply,
socket
|> assign(:show_form, true)

View file

@ -289,6 +289,6 @@ defmodule MvWeb.CustomFieldValueLive.Form do
end
defp member_options(members) do
Enum.map(members, &{"#{&1.first_name} #{&1.last_name}", &1.id})
Enum.map(members, &{MvWeb.Helpers.MemberHelpers.display_name(&1), &1.id})
end
end

View file

@ -26,7 +26,7 @@ defmodule MvWeb.CustomFieldValueLive.Show do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field value {@custom_field_value.id}
Data field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
@ -62,6 +62,6 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end
defp page_title(:show), do: "Show Custom field value"
defp page_title(:edit), do: "Edit Custom field value"
defp page_title(:show), do: "Show data field value"
defp page_title(:edit), do: "Edit data field value"
end

View file

@ -31,6 +31,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign_form()}
end
@ -62,11 +63,21 @@ defmodule MvWeb.GlobalSettingsLive do
</.button>
</.form>
</.form_section>
<%!-- Custom Fields Section --%>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
<%!-- Memberdata Section --%>
<.form_section title={gettext("Memberdata")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
<%!-- Custom Fields Section --%>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
</.form_section>
</Layouts.app>
"""
end
@ -105,12 +116,14 @@ defmodule MvWeb.GlobalSettingsLive do
)
{:noreply,
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
end
@impl true
@ -119,7 +132,7 @@ defmodule MvWeb.GlobalSettingsLive do
put_flash(
socket,
:error,
gettext("Failed to delete custom field: %{error}", error: inspect(error))
gettext("Failed to delete data field: %{error}", error: inspect(error))
)}
end
@ -128,6 +141,43 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
# Reload settings to get updated member_field_visibility
{:ok, updated_settings} = Membership.get_settings()
# Send update to member fields component to close form
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
show_form: false,
settings: updated_settings
)
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
def handle_info({:member_field_visibility_updated}, socket) do
# Legacy event - reload settings and update component
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
settings: updated_settings
)
{:noreply, assign(socket, :settings, updated_settings)}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(

View file

@ -0,0 +1,338 @@
defmodule MvWeb.MemberFieldLive.FormComponent do
@moduledoc """
LiveComponent form for editing member field properties (embedded in settings).
## Features
- Edit member field visibility (show_in_overview)
- Display member field information from Member Resource (read-only)
- Restrict editing for email field (only show_in_overview can be changed)
- Real-time validation
- Updates Settings.member_field_visibility atomically
## Props
- `member_field` - The member field atom to edit (e.g., :first_name, :email)
- `settings` - The current Settings resource
- `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled
## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required).
Only the visibility (show_in_overview) can be modified.
"""
use MvWeb, :live_component
alias Mv.Helpers.TypeParsers
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email]
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:field_label, MemberFields.label(assigns.member_field))
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to Settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<div
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Name")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:name].name}
id={@form[:name].id}
value={@field_label}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Value type")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:value_type].name}
id={@form[:value_type].id}
value={FieldTypeFormatter.format(@field_attributes.value_type)}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:description].name}
id={@form[:description].id}
value={@form[:description].value}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:description]}
type="text"
label={gettext("Description")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
# For member fields, we only validate show_in_overview
# Other fields are read-only or derived from the Member Resource
form = socket.assigns.form
updated_params =
member_field_params
|> Map.put(
"show_in_overview",
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
)
|> Map.put("name", form.source["name"])
|> Map.put("value_type", form.source["value_type"])
|> Map.put("description", form.source["description"])
|> Map.put("required", form.source["required"])
updated_form =
form
|> Map.put(:value, updated_params)
|> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)}
end
@impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
field_string = Atom.to_string(socket.assigns.member_field)
# Use atomic action to update only this single field
# This prevents lost updates in concurrent scenarios
case Membership.update_single_member_field_visibility(
socket.assigns.settings,
field: field_string,
show_in_overview: show_in_overview
) do
{:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket}
{:error, error} ->
# Add error to form
form =
socket.assigns.form
|> Map.put(:errors, [
%{field: :show_in_overview, message: format_error(error)}
])
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
# Helper functions
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{}
normalized_config = VisibilityConfig.normalize(visibility_config)
show_in_overview = Map.get(normalized_config, member_field, true)
# Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{
"name" => MemberFields.label(member_field),
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "",
"required" => field_attributes.required,
"show_in_overview" => show_in_overview
}
form = to_form(form_data, as: "member_field")
assign(socket, form: form)
end
defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do
nil ->
# Fallback for fields not in resource (shouldn't happen with Constants)
%{
value_type: :string,
description: nil,
required: field in @required_fields
}
attribute ->
%{
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
end
end
defp format_error(%Ash.Error.Invalid{} = error) do
Ash.ErrorKind.message(error)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -0,0 +1,219 @@
defmodule MvWeb.MemberFieldLive.IndexComponent do
@moduledoc """
LiveComponent for managing member field visibility in overview (embedded in settings).
## Features
- List all member fields from Mv.Constants.member_fields()
- Display show_in_overview status as badge (Yes/No)
- Display required status based on actual attribute definitions (allow_nil? false)
- Edit member field properties (expandable form like custom fields)
- Updates Settings.member_field_visibility
"""
use MvWeb, :live_component
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H"""
<div id={@id}>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
)}
</p>
<%!-- Show form when editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.MemberFieldLive.FormComponent}
id={@form_id}
member_field={@editing_member_field}
settings={@settings}
on_save={
fn member_field, action ->
send(self(), {:member_field_saved, member_field, action})
end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="member_fields"
rows={@member_fields}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Value Type")}>
{format_value_type(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span
:if={@required?.(field_data.field)}
class="text-base-content font-semibold"
>
{gettext("Required")}
</span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={field_data.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_field_name, field_data}}>
<.link
phx-click="edit_member_field"
phx-value-field={Atom.to_string(field_data.field)}
phx-target={@myself}
>
{gettext("Edit")}
</.link>
</:action>
</.table>
</div>
"""
end
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_member_field, nil)
|> assign(:form_id, "member-field-form-new")
else
socket
end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok,
socket
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
end
@impl true
def handle_event("edit_member_field", %{"field" => field_string}, socket) do
# Validate that the field is a valid member field before converting to atom
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field_string in valid_fields do
field_atom = String.to_existing_atom(field_string)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :member_fields})
end
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_member_field, field_atom)
|> assign(:form_id, "member-field-form-#{field_string}")}
else
{:noreply, socket}
end
end
# Helper functions
defp get_settings do
case Membership.get_settings() do
{:ok, settings} ->
settings
{:error, _} ->
# Return a minimal struct-like map for fallback
# This is only used for initial rendering, actual settings will be loaded properly
%{member_field_visibility: %{}}
end
end
defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{}
# Normalize visibility config keys to atoms
normalized_config = VisibilityConfig.normalize(visibility_config)
Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true)
attribute = Info.attribute(Mv.Membership.Member, field)
%{
field: field,
show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string,
description: nil
}
end)
|> Enum.map(fn field_data ->
{Atom.to_string(field_data.field), field_data}
end)
end
defp format_value_type(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> FieldTypeFormatter.format(:string)
attribute -> FieldTypeFormatter.format(attribute.type)
end
end
# Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> false
attribute -> not attribute.allow_nil?
end
end
defp required?(_), do: false
end

View file

@ -43,7 +43,7 @@ defmodule MvWeb.MemberLive.Form do
<h1 class="text-2xl font-bold text-center flex-1">
<%= if @member do %>
{@member.first_name} {@member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
@ -82,10 +82,10 @@ defmodule MvWeb.MemberLive.Form do
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:first_name]} label={gettext("First Name")} />
</div>
<div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} />
</div>
</div>
@ -110,11 +110,6 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Phone --%>
<div>
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">

View file

@ -31,10 +31,10 @@ defmodule MvWeb.MemberLive.Index do
import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")

View file

@ -239,24 +239,6 @@
>
{member.city}
</:col>
<:col
:let={member}
:if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}
@ -275,6 +257,24 @@
>
{MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col>
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_exit_date}
field={:exit_date}
label={gettext("Exit Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
</:col>
<:col
:let={member}
label={gettext("Membership Fee Status")}

View file

@ -20,6 +20,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
3. Default (all fields visible)
"""
alias Mv.Membership.Helpers.VisibilityConfig
@doc """
Gets all available fields for selection.
@ -177,13 +179,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
show_in_overview = Map.get(visibility_config, field, true)
# exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
end
@ -199,27 +203,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do

View file

@ -35,7 +35,7 @@ defmodule MvWeb.MemberLive.Show do
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{@member.first_name} {@member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
@ -104,11 +104,6 @@ defmodule MvWeb.MemberLive.Show do
</.data_field>
</div>
<%!-- Phone --%>
<div>
<.data_field label={gettext("Phone")} value={@member.phone_number} />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field

View file

@ -15,10 +15,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query
alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -63,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
title={gettext("Delete All Cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
@ -168,7 +168,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
title={gettext("Delete Cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
@ -329,16 +329,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
<label class="label">
<span class="label-text-alt">
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
{gettext("The cycle will be calculated based on this date and the interval.")}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle Period")}</span>
<span class="label-text">{gettext("Cycle")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(

View file

@ -15,9 +15,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
require Ash.Query
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true

View file

@ -16,10 +16,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
require Ash.Query
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -115,7 +115,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete membership fee type")}
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</button>

View file

@ -0,0 +1,237 @@
defmodule MvWeb.RoleLive.Form do
@moduledoc """
LiveView form for creating and editing roles.
## Features
- Create new roles
- Edit existing roles (name, description, permission_set_name)
- Custom dropdown for permission_set_name with badges
- Form validation
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Authorization.PermissionSets
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
</.header>
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="form-control">
<label class="label" for="role-form_permission_set_name">
<span class="label-text font-semibold">
{gettext("Permission Set")}
<span class="text-red-700">*</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:permission_set_name] && "select-error"
]}
name="role[permission_set_name]"
id="role-form_permission_set_name"
required
aria-label={gettext("Permission Set")}
>
<option value="">{gettext("Select permission set")}</option>
<%= for permission_set <- all_permission_sets() do %>
<option
value={permission_set}
selected={@form[:permission_set_name].value == permission_set}
>
{format_permission_set_option(permission_set)}
</option>
<% end %>
</select>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
</div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Role")}
</.button>
<.button navigate={return_path(@return_to, @role)} type="button">
{gettext("Cancel")}
</.button>
</div>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
case params["id"] do
nil ->
action = gettext("New")
page_title = action <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, nil)
|> assign(:page_title, page_title)
|> assign_form()}
id ->
try do
case Ash.get(
Mv.Authorization.Role,
id,
domain: Mv.Authorization,
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
action = gettext("Edit")
page_title = action <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, role)
|> assign(:page_title, page_title)
|> assign_form()}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
{:error, error} ->
{:ok,
socket
|> put_flash(:error, format_error(error))
|> redirect(to: ~p"/admin/roles")}
end
rescue
e in [Ash.Error.Invalid] ->
# Handle exceptions that Ash.get might throw (e.g., policy violations)
case e do
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
_ ->
reraise e, __STACKTRACE__
end
end
end
end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"role" => role_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, role_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event("save", %{"role" => role_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
{:ok, role} ->
notify_parent({:saved, role})
redirect_path =
if socket.assigns.return_to == "show" do
~p"/admin/roles/#{role.id}"
else
~p"/admin/roles"
end
socket =
socket
|> put_flash(:info, gettext("Role saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{role: role, current_user: actor}} = socket) do
form =
if role do
AshPhoenix.Form.for_update(role, :update_role,
domain: Mv.Authorization,
as: "role",
actor: actor
)
else
AshPhoenix.Form.for_create(
Mv.Authorization.Role,
:create_role,
domain: Mv.Authorization,
as: "role",
actor: actor
)
end
assign(socket, form: to_form(form))
end
defp all_permission_sets do
PermissionSets.all_permission_sets() |> Enum.map(&Atom.to_string/1)
end
defp format_permission_set_option("own_data"),
do: gettext("own_data - Access only to own data")
defp format_permission_set_option("read_only"),
do: gettext("read_only - Read access to all data")
defp format_permission_set_option("normal_user"),
do: gettext("normal_user - Create/Read/Update access")
defp format_permission_set_option("admin"),
do: gettext("admin - Unrestricted access")
defp format_permission_set_option(set), do: set
@spec return_path(String.t(), Mv.Authorization.Role.t() | nil) :: String.t()
defp return_path("index", _role), do: ~p"/admin/roles"
defp return_path("show", role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
defp return_path("show", _role), do: ~p"/admin/roles"
defp return_path(_, role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
defp return_path(_, _role), do: ~p"/admin/roles"
end

View file

@ -0,0 +1,44 @@
defmodule MvWeb.RoleLive.Helpers do
@moduledoc """
Shared helper functions for RoleLive modules.
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats an error for display to the user.
Extracts error messages from Ash.Error.Invalid and joins them.
"""
@spec format_error(Ash.Error.Invalid.t() | String.t() | any()) :: String.t()
def format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
def format_error(error) when is_binary(error), do: error
def format_error(_error), do: gettext("An error occurred")
@doc """
Returns the CSS badge class for a permission set name.
"""
@spec permission_set_badge_class(String.t()) :: String.t()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
def permission_set_badge_class("read_only"), do: "badge badge-info badge-sm"
def permission_set_badge_class("normal_user"), do: "badge badge-success badge-sm"
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
@doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
"""
@spec opts_with_actor(keyword(), map() | nil, atom()) :: keyword()
def opts_with_actor(base_opts \\ [], actor, domain) do
opts = Keyword.put(base_opts, :domain, domain)
if actor do
Keyword.put(opts, :actor, actor)
else
require Logger
Logger.warning("opts_with_actor called with nil actor - this may bypass policies")
opts
end
end
end

View file

@ -0,0 +1,170 @@
defmodule MvWeb.RoleLive.Index do
@moduledoc """
LiveView for displaying and managing the role list.
## Features
- List all roles with name, description, permission_set_name, is_system_role
- Create new roles
- Navigate to role details and edit forms
- Delete non-system roles
## Events
- `delete` - Remove a role from the database (only non-system roles)
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
actor = socket.assigns[:current_user]
roles = load_roles(actor)
user_counts = load_user_counts(roles, actor)
{:ok,
socket
|> assign(:page_title, gettext("Listing Roles"))
|> assign(:roles, roles)
|> assign(:user_counts, user_counts)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, id, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
defp handle_delete_role(role, id, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
else
perform_role_deletion(role, id, socket)
end
end
end
defp perform_role_deletion(role, id, socket) do
case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.user_counts, id)
{:noreply,
socket
|> assign(:roles, updated_roles)
|> assign(:user_counts, updated_counts)
|> put_flash(:info, gettext("Role deleted successfully."))}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = if actor, do: [actor: actor], else: []
case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name)
{:error, _} -> []
end
end
# Loads all user counts for roles using DB-side aggregation for better performance
@spec load_user_counts([Mv.Authorization.Role.t()], map() | nil) :: %{
Ecto.UUID.t() => non_neg_integer()
}
defp load_user_counts(roles, _actor) do
role_ids = Enum.map(roles, & &1.id)
# Use Ecto directly for efficient GROUP BY COUNT query
# This is much more performant than loading all users and counting in Elixir
# Note: We bypass Ash here for performance, but this is a simple read-only query
import Ecto.Query
query =
from u in Accounts.User,
where: u.role_id in ^role_ids,
group_by: u.role_id,
select: {u.role_id, count(u.id)}
results = Mv.Repo.all(query)
results
|> Enum.into(%{}, fn {role_id, count} -> {role_id, count} end)
end
# Gets user count from preloaded assigns map
@spec get_user_count(Mv.Authorization.Role.t(), %{Ecto.UUID.t() => non_neg_integer()}) ::
non_neg_integer()
defp get_user_count(role, user_counts) do
Map.get(user_counts, role.id, 0)
end
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count
_ -> 0
end
end
end

View file

@ -0,0 +1,97 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Listing Roles")}
<:subtitle>
{gettext("Manage user roles and their permission sets.")}
</:subtitle>
<:actions>
<%= if can?(@current_user, :create, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/new"}>
<.icon name="hero-plus" /> {gettext("New Role")}
</.button>
<% end %>
</:actions>
</.header>
<.table
id="roles"
rows={@roles}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
>
<:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{role.name}</span>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
<% end %>
</div>
</:col>
<:col :let={role} label={gettext("Description")}>
<%= if role.description do %>
<span class="text-sm">{role.description}</span>
<% else %>
<span class="text-base-content/70">{gettext("No description")}</span>
<% end %>
</:col>
<:col :let={role} label={gettext("Permission Set")}>
<span class={permission_set_badge_class(role.permission_set_name)}>
{role.permission_set_name}
</span>
</:col>
<:col :let={role} label={gettext("Type")}>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
<% else %>
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
<% end %>
</:col>
<:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
</:col>
<:action :let={role}>
<div class="sr-only">
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={role}>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</.link>
<% else %>
<div
:if={role.is_system_role}
class="tooltip tooltip-left"
data-tip={gettext("System roles cannot be deleted")}
>
<button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
disabled={true}
aria-label={gettext("Cannot delete system role")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -0,0 +1,216 @@
defmodule MvWeb.RoleLive.Show do
@moduledoc """
LiveView for displaying a single role's details.
## Features
- Display role information (name, description, permission_set_name, is_system_role)
- Navigate to edit form
- Return to role list
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Accounts
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(%{"id" => id}, _session, socket) do
try do
case Ash.get(
Mv.Authorization.Role,
id,
domain: Mv.Authorization,
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
user_count = load_user_count(role, socket.assigns[:current_user])
{:ok,
socket
|> assign(:page_title, gettext("Show Role"))
|> assign(:role, role)
|> assign(:user_count, user_count)}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
{:error, error} ->
{:ok,
socket
|> put_flash(:error, format_error(error))
|> redirect(to: ~p"/admin/roles")}
end
rescue
e in [Ash.Error.Invalid] ->
# Handle exceptions that Ash.get might throw (e.g., policy violations)
case e do
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
_ ->
reraise e, __STACKTRACE__
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Mv.Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
defp handle_delete_role(role, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
else
perform_role_deletion(role, socket)
end
end
end
defp perform_role_deletion(role, socket) do
case Mv.Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
{:noreply,
socket
|> put_flash(:info, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count
_ -> 0
end
end
# Loads user count for initial display (uses same logic as recalculate)
@spec load_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp load_user_count(role, actor) do
recalculate_user_count(role, actor)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions>
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to roles list")}</span>
</.button>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Role")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={gettext("Are you sure?")}
class="btn btn-error"
>
<.icon name="hero-trash" /> {gettext("Delete Role")}
</.link>
<% end %>
</:actions>
</.header>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
<%= if @role.description do %>
{@role.description}
<% else %>
<span class="text-base-content/70 italic">{gettext("No description")}</span>
<% end %>
</:item>
<:item title={gettext("Permission Set")}>
<span class={permission_set_badge_class(@role.permission_set_name)}>
{@role.permission_set_name}
</span>
</:item>
<:item title={gettext("System Role")}>
<%= if @role.is_system_role do %>
<span class="badge badge-warning">{gettext("Yes")}</span>
<% else %>
<span class="badge badge-ghost">{gettext("No")}</span>
<% end %>
</:item>
</.list>
</Layouts.app>
"""
end
end

View file

@ -131,7 +131,7 @@ defmodule MvWeb.UserLive.Form do
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{@user.member.first_name} {@user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
@ -210,7 +210,7 @@ defmodule MvWeb.UserLive.Form do
)
]}
>
<p class="font-medium">{member.first_name} {member.last_name}</p>
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
@ -438,7 +438,7 @@ defmodule MvWeb.UserLive.Form do
member_name =
if selected_member,
do: "#{selected_member.first_name} #{selected_member.last_name}",
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
else: ""
# Store the selected member ID and name in socket state and clear unlink flag

View file

@ -51,7 +51,7 @@
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>

View file

@ -57,7 +57,7 @@ defmodule MvWeb.UserLive.Show do
class="text-blue-600 underline hover:text-blue-800"
>
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
{@user.member.first_name} {@user.member.last_name}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</.link>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>

View file

@ -4,16 +4,59 @@ defmodule MvWeb.LiveHelpers do
## on_mount Hooks
- `:default` - Sets the user's locale from session (defaults to "de")
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
## Usage
Add to LiveView modules via:
```elixir
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
```
"""
import Phoenix.Component
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)
{:cont, socket}
end
def on_mount(:ensure_user_role_loaded, _params, _session, socket) do
socket = ensure_user_role_loaded(socket)
{:cont, socket}
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
end

View file

@ -46,7 +46,10 @@ defmodule MvWeb.Router do
AshAuthentication-specific: We define that all routes can only be accessed when the user is signed in.
"""
ash_authentication_live_session :authentication_required,
on_mount: {MvWeb.LiveUserAuth, :live_user_required} do
on_mount: [
{MvWeb.LiveUserAuth, :live_user_required},
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
] do
live "/", MemberLive.Index, :index
live "/members", MemberLive.Index, :index
@ -81,6 +84,12 @@ defmodule MvWeb.Router do
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new
live "/admin/roles/:id", RoleLive.Show, :show
live "/admin/roles/:id/edit", RoleLive.Form, :edit
post "/set_locale", LocaleController, :set_locale
end

View file

@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:first_name), do: gettext("First Name")
def label(:last_name), do: gettext("Last Name")
def label(:email), do: gettext("Email")
def label(:phone_number), do: gettext("Phone")
def label(:join_date), do: gettext("Join Date")
def label(:exit_date), do: gettext("Exit Date")
def label(:notes), do: gettext("Notes")
@ -28,6 +27,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:street), do: gettext("Street")
def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
# Fallback for unknown fields
def label(field) do

View file

@ -1,27 +1,27 @@
%{
"ash": {:hex, :ash, "3.11.1", "9794620bffeb83d1803d92a64e7803f70b57372eb4addba5c12a24343cd04e1a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e0074302bb88d667635fcbfdacbf8a641c53973a3902d0e744f567a49ec808fc"},
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"},
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
"ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"},
"ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"},
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
@ -56,7 +56,7 @@
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
@ -64,24 +64,24 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},

View file

@ -18,6 +18,8 @@ msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -39,6 +41,7 @@ msgstr "Stadt"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@ -46,7 +49,10 @@ msgstr "Löschen"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
@ -100,6 +106,7 @@ msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Show"
@ -121,6 +128,7 @@ msgid "close"
msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
@ -150,11 +158,6 @@ msgstr "Notizen"
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
@ -170,8 +173,10 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
@ -185,8 +190,10 @@ msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "No"
msgstr "Nein"
@ -197,9 +204,11 @@ msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
@ -256,9 +265,11 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@ -271,7 +282,12 @@ msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@ -314,8 +330,13 @@ msgstr "Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -347,6 +368,9 @@ msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
@ -404,6 +428,7 @@ msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -420,6 +445,7 @@ msgstr "aufsteigend"
msgid "descending"
msgstr "absteigend"
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "New"
@ -545,6 +571,7 @@ msgid "Search..."
msgstr "Suchen..."
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Users"
msgstr "Benutzer*innen"
@ -599,11 +626,6 @@ msgstr "Wähle ein Benutzerdefiniertes Feld"
msgid "Custom field"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
@ -614,7 +636,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -638,11 +659,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr "Benutzerdefiniertes Feld löschen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
@ -660,6 +676,8 @@ msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr "In Übersicht anzeigen"
@ -842,13 +860,6 @@ msgstr "Zahlungen"
msgid "Personal Data"
msgstr "Persönliche Daten"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Phone"
msgstr "Telefon"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -882,6 +893,7 @@ msgid "Amount"
msgstr "Betrag"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen"
@ -917,11 +929,6 @@ msgstr "Beitragsarten"
msgid "Contribution type"
msgstr "Beitragsart"
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann."
#: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions"
@ -959,10 +966,11 @@ msgstr "Familie"
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings"
msgstr "Vereinsdaten"
msgstr "Globale Einstellungen"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
@ -991,7 +999,7 @@ msgstr "Ehrenamtlich"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr "Zyklus"
msgstr "Intervall"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1219,11 +1227,6 @@ msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
msgid "Yearly"
msgstr "jährlich"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr "Spalten"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Custom Field %{id}"
@ -1255,32 +1258,6 @@ msgstr "Alle auswählen"
msgid "Select none"
msgstr "Keine auswählen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
msgstr "Zurück zur Felderliste"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field deleted successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich gelöscht"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Custom Field"
msgstr "Benutzerdefiniertes Feld löschen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete custom field: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field"
msgstr "Neues Benutzerdefiniertes Feld"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
@ -1292,6 +1269,7 @@ msgid "These will appear in addition to other data when adding new members."
msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werden, wenn ein neues Mitglied angelegt wird."
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Value Type"
msgstr "Wertetyp"
@ -1322,107 +1300,42 @@ msgstr "Textfeld"
msgid "Yes/No-Selection"
msgstr "Ja/Nein-Auswahl"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr "E-Mail-Adressen kopieren"
msgid "Memberdata"
msgstr "Mitgliederdaten"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "This field is required"
msgstr "Dieses Feld ist erforderlich"
msgid "Optional"
msgstr "Optional"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Default Membership Fee Type"
msgstr "Standard-Mitgliedsbeitragsart"
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Generated cycles"
msgstr "Generierte Zyklen"
msgid "Member field %{action} successfully"
msgstr "Mitglied wurde erfolgreich %{action}"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Include joining cycle"
msgstr "Beitrittsdatum einbeziehen"
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr "Mitgliedsbeitragseinstellungen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership fee start"
msgstr "Beitragsbeginn"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Monthly Interval - Joining Cycle Included"
msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "None (no default)"
msgstr "Keine (kein Standard)"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully."
msgstr "Einstellungen erfolgreich gespeichert"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "When active: Members pay from the cycle of their joining."
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Yearly Interval - Joining Cycle Excluded"
msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
msgid "A cycle for this period already exists"
msgstr "Für dieses Intervall besteht bereits ein Zyklus."
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
msgstr "Über Mitgliedsbeitragsarten"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "All cycles deleted"
msgstr "Alle Zyklen gelöscht"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Already paid cycles will remain with the old amount."
@ -1430,6 +1343,7 @@ msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
msgstr "Ein Fehler ist aufgetreten"
@ -1454,16 +1368,56 @@ msgstr "Betrag ändern?"
msgid "Changing the amount will affect %{count} member(s)."
msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr "Klicke um den Betrag zu ändern"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
msgstr "Änderung bestätigen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Confirmation text does not match"
msgstr "Bestätigungstext stimmt nicht überein"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr "E-Mail-Adressen kopieren"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create"
msgstr "erstellt"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Cycle"
msgstr "Aktueller Zyklus"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr "Erstelle manuell einen neuen Zyklus"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current Cycle"
msgstr "Aktueller Zyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr "Aktueller Zyklus Zahlungsstatus"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Current amount"
@ -1479,6 +1433,11 @@ msgstr "Zyklus"
msgid "Cycle amount updated"
msgstr "Zyklusbetrag aktualisiert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle created successfully"
msgstr "Zyklen erfolgreich regeneriert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle deleted"
@ -1494,6 +1453,21 @@ msgstr "Zyklenstatus aktualisiert"
msgid "Cycles regenerated successfully"
msgstr "Zyklen erfolgreich regeneriert"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default Membership Fee Type"
msgstr "Standard-Mitgliedsbeitragsart"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All"
msgstr "Löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All Cycles"
msgstr "Alle Zyklen löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Cycle"
@ -1504,11 +1478,21 @@ msgstr "Zyklus löschen"
msgid "Edit Cycle Amount"
msgstr "Zyklusbetrag bearbeiten"
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Field: %{field}"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Edit Membership Fee Type"
msgstr "Mitgliedsbeitragsart bearbeiten"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
msgstr "Mitgliedsbeitragsart bearbeiten"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Failed to update cycle status: %{errors}"
@ -1524,6 +1508,16 @@ msgstr "Zukünftige unbezahlte Zyklen werden mit dem neuen Betrag regeneriert."
msgid "Generate cycles from the last existing cycle to today"
msgstr "Zyklen vom letzten existierenden Zyklus bis heute generieren"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Generated cycles"
msgstr "Generierte Zyklen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Include joining cycle"
msgstr "Beitrittsdatum einbeziehen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Interval cannot be changed after creation."
@ -1534,11 +1528,21 @@ msgstr "Das Intervall kann nach der Erstellung nicht geändert werden."
msgid "Invalid amount format"
msgstr "Ungültiges Betragsformat"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Invalid date format"
msgstr "Ungültiges Betragsformat"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Last Cycle"
msgstr "Letzter Zyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr "Letzter Zyklus Zahlungsstatus"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
@ -1565,6 +1569,12 @@ msgstr "Als unbezahlt markieren"
msgid "Membership Fee"
msgstr "Mitgliedsbeitrag"
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr "Mitgliedsbeitragseinstellungen"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
@ -1588,6 +1598,11 @@ msgstr "Mitgliedsbeitragsarten"
msgid "Membership Fees"
msgstr "Mitgliedsbeiträge"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership fee start"
msgstr "Beitragsbeginn"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
@ -1613,6 +1628,11 @@ msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert."
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstrukturen. Jede Art hat ein festes Intervall (monatlich, vierteljährlich, halbjährlich, jährlich), das nach der Erstellung nicht geändert werden kann."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Monthly Interval - Joining Cycle Included"
msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1634,6 +1654,11 @@ msgstr "Kein Zyklus"
msgid "No cycles"
msgstr "Keine Zyklen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No cycles to delete"
msgstr "Keine Zyklen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -1650,11 +1675,31 @@ msgstr "Keine Mitgliedsbeitragsart zugewiesen"
msgid "No status"
msgstr "Kein Status"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "None (no default)"
msgstr "Keine (kein Standard)"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Interval"
msgstr "Zahlungsfilter"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please confirm the amount change first"
msgstr "Bitte bestätigen Sie zuerst die Betragsänderung"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Regenerate Cycles"
@ -1665,6 +1710,16 @@ msgstr "Zyklen regenerieren"
msgid "Regenerating..."
msgstr "Regeneriere..."
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Membership Fee Type"
@ -1680,156 +1735,167 @@ msgstr "Wählen Sie eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder k
msgid "Select interval"
msgstr "Intervall auswählen"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This action cannot be undone."
msgstr "Diese Aktion kann nicht rückgängig gemacht werden."
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "This field is required"
msgstr "Dieses Feld ist erforderlich"
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "This is a technical field and cannot be changed"
msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden."
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Type"
msgstr "Art"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr "Trage '%{confirmation}' ein um zu bestätigen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr "Verwenden Sie dieses Formular, um Mitgliedsbeitragsarten in Ihrer Datenbank zu verwalten."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
msgstr "Warnung"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wählen Sie eine Mitgliedsbeitragsart mit demselben Intervall."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "All cycles deleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create"
msgstr "erstellt"
msgid "When active: Members pay from the cycle of their joining."
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Cycle"
msgstr "Aktueller Zyklus"
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle Period"
msgstr "Zyklus"
msgid "Yearly Interval - Joining Cycle Excluded"
msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cycle created successfully"
msgstr "Zyklen erfolgreich regeneriert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All"
msgstr "Löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete All Cycles"
msgstr "Alle Zyklen löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete all cycles"
msgstr "Zyklus löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete cycle"
msgstr "Zyklus löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Invalid date format"
msgstr "Ungültiges Betragsformat"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Interval"
msgstr "Zahlungsfilter"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "This action cannot be undone."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen."
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr "Aktueller Zyklus Zahlungsstatus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr "Letzter Zyklus Zahlungsstatus"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete membership fee type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann."
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
msgstr "Mitgliedsbeitragsart bearbeiten"
msgid "Delete Membership Fee Type"
msgstr "Mitgliedsbeitragsart löschen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Start Date"
msgstr "Mitgliedsbeitragsstatus"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Confirmation text does not match"
msgstr ""
msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No cycles to delete"
msgstr "Keine Zyklen"
msgid "The cycle will be calculated based on this date and the interval."
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings"
msgstr "Zurück zu den Einstellungen"
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field %{action} successfully"
msgstr "Datenfeld erfolgreich %{action}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field deleted successfully"
msgstr "Datenfeld erfolgreich gelöscht"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Data Field"
msgstr "Datenfeld löschen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Data Field"
msgstr "Datenfeld bearbeiten"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete data field: %{error}"
msgstr "Konnte Datenfeld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Data Field"
msgstr "Neues Datenfeld"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Data Field"
msgstr "Datenfeld speichern"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr "Jeder Zahlungs-Zustand"
#~ msgid "Show current cycle"
#~ msgstr "Aktuellen Zyklus anzeigen"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr "Unbezahlt im letzten Zyklus"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr "Benutzerdefiniertes Feld speichern"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to roles list"
msgstr "Zurück zur Rollen-Liste"
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
@ -1863,10 +1929,11 @@ msgstr "Nicht gesetzt"
#~ msgid "Default Contribution Type"
#~ msgstr "Standard-Beitragsart"
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Edit amount"
#~ msgstr "Betrag bearbeiten"
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No description"
msgstr "Beschreibung"
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
@ -1903,6 +1970,18 @@ msgstr "Nicht gesetzt"
#~ msgid "Pending"
#~ msgstr "Ausstehend"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #: lib/mv_web/translations/member_fields.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone"
#~ msgstr "Telefon"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Phone Number"
#~ msgstr "Telefonnummer"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
@ -1965,7 +2044,7 @@ msgstr "Nicht gesetzt"
#~ msgid "monthly"
#~ msgstr "monatlich"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "yearly"
#~ msgstr "jährlich"

View file

@ -19,6 +19,8 @@ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -40,6 +42,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@ -47,7 +50,10 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
@ -101,6 +107,7 @@ msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Show"
@ -122,6 +129,7 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
@ -151,11 +159,6 @@ msgstr ""
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
@ -171,8 +174,10 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
@ -186,8 +191,10 @@ msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
@ -198,9 +205,11 @@ msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@ -257,9 +266,11 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@ -272,7 +283,12 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -316,8 +332,13 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -344,6 +365,9 @@ msgid "Password Authentication"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
@ -402,6 +426,7 @@ msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -418,6 +443,7 @@ msgstr ""
msgid "descending"
msgstr ""
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "New"
@ -545,6 +571,7 @@ msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Users"
@ -600,11 +627,6 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
@ -615,7 +637,6 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -640,11 +661,6 @@ msgstr[1] ""
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
@ -662,6 +678,8 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr ""
@ -844,13 +862,6 @@ msgstr ""
msgid "Personal Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -884,6 +895,7 @@ msgid "Amount"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to Settings"
msgstr ""
@ -920,11 +932,6 @@ msgstr ""
msgid "Contribution type"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
@ -963,6 +970,7 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Global Settings"
@ -1223,11 +1231,6 @@ msgstr ""
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Columns"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Custom Field %{id}"
@ -1259,32 +1262,6 @@ msgstr ""
msgid "Select none"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Custom field deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Edit Custom Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete custom field: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
@ -1296,6 +1273,7 @@ msgid "These will appear in addition to other data when adding new members."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Value Type"
msgstr ""
@ -1326,100 +1304,30 @@ msgstr ""
msgid "Yes/No-Selection"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy email addresses"
msgid "Memberdata"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field"
msgid "Optional"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field Value"
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This field is required"
msgid "Member field %{action} successfully"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership fees."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated cycles"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining cycle"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Monthly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "None (no default)"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings saved successfully."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When active: Members pay from the cycle of their joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Included"
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -1427,6 +1335,11 @@ msgstr ""
msgid "About Membership Fee Types"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "All cycles deleted"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Already paid cycles will remain with the old amount."
@ -1434,6 +1347,7 @@ msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
msgstr ""
@ -1458,16 +1372,56 @@ msgstr ""
msgid "Changing the amount will affect %{count} member(s)."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership fees."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Confirmation text does not match"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create Cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Current Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Current amount"
@ -1483,6 +1437,11 @@ msgstr ""
msgid "Cycle amount updated"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle created successfully"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle deleted"
@ -1498,6 +1457,21 @@ msgstr ""
msgid "Cycles regenerated successfully"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default Membership Fee Type"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete All"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete All Cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Cycle"
@ -1508,11 +1482,21 @@ msgstr ""
msgid "Edit Cycle Amount"
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Edit Field: %{field}"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Edit membership fee type"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Failed to update cycle status: %{errors}"
@ -1528,6 +1512,16 @@ msgstr ""
msgid "Generate cycles from the last existing cycle to today"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated cycles"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining cycle"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Interval cannot be changed after creation."
@ -1538,11 +1532,21 @@ msgstr ""
msgid "Invalid amount format"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invalid date format"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Last Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
@ -1569,6 +1573,12 @@ msgstr ""
msgid "Membership Fee"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Membership Fee Status"
@ -1592,6 +1602,11 @@ msgstr ""
msgid "Membership Fees"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
@ -1617,6 +1632,11 @@ msgstr ""
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Monthly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1638,6 +1658,11 @@ msgstr ""
msgid "No cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No cycles to delete"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -1654,11 +1679,31 @@ msgstr ""
msgid "No status"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "None (no default)"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Interval"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please confirm the amount change first"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Regenerate Cycles"
@ -1669,6 +1714,16 @@ msgstr ""
msgid "Regenerating..."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field Value"
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Field"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Membership Fee Type"
@ -1684,110 +1739,76 @@ msgstr ""
msgid "Select interval"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings saved successfully."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "This action cannot be undone."
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "This field is required"
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "This is a technical field and cannot be changed"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Type"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "A cycle for this period already exists"
msgid "When active: Members pay from the cycle of their joining."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "All cycles deleted"
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgid "Yearly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Create"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create Cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Create a new cycle manually"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle Period"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cycle created successfully"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete All"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete All Cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete all cycles"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Delete cycle"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invalid date format"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Interval"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "This action cannot be undone."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning"
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -1795,67 +1816,110 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete membership fee type"
msgid "Delete Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Edit membership fee type"
msgid "Membership Fee Start Date"
msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format
msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Confirmation text does not match"
msgid "The cycle will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "No cycles to delete"
msgid "Back to settings"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Data field %{action} successfully"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Data field deleted successfully"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Delete Data Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Edit Data Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete data field: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "New Data Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Data Field"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to roles list"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Cannot delete system role"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Edit Role"
msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}"
msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Roles"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Manage user roles and their permission sets."
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Main navigation"
msgstr ""
#: lib/mv_web/components/layouts.ex
#, elixir-autogen, elixir-format
msgid "Open navigation menu"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Profile"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Toggle sidebar"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "User menu"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,404 @@
defmodule Mv.Repo.Migrations.RemovePhoneNumberAndMakeFieldsOptional do
@moduledoc """
Removes phone_number field from members table and makes first_name/last_name optional.
This migration:
1. Removes phone_number column from members table
2. Makes first_name and last_name columns nullable
3. Updates members_search_vector_trigger() function to remove phone_number
4. Updates update_member_search_vector_from_custom_field_value() function to remove phone_number
5. Updates existing search_vector values for all members
"""
use Ecto.Migration
def up do
# Update the main trigger function to remove phone_number
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
BEGIN
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
-- Build search_vector with member fields and custom field values
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Update trigger function to remove phone_number
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
old_value_text text;
new_value_text text;
BEGIN
-- Get member ID from trigger context
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
-- Optimization: For UPDATE operations, check if value actually changed
-- If value hasn't changed, we can skip the expensive re-aggregation
IF TG_OP = 'UPDATE' THEN
-- Extract OLD value for comparison (handle both JSONB formats)
-- ->> operator always returns TEXT directly
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
-- Extract NEW value for comparison (handle both JSONB formats)
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
-- Check if value, member_id, or custom_field_id actually changed
-- If nothing changed, skip expensive re-aggregation
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
-- Fetch only required fields instead of full record (performance optimization)
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
-- Update the search_vector for the affected member
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Update existing search_vector values for all members
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C')
""")
# Make first_name and last_name nullable
execute("ALTER TABLE members ALTER COLUMN first_name DROP NOT NULL")
execute("ALTER TABLE members ALTER COLUMN last_name DROP NOT NULL")
# Remove phone_number column
alter table(:members) do
remove :phone_number
end
end
def down do
# Set default values for NULL fields before restoring NOT NULL constraint
# This prevents the migration from failing if NULL values exist
execute("UPDATE members SET first_name = '' WHERE first_name IS NULL")
execute("UPDATE members SET last_name = '' WHERE last_name IS NULL")
# Restore first_name and last_name as NOT NULL
execute("ALTER TABLE members ALTER COLUMN first_name SET NOT NULL")
execute("ALTER TABLE members ALTER COLUMN last_name SET NOT NULL")
# Add phone_number column back
alter table(:members) do
add :phone_number, :text
end
# Restore trigger functions with phone_number
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
BEGIN
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
-- Build search_vector with member fields and custom field values
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_phone_number text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
old_value_text text;
new_value_text text;
BEGIN
-- Get member ID from trigger context
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
-- Optimization: For UPDATE operations, check if value actually changed
-- If value hasn't changed, we can skip the expensive re-aggregation
IF TG_OP = 'UPDATE' THEN
-- Extract OLD value for comparison (handle both JSONB formats)
-- ->> operator always returns TEXT directly
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
-- Extract NEW value for comparison (handle both JSONB formats)
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
-- Check if value, member_id, or custom_field_id actually changed
-- If nothing changed, skip expensive re-aggregation
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
-- Fetch only required fields instead of full record (performance optimization)
SELECT
first_name,
last_name,
email,
phone_number,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_phone_number,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
-- Update the search_vector for the affected member
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Update existing search_vector values to include phone_number
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C')
""")
end
end

View file

@ -0,0 +1,80 @@
defmodule Mv.Repo.Migrations.AddAuthorizationDomain do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :role_id, :uuid
end
create table(:roles, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
end
alter table(:users) do
modify :role_id,
references(:roles,
column: :id,
name: "users_role_id_fkey",
type: :uuid,
on_delete: :restrict,
prefix: "public"
)
end
alter table(:roles) do
add :name, :text, null: false
add :description, :text
add :permission_set_name, :text, null: false
add :is_system_role, :boolean, null: false, default: false
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
create unique_index(:roles, [:name], name: "roles_unique_name_index")
create index(:roles, [:permission_set_name], name: "roles_permission_set_name_index")
create index(:users, [:role_id], name: "users_role_id_index")
end
def down do
drop_if_exists index(:users, [:role_id], name: "users_role_id_index")
drop_if_exists index(:roles, [:permission_set_name], name: "roles_permission_set_name_index")
drop_if_exists unique_index(:roles, [:name], name: "roles_unique_name_index")
alter table(:roles) do
remove :updated_at
remove :inserted_at
remove :is_system_role
remove :permission_set_name
remove :description
remove :name
end
drop constraint(:users, "users_role_id_fkey")
alter table(:users) do
modify :role_id, :uuid
end
drop table(:roles)
alter table(:users) do
remove :role_id
end
end
end

View file

@ -5,6 +5,7 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
@ -124,9 +125,42 @@ for attrs <- [
end
# Create admin user for testing
Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
admin_user =
Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
# Create admin role and assign it to admin user
admin_role =
case Authorization.list_roles() do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin" && &1.permission_set_name == "admin")) do
nil ->
# Create admin role if it doesn't exist
case Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
}) do
{:ok, role} -> role
{:error, _error} -> nil
end
role ->
role
end
{:error, _error} ->
nil
end
# Assign admin role to admin user if role was created/found
if admin_role do
admin_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
end
# Load all membership fee types for assignment
# Sort by name to ensure deterministic order
@ -147,7 +181,6 @@ member_attrs_list = [
last_name: "Müller",
email: "hans.mueller@example.de",
join_date: ~D[2023-01-15],
phone_number: "+49301234567",
city: "München",
street: "Hauptstraße",
house_number: "42",
@ -160,7 +193,6 @@ member_attrs_list = [
last_name: "Schmidt",
email: "greta.schmidt@example.de",
join_date: ~D[2023-02-01],
phone_number: "+49309876543",
city: "Hamburg",
street: "Lindenstraße",
house_number: "17",
@ -174,7 +206,6 @@ member_attrs_list = [
last_name: "Wagner",
email: "friedrich.wagner@example.de",
join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8",
@ -186,7 +217,6 @@ member_attrs_list = [
last_name: "Wagner",
email: "marianne.wagner@example.de",
join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
@ -299,7 +329,6 @@ linked_members = [
last_name: "Weber",
email: "maria.weber@example.de",
join_date: ~D[2023-03-15],
phone_number: "+49301357924",
city: "Frankfurt",
street: "Goetheplatz",
house_number: "5",
@ -313,7 +342,6 @@ linked_members = [
last_name: "Klein",
email: "thomas.klein@example.de",
join_date: ~D[2023-04-01],
phone_number: "+49302468135",
city: "Köln",
street: "Rheinstraße",
house_number: "23",
@ -497,10 +525,39 @@ default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
case Membership.get_settings() do
{:ok, existing_settings} ->
# Settings exist, update if club_name is different from env var
if existing_settings.club_name != default_club_name do
{:ok, _updated} =
Membership.update_settings(existing_settings, %{club_name: default_club_name})
# Also ensure exit_date is set to false by default if not already configured
updates =
%{}
|> then(fn acc ->
if existing_settings.club_name != default_club_name,
do: Map.put(acc, :club_name, default_club_name),
else: acc
end)
|> then(fn acc ->
visibility_config = existing_settings.member_field_visibility || %{}
# Ensure exit_date is set to false if not already configured
if not Map.has_key?(visibility_config, "exit_date") and
not Map.has_key?(visibility_config, :exit_date) do
updated_visibility = Map.put(visibility_config, "exit_date", false)
Map.put(acc, :member_field_visibility, updated_visibility)
else
acc
end
end)
if map_size(updates) > 0 do
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
end
{:ok, nil} ->
# Settings don't exist yet, create with exit_date defaulting to false
{:ok, _settings} =
Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!()
end
IO.puts("✅ Seeds completed successfully!")

View file

@ -0,0 +1,118 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "permission_set_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "is_system_role",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "8822483B2830DB45988E3B673F36EAE43311B336EE34FBDA1FA24BF9867D7494",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "roles_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "roles"
}

View file

@ -0,0 +1,172 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "oidc_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_member_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_role_id_fkey",
"on_delete": "restrict",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "roles"
},
"scale": null,
"size": null,
"source": "role_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E381FA10CFC1D8D4CCD09AC1AD4B0CC9F8931436F22139CCF3A4558E84C422D3",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_oidc_id_index",
"keys": [
{
"type": "atom",
"value": "oidc_id"
}
],
"name": "unique_oidc_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -13,14 +13,17 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
alias Mv.Membership.Member
describe "show_in_overview?/1" do
test "returns true for all member fields by default" do
test "returns true for all member fields by default, except exit_date" do
# When no settings exist or member_field_visibility is not configured
# Test with fields from constants
# Note: exit_date defaults to false (hidden) by design
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
expected_visibility = if field == :exit_date, do: false, else: true
assert Member.show_in_overview?(field) == expected_visibility,
"Field #{field} should be #{if expected_visibility, do: "visible", else: "hidden"} by default"
end)
end
@ -77,4 +80,72 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
end)
end
end
describe "update_single_member_field_visibility/3" do
test "atomically updates a single field in member_field_visibility" do
{:ok, settings} = Mv.Membership.get_settings()
field_string = "street"
# Update single field
{:ok, updated_settings} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field_string,
show_in_overview: false
)
# Verify the field was updated
assert updated_settings.member_field_visibility[field_string] == false
# Verify other fields are not affected
other_fields =
Mv.Constants.member_fields()
|> Enum.reject(&(&1 == String.to_existing_atom(field_string)))
Enum.each(other_fields, fn field ->
field_string = Atom.to_string(field)
# Fields not explicitly set should default to true (except exit_date)
expected = if field == :exit_date, do: false, else: true
assert Map.get(updated_settings.member_field_visibility, field_string, expected) ==
expected
end)
end
test "returns error for invalid field name" do
{:ok, settings} = Mv.Membership.get_settings()
assert {:error, %Ash.Error.Invalid{errors: [%{field: :member_field_visibility}]}} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: "invalid_field",
show_in_overview: false
)
end
test "handles concurrent updates atomically" do
{:ok, settings} = Mv.Membership.get_settings()
field1 = "street"
field2 = "house_number"
# Simulate concurrent updates by updating different fields
{:ok, updated1} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field1,
show_in_overview: false
)
{:ok, updated2} =
Mv.Membership.update_single_member_field_visibility(
updated1,
field: field2,
show_in_overview: true
)
# Both fields should be correctly updated
assert updated2.member_field_visibility[field1] == false
assert updated2.member_field_visibility[field2] == true
end
end
end

View file

@ -7,7 +7,6 @@ defmodule Mv.Membership.MemberTest do
first_name: "John",
last_name: "Doe",
email: "john@example.com",
phone_number: "+49123456789",
join_date: ~D[2020-01-01],
exit_date: nil,
notes: "Test note",
@ -17,16 +16,14 @@ defmodule Mv.Membership.MemberTest do
postal_code: "12345"
}
test "First name is required and must not be empty" do
attrs = Map.put(@valid_attrs, :first_name, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :first_name) =~ "must be present"
test "First name is optional" do
attrs = Map.delete(@valid_attrs, :first_name)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "Last name is required and must not be empty" do
attrs = Map.put(@valid_attrs, :last_name, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :last_name) =~ "must be present"
test "Last name is optional" do
attrs = Map.delete(@valid_attrs, :last_name)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "Email is required" do
@ -41,14 +38,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email"
end
test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :phone_number) =~ "is not a valid phone number"
attrs2 = Map.delete(@valid_attrs, :phone_number)
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Join date cannot be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))

View file

@ -0,0 +1,87 @@
defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
@moduledoc """
Integration tests for HasPermission policy check.
These tests verify that the filter expressions generated by HasPermission
have the correct structure for relationship-based filtering.
Note: Full integration tests with real queries require resources to have
policies that use HasPermission. These tests validate filter expression
structure and ensure the relationship paths are correct.
"""
use ExUnit.Case, async: true
alias Mv.Authorization.Checks.HasPermission
# Helper to create mock actor with role
defp create_actor_with_role(permission_set_name) do
%{
id: "user-#{System.unique_integer([:positive])}",
role: %{permission_set_name: permission_set_name}
}
end
describe "Filter Expression Structure - :linked scope" do
test "Member filter uses user.id relationship path" do
actor = create_actor_with_role("own_data")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# Verify filter is not nil (should return a filter for :linked scope)
assert not is_nil(filter)
# The filter should be a valid expression (keyword list or Ash.Expr)
# We verify it's not nil and can be used in queries
assert is_list(filter) or is_map(filter)
end
test "CustomFieldValue filter uses member.user.id relationship path" do
actor = create_actor_with_role("own_data")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# Verify filter is not nil
assert not is_nil(filter)
# The filter should be a valid expression
assert is_list(filter) or is_map(filter)
end
end
describe "Filter Expression Structure - :own scope" do
test "User filter uses id == actor.id" do
actor = create_actor_with_role("own_data")
authorizer = create_authorizer(Mv.Accounts.User, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# Verify filter is not nil (should return a filter for :own scope)
assert not is_nil(filter)
# The filter should be a valid expression
assert is_list(filter) or is_map(filter)
end
end
describe "Filter Expression Structure - :all scope" do
test "Admin can read all members without filter" do
actor = create_actor_with_role("admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# :all scope should return nil (no filter needed)
assert is_nil(filter)
end
end
# Helper to create a mock authorizer
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
}
end
end

View file

@ -0,0 +1,264 @@
defmodule Mv.Authorization.Checks.HasPermissionTest do
@moduledoc """
Tests for the HasPermission Ash Policy Check.
This check evaluates permissions from the PermissionSets module and applies
scope filters to Ash queries.
"""
use ExUnit.Case, async: true
alias Mv.Authorization.Checks.HasPermission
# Helper to create a mock authorizer for strict_check/3
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
}
end
# Helper to create actor with role
defp create_actor(id, permission_set_name) do
%{
id: id,
role: %{permission_set_name: permission_set_name}
}
end
describe "describe/1" do
test "returns human-readable description" do
description = HasPermission.describe([])
assert is_binary(description)
assert description =~ "permission"
end
end
describe "strict_check/3 - Permission Lookup" do
test "admin has permission for all resources/actions" do
admin = create_actor("admin-123", "admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(admin, authorizer, [])
assert result == true or result == :unknown
end
test "read_only has read permission for Member" do
read_only_user = create_actor("read-only-123", "read_only")
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(read_only_user, authorizer, [])
assert result == true or result == :unknown
end
test "read_only does NOT have create permission for Member" do
read_only_user = create_actor("read-only-123", "read_only")
authorizer = create_authorizer(Mv.Membership.Member, :create)
{:ok, result} = HasPermission.strict_check(read_only_user, authorizer, [])
assert result == false
end
test "own_data has update permission for User with scope :own" do
own_data_user = create_actor("user-123", "own_data")
authorizer = create_authorizer(Mv.Accounts.User, :update)
{:ok, result} = HasPermission.strict_check(own_data_user, authorizer, [])
# Should return :unknown for :own scope (needs filter)
assert result == :unknown
end
end
describe "strict_check/3 - Scope :all" do
test "actor with scope :all can access any record" do
admin = create_actor("admin-123", "admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(admin, authorizer, [])
# :all scope should return true (no filter needed)
assert result == true
end
test "admin can read all members without filter" do
admin = create_actor("admin-123", "admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(admin, authorizer, [])
# Should return true for :all scope
assert result == true
end
end
describe "strict_check/3 - Scope :own" do
test "actor with scope :own returns :unknown (needs filter)" do
user = create_actor("user-123", "own_data")
authorizer = create_authorizer(Mv.Accounts.User, :read)
{:ok, result} = HasPermission.strict_check(user, authorizer, [])
# Should return :unknown for :own scope (needs filter via auto_filter)
assert result == :unknown
end
end
describe "auto_filter/3 - Scope :own" do
test "scope :own returns filter expression" do
user = create_actor("user-123", "own_data")
authorizer = create_authorizer(Mv.Accounts.User, :update)
filter = HasPermission.auto_filter(user, authorizer, [])
# Should return a filter expression
assert not is_nil(filter)
end
end
describe "auto_filter/3 - Scope :linked" do
test "scope :linked for Member returns user_id filter" do
user = create_actor("user-123", "own_data")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(user, authorizer, [])
# Should return a filter expression
assert not is_nil(filter)
end
test "scope :linked for CustomFieldValue returns member.user_id filter" do
user = create_actor("user-123", "own_data")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
filter = HasPermission.auto_filter(user, authorizer, [])
# Should return a filter expression that traverses member relationship
assert not is_nil(filter)
end
end
describe "strict_check/3 - Error Handling" do
test "returns {:ok, false} for nil actor" do
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(nil, authorizer, [])
assert result == false
end
test "returns {:ok, false} for actor missing role" do
actor_without_role = %{id: "user-123"}
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(actor_without_role, authorizer, [])
assert result == false
end
test "returns {:ok, false} for actor with nil role" do
actor_with_nil_role = %{id: "user-123", role: nil}
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(actor_with_nil_role, authorizer, [])
assert result == false
end
test "returns {:ok, false} for invalid permission_set_name" do
actor_with_invalid_permission = %{
id: "user-123",
role: %{permission_set_name: "invalid_set"}
}
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(actor_with_invalid_permission, authorizer, [])
assert result == false
end
test "returns {:ok, false} for no matching permission" do
read_only_user = create_actor("read-only-123", "read_only")
authorizer = create_authorizer(Mv.Authorization.Role, :create)
{:ok, result} = HasPermission.strict_check(read_only_user, authorizer, [])
assert result == false
end
test "handles role with nil permission_set_name gracefully" do
actor_with_nil_permission_set = %{
id: "user-123",
role: %{permission_set_name: nil}
}
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(actor_with_nil_permission_set, authorizer, [])
assert result == false
end
end
describe "strict_check/3 - Logging" do
import ExUnit.CaptureLog
test "logs authorization failure for nil actor" do
authorizer = create_authorizer(Mv.Membership.Member, :read)
log =
capture_log(fn ->
HasPermission.strict_check(nil, authorizer, [])
end)
assert log =~ "Authorization failed" or log == ""
end
test "logs authorization failure for missing role" do
actor_without_role = %{id: "user-123"}
authorizer = create_authorizer(Mv.Membership.Member, :read)
log =
capture_log(fn ->
HasPermission.strict_check(actor_without_role, authorizer, [])
end)
assert log =~ "Authorization failed" or log == ""
end
end
describe "strict_check/3 - Resource Name Extraction" do
test "correctly extracts resource name from nested module" do
admin = create_actor("admin-123", "admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
{:ok, result} = HasPermission.strict_check(admin, authorizer, [])
# Should work correctly (not crash)
assert result == true or result == :unknown or result == false
end
test "works with different resource modules" do
admin = create_actor("admin-123", "admin")
resources = [
Mv.Accounts.User,
Mv.Membership.Member,
Mv.Membership.CustomFieldValue,
Mv.Membership.CustomField,
Mv.Authorization.Role
]
for resource <- resources do
authorizer = create_authorizer(resource, :read)
{:ok, result} = HasPermission.strict_check(admin, authorizer, [])
# Should not crash and should return valid result
assert result == true or result == :unknown or result == false
end
end
end
end

View file

@ -0,0 +1,584 @@
defmodule Mv.Authorization.PermissionSetsTest do
@moduledoc """
Tests for the PermissionSets module that defines hardcoded permission sets.
"""
use ExUnit.Case, async: true
alias Mv.Authorization.PermissionSets
describe "all_permission_sets/0" do
test "returns all four permission sets" do
sets = PermissionSets.all_permission_sets()
assert length(sets) == 4
assert :own_data in sets
assert :read_only in sets
assert :normal_user in sets
assert :admin in sets
end
end
describe "get_permissions/1" do
test "all permission sets return map with :resources and :pages keys" do
for set <- PermissionSets.all_permission_sets() do
permissions = PermissionSets.get_permissions(set)
assert Map.has_key?(permissions, :resources),
"#{set} missing :resources key"
assert Map.has_key?(permissions, :pages),
"#{set} missing :pages key"
assert is_list(permissions.resources),
"#{set} :resources must be a list"
assert is_list(permissions.pages),
"#{set} :pages must be a list"
end
end
test "each resource permission has required keys" do
permissions = PermissionSets.get_permissions(:own_data)
Enum.each(permissions.resources, fn perm ->
assert Map.has_key?(perm, :resource)
assert Map.has_key?(perm, :action)
assert Map.has_key?(perm, :scope)
assert Map.has_key?(perm, :granted)
assert is_binary(perm.resource)
assert perm.action in [:read, :create, :update, :destroy]
assert perm.scope in [:own, :linked, :all]
assert is_boolean(perm.granted)
end)
end
test "pages lists are non-empty for all permission sets" do
for set <- [:own_data, :read_only, :normal_user, :admin] do
permissions = PermissionSets.get_permissions(set)
assert not Enum.empty?(permissions.pages),
"Permission set #{set} should have at least one page"
end
end
end
describe "get_permissions/1 - :own_data permission content" do
test "allows User read/update with scope :own" do
permissions = PermissionSets.get_permissions(:own_data)
user_read =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :read end)
user_update =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :update end)
assert user_read.scope == :own
assert user_read.granted == true
assert user_update.scope == :own
assert user_update.granted == true
end
test "allows Member read/update with scope :linked" do
permissions = PermissionSets.get_permissions(:own_data)
member_read =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :read end)
member_update =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :update end)
assert member_read.scope == :linked
assert member_read.granted == true
assert member_update.scope == :linked
assert member_update.granted == true
end
test "allows CustomFieldValue read/update with scope :linked" do
permissions = PermissionSets.get_permissions(:own_data)
cfv_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :read
end)
cfv_update =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :update
end)
assert cfv_read.scope == :linked
assert cfv_read.granted == true
assert cfv_update.scope == :linked
assert cfv_update.granted == true
end
test "allows CustomField read with scope :all" do
permissions = PermissionSets.get_permissions(:own_data)
cf_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :read
end)
assert cf_read.scope == :all
assert cf_read.granted == true
end
test "includes correct pages" do
permissions = PermissionSets.get_permissions(:own_data)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
assert "/members/:id" in permissions.pages
end
end
describe "get_permissions/1 - :read_only permission content" do
test "allows User read/update with scope :own" do
permissions = PermissionSets.get_permissions(:read_only)
user_read =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :read end)
user_update =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :update end)
assert user_read.scope == :own
assert user_read.granted == true
assert user_update.scope == :own
assert user_update.granted == true
end
test "allows Member read with scope :all" do
permissions = PermissionSets.get_permissions(:read_only)
member_read =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :read end)
assert member_read.scope == :all
assert member_read.granted == true
end
test "does NOT allow Member create/update/destroy" do
permissions = PermissionSets.get_permissions(:read_only)
member_create =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :create end)
member_update =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :update end)
member_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "Member" && p.action == :destroy
end)
assert member_create == nil || member_create.granted == false
assert member_update == nil || member_update.granted == false
assert member_destroy == nil || member_destroy.granted == false
end
test "allows CustomFieldValue read with scope :all" do
permissions = PermissionSets.get_permissions(:read_only)
cfv_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :read
end)
assert cfv_read.scope == :all
assert cfv_read.granted == true
end
test "does NOT allow CustomFieldValue create/update/destroy" do
permissions = PermissionSets.get_permissions(:read_only)
cfv_create =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :create
end)
cfv_update =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :update
end)
cfv_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :destroy
end)
assert cfv_create == nil || cfv_create.granted == false
assert cfv_update == nil || cfv_update.granted == false
assert cfv_destroy == nil || cfv_destroy.granted == false
end
test "allows CustomField read with scope :all" do
permissions = PermissionSets.get_permissions(:read_only)
cf_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :read
end)
assert cf_read.scope == :all
assert cf_read.granted == true
end
test "includes correct pages" do
permissions = PermissionSets.get_permissions(:read_only)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
assert "/members" in permissions.pages
assert "/members/:id" in permissions.pages
assert "/custom_field_values" in permissions.pages
assert "/custom_field_values/:id" in permissions.pages
end
end
describe "get_permissions/1 - :normal_user permission content" do
test "allows User read/update with scope :own" do
permissions = PermissionSets.get_permissions(:normal_user)
user_read =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :read end)
user_update =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :update end)
assert user_read.scope == :own
assert user_read.granted == true
assert user_update.scope == :own
assert user_update.granted == true
end
test "allows Member read/create/update with scope :all" do
permissions = PermissionSets.get_permissions(:normal_user)
member_read =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :read end)
member_create =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :create end)
member_update =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :update end)
assert member_read.scope == :all
assert member_read.granted == true
assert member_create.scope == :all
assert member_create.granted == true
assert member_update.scope == :all
assert member_update.granted == true
end
test "does NOT allow Member destroy (safety)" do
permissions = PermissionSets.get_permissions(:normal_user)
member_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "Member" && p.action == :destroy
end)
assert member_destroy == nil || member_destroy.granted == false
end
test "allows CustomFieldValue full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:normal_user)
cfv_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :read
end)
cfv_create =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :create
end)
cfv_update =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :update
end)
cfv_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :destroy
end)
assert cfv_read.scope == :all
assert cfv_read.granted == true
assert cfv_create.scope == :all
assert cfv_create.granted == true
assert cfv_update.scope == :all
assert cfv_update.granted == true
assert cfv_destroy.scope == :all
assert cfv_destroy.granted == true
end
test "allows CustomField read with scope :all" do
permissions = PermissionSets.get_permissions(:normal_user)
cf_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :read
end)
assert cf_read.scope == :all
assert cf_read.granted == true
end
test "includes correct pages" do
permissions = PermissionSets.get_permissions(:normal_user)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
assert "/members" in permissions.pages
assert "/members/new" in permissions.pages
assert "/members/:id" in permissions.pages
assert "/members/:id/edit" in permissions.pages
assert "/custom_field_values" in permissions.pages
assert "/custom_field_values/:id" in permissions.pages
assert "/custom_field_values/new" in permissions.pages
assert "/custom_field_values/:id/edit" in permissions.pages
end
end
describe "get_permissions/1 - :admin permission content" do
test "allows User full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
user_read =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :read end)
user_create =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :create end)
user_update =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :update end)
user_destroy =
Enum.find(permissions.resources, fn p -> p.resource == "User" && p.action == :destroy end)
assert user_read.scope == :all
assert user_read.granted == true
assert user_create.scope == :all
assert user_create.granted == true
assert user_update.scope == :all
assert user_update.granted == true
assert user_destroy.scope == :all
assert user_destroy.granted == true
end
test "allows Member full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
member_read =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :read end)
member_create =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :create end)
member_update =
Enum.find(permissions.resources, fn p -> p.resource == "Member" && p.action == :update end)
member_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "Member" && p.action == :destroy
end)
assert member_read.scope == :all
assert member_read.granted == true
assert member_create.scope == :all
assert member_create.granted == true
assert member_update.scope == :all
assert member_update.granted == true
assert member_destroy.scope == :all
assert member_destroy.granted == true
end
test "allows CustomFieldValue full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
cfv_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :read
end)
cfv_create =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :create
end)
cfv_update =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :update
end)
cfv_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomFieldValue" && p.action == :destroy
end)
assert cfv_read.scope == :all
assert cfv_read.granted == true
assert cfv_create.scope == :all
assert cfv_create.granted == true
assert cfv_update.scope == :all
assert cfv_update.granted == true
assert cfv_destroy.scope == :all
assert cfv_destroy.granted == true
end
test "allows CustomField full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
cf_read =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :read
end)
cf_create =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :create
end)
cf_update =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :update
end)
cf_destroy =
Enum.find(permissions.resources, fn p ->
p.resource == "CustomField" && p.action == :destroy
end)
assert cf_read.scope == :all
assert cf_read.granted == true
assert cf_create.scope == :all
assert cf_create.granted == true
assert cf_update.scope == :all
assert cf_update.granted == true
assert cf_destroy.scope == :all
assert cf_destroy.granted == true
end
test "allows Role full CRUD with scope :all" do
permissions = PermissionSets.get_permissions(:admin)
role_read =
Enum.find(permissions.resources, fn p -> p.resource == "Role" && p.action == :read end)
role_create =
Enum.find(permissions.resources, fn p -> p.resource == "Role" && p.action == :create end)
role_update =
Enum.find(permissions.resources, fn p -> p.resource == "Role" && p.action == :update end)
role_destroy =
Enum.find(permissions.resources, fn p -> p.resource == "Role" && p.action == :destroy end)
assert role_read.scope == :all
assert role_read.granted == true
assert role_create.scope == :all
assert role_create.granted == true
assert role_update.scope == :all
assert role_update.granted == true
assert role_destroy.scope == :all
assert role_destroy.granted == true
end
test "has wildcard page permission" do
permissions = PermissionSets.get_permissions(:admin)
assert "*" in permissions.pages
end
end
describe "valid_permission_set?/1" do
test "returns true for valid permission set string" do
assert PermissionSets.valid_permission_set?("own_data") == true
assert PermissionSets.valid_permission_set?("read_only") == true
assert PermissionSets.valid_permission_set?("normal_user") == true
assert PermissionSets.valid_permission_set?("admin") == true
end
test "returns true for valid permission set atom" do
assert PermissionSets.valid_permission_set?(:own_data) == true
assert PermissionSets.valid_permission_set?(:read_only) == true
assert PermissionSets.valid_permission_set?(:normal_user) == true
assert PermissionSets.valid_permission_set?(:admin) == true
end
test "returns false for invalid permission set string" do
assert PermissionSets.valid_permission_set?("invalid") == false
assert PermissionSets.valid_permission_set?("") == false
assert PermissionSets.valid_permission_set?("admin_user") == false
end
test "returns false for invalid permission set atom" do
assert PermissionSets.valid_permission_set?(:invalid) == false
assert PermissionSets.valid_permission_set?(:unknown) == false
end
test "returns false for nil input" do
assert PermissionSets.valid_permission_set?(nil) == false
end
test "returns false for invalid types" do
assert PermissionSets.valid_permission_set?(123) == false
assert PermissionSets.valid_permission_set?([]) == false
assert PermissionSets.valid_permission_set?(%{}) == false
assert PermissionSets.valid_permission_set?("") == false
end
end
describe "permission_set_name_to_atom/1" do
test "returns {:ok, atom} for valid permission set name" do
assert PermissionSets.permission_set_name_to_atom("own_data") == {:ok, :own_data}
assert PermissionSets.permission_set_name_to_atom("read_only") == {:ok, :read_only}
assert PermissionSets.permission_set_name_to_atom("normal_user") == {:ok, :normal_user}
assert PermissionSets.permission_set_name_to_atom("admin") == {:ok, :admin}
end
test "returns {:error, :invalid_permission_set} for invalid permission set name" do
assert PermissionSets.permission_set_name_to_atom("invalid") ==
{:error, :invalid_permission_set}
assert PermissionSets.permission_set_name_to_atom("") == {:error, :invalid_permission_set}
assert PermissionSets.permission_set_name_to_atom("admin_user") ==
{:error, :invalid_permission_set}
end
test "handles non-existent atom gracefully" do
# String.to_existing_atom will raise ArgumentError for non-existent atoms
assert PermissionSets.permission_set_name_to_atom("nonexistent_atom_12345") ==
{:error, :invalid_permission_set}
end
end
describe "get_permissions/1 - error handling" do
test "raises ArgumentError for invalid permission set with helpful message" do
assert_raise ArgumentError,
~r/invalid permission set: :invalid\. Must be one of:/,
fn ->
PermissionSets.get_permissions(:invalid)
end
end
test "error message includes all valid permission sets" do
error =
assert_raise ArgumentError, fn ->
PermissionSets.get_permissions(:unknown)
end
error_message = Exception.message(error)
assert error_message =~ "own_data"
assert error_message =~ "read_only"
assert error_message =~ "normal_user"
assert error_message =~ "admin"
end
end
end

View file

@ -0,0 +1,97 @@
defmodule Mv.Authorization.RoleTest do
@moduledoc """
Unit tests for Role resource validations and constraints.
"""
use Mv.DataCase, async: true
alias Mv.Authorization
describe "permission_set_name validation" do
test "accepts valid permission set names" do
attrs = %{
name: "Test Role",
permission_set_name: "own_data"
}
assert {:ok, role} = Authorization.create_role(attrs)
assert role.permission_set_name == "own_data"
end
test "rejects invalid permission set names" do
attrs = %{
name: "Test Role",
permission_set_name: "invalid_set"
}
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert error_message(errors, :permission_set_name) =~ "must be one of"
end
test "accepts all four valid permission sets" do
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
for permission_set <- valid_sets do
attrs = %{
name: "Role #{permission_set}",
permission_set_name: permission_set
}
assert {:ok, _role} = Authorization.create_role(attrs)
end
end
end
describe "system role deletion protection" do
test "prevents deletion of system roles" do
# is_system_role is not settable via public API, so we use Ash.Changeset directly
changeset =
Mv.Authorization.Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
{:ok, system_role} = Ash.create(changeset)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.destroy_role(system_role)
message = error_message(errors, :is_system_role)
assert message =~ "Cannot delete system role"
end
test "allows deletion of non-system roles" do
# is_system_role defaults to false, so regular create works
{:ok, regular_role} =
Authorization.create_role(%{
name: "Regular Role",
permission_set_name: "read_only"
})
assert :ok = Authorization.destroy_role(regular_role)
end
end
describe "name uniqueness" do
test "enforces unique role names" do
attrs = %{
name: "Unique Role",
permission_set_name: "own_data"
}
assert {:ok, _} = Authorization.create_role(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert error_message(errors, :name) =~ "has already been taken"
end
end
# Helper function for error evaluation
defp error_message(errors, field) when is_atom(field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map(&Map.get(&1, :message, ""))
|> List.first() || ""
end
end

View file

@ -0,0 +1,219 @@
defmodule MvWeb.AuthorizationTest do
@moduledoc """
Tests for UI-level authorization helpers.
"""
use ExUnit.Case, async: true
alias MvWeb.Authorization
alias Mv.Membership.Member
alias Mv.Accounts.User
describe "can?/3 with resource atom" do
test "returns true when user has permission for resource+action" do
admin = %{
id: "admin-123",
role: %{permission_set_name: "admin"}
}
assert Authorization.can?(admin, :create, Mv.Membership.Member) == true
assert Authorization.can?(admin, :read, Mv.Membership.Member) == true
assert Authorization.can?(admin, :update, Mv.Membership.Member) == true
assert Authorization.can?(admin, :destroy, Mv.Membership.Member) == true
end
test "returns false when user lacks permission" do
read_only_user = %{
id: "read-only-123",
role: %{permission_set_name: "read_only"}
}
assert Authorization.can?(read_only_user, :create, Mv.Membership.Member) == false
assert Authorization.can?(read_only_user, :read, Mv.Membership.Member) == true
assert Authorization.can?(read_only_user, :update, Mv.Membership.Member) == false
assert Authorization.can?(read_only_user, :destroy, Mv.Membership.Member) == false
end
test "returns false for nil user" do
assert Authorization.can?(nil, :create, Mv.Membership.Member) == false
assert Authorization.can?(nil, :read, Mv.Membership.Member) == false
end
test "admin can manage roles" do
admin = %{
id: "admin-123",
role: %{permission_set_name: "admin"}
}
assert Authorization.can?(admin, :create, Mv.Authorization.Role) == true
assert Authorization.can?(admin, :read, Mv.Authorization.Role) == true
assert Authorization.can?(admin, :update, Mv.Authorization.Role) == true
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
end
test "non-admin cannot manage roles" do
normal_user = %{
id: "normal-123",
role: %{permission_set_name: "normal_user"}
}
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
end
end
describe "can?/3 with record struct - scope :all" do
test "admin can update any member" do
admin = %{
id: "admin-123",
role: %{permission_set_name: "admin"}
}
member1 = %Member{id: "member-1", user: %User{id: "other-user"}}
member2 = %Member{id: "member-2", user: %User{id: "another-user"}}
assert Authorization.can?(admin, :update, member1) == true
assert Authorization.can?(admin, :update, member2) == true
end
test "normal_user can update any member" do
normal_user = %{
id: "normal-123",
role: %{permission_set_name: "normal_user"}
}
member = %Member{id: "member-1", user: %User{id: "other-user"}}
assert Authorization.can?(normal_user, :update, member) == true
end
end
describe "can?/3 with record struct - scope :own" do
test "user can update own User record" do
user = %{
id: "user-123",
role: %{permission_set_name: "own_data"}
}
own_user_record = %User{id: "user-123"}
other_user_record = %User{id: "other-user"}
assert Authorization.can?(user, :update, own_user_record) == true
assert Authorization.can?(user, :update, other_user_record) == false
end
end
describe "can?/3 with record struct - scope :linked" do
test "user can update linked member" do
user = %{
id: "user-123",
role: %{permission_set_name: "own_data"}
}
# Member has_one :user (inverse relationship)
linked_member = %Member{id: "member-1", user: %User{id: "user-123"}}
unlinked_member = %Member{id: "member-2", user: nil}
unlinked_member_other = %Member{id: "member-3", user: %User{id: "other-user"}}
assert Authorization.can?(user, :update, linked_member) == true
assert Authorization.can?(user, :update, unlinked_member) == false
assert Authorization.can?(user, :update, unlinked_member_other) == false
end
test "user can update CustomFieldValue of linked member" do
user = %{
id: "user-123",
role: %{permission_set_name: "own_data"}
}
linked_cfv = %Mv.Membership.CustomFieldValue{
id: "cfv-1",
member: %Member{id: "member-1", user: %User{id: "user-123"}}
}
unlinked_cfv = %Mv.Membership.CustomFieldValue{
id: "cfv-2",
member: %Member{id: "member-2", user: nil}
}
unlinked_cfv_other = %Mv.Membership.CustomFieldValue{
id: "cfv-3",
member: %Member{id: "member-3", user: %User{id: "other-user"}}
}
assert Authorization.can?(user, :update, linked_cfv) == true
assert Authorization.can?(user, :update, unlinked_cfv) == false
assert Authorization.can?(user, :update, unlinked_cfv_other) == false
end
end
describe "can_access_page?/2" do
test "admin can access all pages via wildcard" do
admin = %{
id: "admin-123",
role: %{permission_set_name: "admin"}
}
assert Authorization.can_access_page?(admin, "/admin/roles") == true
assert Authorization.can_access_page?(admin, "/members") == true
assert Authorization.can_access_page?(admin, "/any/page") == true
end
test "read_only user can access allowed pages" do
read_only_user = %{
id: "read-only-123",
role: %{permission_set_name: "read_only"}
}
assert Authorization.can_access_page?(read_only_user, "/") == true
assert Authorization.can_access_page?(read_only_user, "/members") == true
assert Authorization.can_access_page?(read_only_user, "/members/123") == true
assert Authorization.can_access_page?(read_only_user, "/admin/roles") == false
end
test "matches dynamic routes correctly" do
read_only_user = %{
id: "read-only-123",
role: %{permission_set_name: "read_only"}
}
assert Authorization.can_access_page?(read_only_user, "/members/123") == true
assert Authorization.can_access_page?(read_only_user, "/members/abc") == true
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
end
test "returns false for nil user" do
assert Authorization.can_access_page?(nil, "/members") == false
assert Authorization.can_access_page?(nil, "/admin/roles") == false
end
end
describe "error handling" do
test "user without role returns false" do
user_without_role = %{id: "user-123", role: nil}
assert Authorization.can?(user_without_role, :create, Mv.Membership.Member) == false
assert Authorization.can_access_page?(user_without_role, "/members") == false
end
test "user with invalid permission_set_name returns false" do
user_with_invalid_permission = %{
id: "user-123",
role: %{permission_set_name: "invalid_set"}
}
assert Authorization.can?(user_with_invalid_permission, :create, Mv.Membership.Member) ==
false
assert Authorization.can_access_page?(user_with_invalid_permission, "/members") == false
end
test "handles missing fields gracefully" do
user_missing_role = %{id: "user-123"}
assert Authorization.can?(user_missing_role, :create, Mv.Membership.Member) == false
assert Authorization.can_access_page?(user_missing_role, "/members") == false
end
end
end

View file

@ -24,7 +24,6 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
@ -101,7 +100,6 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='street'] .opacity-40")
assert has_element?(view, "[data-testid='house_number'] .opacity-40")
assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
assert has_element?(view, "[data-testid='join_date'] .opacity-40")
end

View file

@ -0,0 +1,141 @@
defmodule MvWeb.Helpers.MemberHelpersTest do
@moduledoc """
Tests for the display_name/1 helper function in MemberHelpers.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias MvWeb.Helpers.MemberHelpers
describe "display_name/1" do
test "returns full name when both first_name and last_name are present" do
member = %Member{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "returns email when both first_name and last_name are nil" do
member = %Member{
first_name: nil,
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns first_name only when last_name is nil" do
member = %Member{
first_name: "John",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "returns last_name only when first_name is nil" do
member = %Member{
first_name: nil,
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
test "returns email when first_name and last_name are empty strings" do
member = %Member{
first_name: "",
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns email when first_name and last_name are whitespace only" do
member = %Member{
first_name: " ",
last_name: " \t ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "trims whitespace from name parts" do
member = %Member{
first_name: " John ",
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "handles one empty string and one nil" do
member = %Member{
first_name: "",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one nil and one empty string" do
member = %Member{
first_name: nil,
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one whitespace and one nil" do
member = %Member{
first_name: " ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one valid name and one whitespace" do
member = %Member{
first_name: "John",
last_name: " ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only first_name containing whitespace" do
member = %Member{
first_name: " John ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only last_name containing whitespace" do
member = %Member{
first_name: nil,
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
end
end

View file

@ -154,7 +154,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> render_click()
# Should show success message
assert render(view) =~ "Custom field deleted successfully"
assert render(view) =~ "Data field deleted successfully"
# Custom field should be gone from database
assert {:error, _} = Ash.get(CustomField, custom_field.id)

View file

@ -64,5 +64,21 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "must be present"
end
test "displays Memberdata section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
assert html =~ "Memberdata" or html =~ "Member Data"
end
test "displays flash message after member field visibility update", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate member field visibility update
send(view.pid, {:member_field_visibility_updated})
# Check for flash message
assert render(view) =~ "updated" or render(view) =~ "success"
end
end
end

View file

@ -0,0 +1,124 @@
defmodule MvWeb.MemberFieldLive.IndexComponentTest do
@moduledoc """
Tests for MemberFieldLive.IndexComponent.
Tests cover:
- Rendering all member fields from Mv.Constants.member_fields()
- Displaying show_in_overview status as badge (Yes/No)
- Displaying required status for required fields (first_name, last_name, email)
- Current status is displayed based on settings.member_field_visibility
- Default status is "Yes" (visible) when not configured in settings
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
{:ok, conn: conn, user: user}
end
describe "rendering" do
test "renders all member fields from Constants", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check that all member fields are displayed
member_fields = Mv.Constants.member_fields()
for field <- member_fields do
field_name = String.replace(Atom.to_string(field), "_", " ") |> String.capitalize()
# Field name should appear in the table (either as label or in some form)
assert html =~ field_name or html =~ Atom.to_string(field)
end
end
test "displays show_in_overview status as badge", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Should have "Show in overview" column header
assert html =~ "Show in overview" or html =~ "Show in Overview"
# Should have badge elements (Yes/No)
assert html =~ "badge" or html =~ "Yes" or html =~ "No"
end
test "displays required status for required fields", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Required fields: first_name, last_name, email
# Should have "Required" column or indicator
assert html =~ "Required" or html =~ "required"
end
test "shows default status as Yes when not configured", %{conn: conn} do
# Ensure settings have no member_field_visibility configured
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_settings(settings, %{member_field_visibility: %{}})
{:ok, _view, html} = live(conn, ~p"/settings")
# All fields should show as visible (Yes) by default
# Check for "Yes" badge or similar indicator
assert html =~ "Yes" or html =~ "badge-success"
end
test "shows configured visibility status from settings", %{conn: conn} do
# Configure some fields as hidden
{:ok, settings} = Membership.get_settings()
visibility_config = %{"street" => false, "house_number" => false}
{:ok, _updated} =
Membership.update_member_field_visibility(settings, visibility_config)
{:ok, _view, html} = live(conn, ~p"/settings")
# Street and house_number should show as hidden (No)
# Other fields should show as visible (Yes)
assert html =~ "street" or html =~ "Street"
assert html =~ "house_number" or html =~ "House number"
end
end
describe "required fields" do
test "marks first_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# first_name should be marked as required
assert html =~ "first_name" or html =~ "First name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks last_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# last_name should be marked as required
assert html =~ "last_name" or html =~ "Last name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks email as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# email should be marked as required
assert html =~ "email" or html =~ "Email"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "does not mark optional fields as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Optional fields should not have required indicator
# Check that street (optional) doesn't have required badge
# This test verifies that only required fields show the indicator
assert html =~ "street" or html =~ "Street"
end
end
end

View file

@ -0,0 +1,452 @@
defmodule MvWeb.RoleLiveTest do
@moduledoc """
Tests for role management LiveViews.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Authorization
alias Mv.Authorization.Role
# Helper to create a role
defp create_role(attrs \\ %{}) do
default_attrs = %{
name: "Test Role #{System.unique_integer([:positive])}",
description: "Test description",
permission_set_name: "read_only"
}
attrs = Map.merge(default_attrs, attrs)
case Authorization.create_role(attrs) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
# Helper to create admin user with admin role
defp create_admin_user(conn) do
# Create admin role
admin_role =
case Authorization.list_roles() do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin")) do
nil ->
# Create admin role if it doesn't exist
create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
role ->
role
end
_ ->
# Create admin role if list_roles fails
create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
end
# Create user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
# Assign admin role using manage_relationship
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
# Load role for authorization checks (must be loaded for can?/3 to work)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
# Store user with role in session for LiveView
conn = conn_with_password_user(conn, user_with_role)
{conn, user_with_role, admin_role}
end
# Helper to create non-admin user
defp create_non_admin_user(conn) do
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
{conn, user}
end
describe "index page" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
end
test "mounts successfully", %{conn: conn} do
{:ok, _view, _html} = live(conn, "/admin/roles")
end
test "loads all roles from database", %{conn: conn} do
role1 = create_role(%{name: "Role 1"})
role2 = create_role(%{name: "Role 2"})
{:ok, _view, html} = live(conn, "/admin/roles")
assert html =~ role1.name
assert html =~ role2.name
end
test "shows table with role names", %{conn: conn} do
role = create_role(%{name: "Test Role"})
{:ok, _view, html} = live(conn, "/admin/roles")
assert html =~ role.name
assert html =~ role.description
assert html =~ role.permission_set_name
end
test "shows system role badge", %{conn: conn} do
_system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
{:ok, _view, html} = live(conn, "/admin/roles")
assert html =~ "System Role" || html =~ "system"
end
test "delete button disabled for system roles", %{conn: conn} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
{:ok, view, _html} = live(conn, "/admin/roles")
assert has_element?(
view,
"button[phx-click='delete'][phx-value-id='#{system_role.id}'][disabled]"
) ||
not has_element?(
view,
"button[phx-click='delete'][phx-value-id='#{system_role.id}']"
)
end
test "delete button enabled for non-system roles", %{conn: conn} do
role = create_role()
{:ok, view, html} = live(conn, "/admin/roles")
# Delete is a link with phx-click containing delete event
# Check if delete link exists in HTML (phx-click contains delete and role id)
assert (html =~ "phx-click" && html =~ "delete" && html =~ role.id) ||
has_element?(view, "a[phx-click*='delete'][phx-value-id='#{role.id}']") ||
has_element?(view, "a[aria-label='Delete role']")
end
test "new role button navigates to form", %{conn: conn} do
{:ok, view, html} = live(conn, "/admin/roles")
# Check if button exists (admin should see it)
if html =~ "New Role" do
{:error, {:live_redirect, %{to: to}}} =
view
|> element("a[href='/admin/roles/new'], button[href='/admin/roles/new']")
|> render_click()
assert to == "/admin/roles/new"
else
# If button not visible, user doesn't have permission (expected for non-admin)
# This test assumes admin user, so button should be visible
flunk("New Role button not found - user may not have admin role loaded")
end
end
end
describe "show page" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
end
test "mounts with valid role ID", %{conn: conn} do
role = create_role()
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
assert html =~ role.name
assert html =~ role.description
assert html =~ role.permission_set_name
end
test "returns 404 for invalid role ID", %{conn: conn} do
invalid_id = Ecto.UUID.generate()
# Should redirect to index with error message
# redirect in mount returns {:error, {:redirect, ...}}
result = live(conn, "/admin/roles/#{invalid_id}")
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
end
test "shows system role badge if is_system_role is true", %{conn: conn} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
assert html =~ "System Role" || html =~ "system"
end
end
describe "form - create" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
end
test "mounts successfully", %{conn: conn} do
{:ok, _view, _html} = live(conn, "/admin/roles/new")
end
test "form dropdown shows all 4 permission sets", %{conn: conn} do
{:ok, _view, html} = live(conn, "/admin/roles/new")
assert html =~ "own_data"
assert html =~ "read_only"
assert html =~ "normal_user"
assert html =~ "admin"
end
test "creates new role with valid data", %{conn: conn} do
{:ok, view, _html} = live(conn, "/admin/roles/new")
attrs = %{
"name" => "New Role",
"description" => "New description",
"permission_set_name" => "read_only"
}
view
|> form("#role-form", role: attrs)
|> render_submit()
# Should redirect to index or show page
assert_redirect(view, "/admin/roles")
end
test "shows error with invalid permission_set_name", %{conn: conn} do
{:ok, view, _html} = live(conn, "/admin/roles/new")
# Try to submit with empty permission_set_name (invalid)
attrs = %{
"name" => "New Role",
"description" => "New description",
"permission_set_name" => ""
}
view
|> form("#role-form", role: attrs)
|> render_submit()
# Should show validation error
html = render(view)
assert html =~ "error" || html =~ "required" || html =~ "Permission Set"
end
test "shows flash message after successful creation", %{conn: conn} do
{:ok, view, _html} = live(conn, "/admin/roles/new")
attrs = %{
"name" => "New Role #{System.unique_integer([:positive])}",
"description" => "New description",
"permission_set_name" => "read_only"
}
view
|> form("#role-form", role: attrs)
|> render_submit()
# Should redirect to index
assert_redirect(view, "/admin/roles")
end
end
describe "form - edit" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
role = create_role()
%{conn: conn, user: user, role: role}
end
test "mounts with valid role ID", %{conn: conn, role: role} do
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}/edit")
assert html =~ role.name
end
test "returns 404 for invalid role ID in edit", %{conn: conn} do
invalid_id = Ecto.UUID.generate()
# Should redirect to index with error message
# redirect in mount returns {:error, {:redirect, ...}}
result = live(conn, "/admin/roles/#{invalid_id}/edit")
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
end
test "updates role name", %{conn: conn, role: role} do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
attrs = %{
"name" => "Updated Role Name",
"description" => role.description,
"permission_set_name" => role.permission_set_name
}
view
|> form("#role-form", role: attrs)
|> render_submit()
assert_redirect(view, "/admin/roles/#{role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(role.id)
assert updated_role.name == "Updated Role Name"
end
test "updates system role's permission_set_name", %{conn: conn} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
{:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}/edit?return_to=show")
attrs = %{
"name" => system_role.name,
"description" => system_role.description,
"permission_set_name" => "read_only"
}
view
|> form("#role-form", role: attrs)
|> render_submit()
assert_redirect(view, "/admin/roles/#{system_role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(system_role.id)
assert updated_role.permission_set_name == "read_only"
end
end
describe "delete functionality" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
end
test "deletes non-system role", %{conn: conn} do
role = create_role()
{:ok, view, html} = live(conn, "/admin/roles")
# Delete is a link - JS.push creates phx-click with value containing id
# Verify the role id is in the HTML (in phx-click value)
assert html =~ role.id
# Send delete event directly to avoid selector issues with multiple delete buttons
render_click(view, "delete", %{"id" => role.id})
# Verify deletion by checking database
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Authorization.get_role(role.id)
end
test "fails to delete system role with error message", %{conn: conn} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
name: "System Role",
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
{:ok, view, html} = live(conn, "/admin/roles")
# System role delete button should be disabled
assert html =~ "disabled" || html =~ "cursor-not-allowed" ||
html =~ "System roles cannot be deleted"
# Try to delete via event (backend check)
render_click(view, "delete", %{"id" => system_role.id})
# Should show error message
assert render(view) =~ "System roles cannot be deleted"
# Role should still exist
{:ok, _role} = Authorization.get_role(system_role.id)
end
end
describe "authorization" do
test "only admin can access /admin/roles", %{conn: conn} do
{conn, _user} = create_non_admin_user(conn)
# Non-admin should be redirected or see error
# Note: Authorization is checked via can_access_page? which returns false
# The page might still mount but show no content or redirect
# For now, we just verify the page doesn't work as expected for non-admin
{:ok, _view, html} = live(conn, "/admin/roles")
# Non-admin should not see "New Role" button (can? returns false)
# But the button might still be in HTML, just hidden or disabled
# We verify that the page loads but admin features are restricted
assert html =~ "Listing Roles" || html =~ "Roles"
end
test "admin can access /admin/roles", %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
{:ok, _view, _html} = live(conn, "/admin/roles")
end
end
end

View file

@ -0,0 +1,141 @@
defmodule MvWeb.Helpers.MemberHelpersTest do
@moduledoc """
Tests for the display_name/1 helper function in MemberHelpers.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias MvWeb.Helpers.MemberHelpers
describe "display_name/1" do
test "returns full name when both first_name and last_name are present" do
member = %Member{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "returns email when both first_name and last_name are nil" do
member = %Member{
first_name: nil,
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns first_name only when last_name is nil" do
member = %Member{
first_name: "John",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "returns last_name only when first_name is nil" do
member = %Member{
first_name: nil,
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
test "returns email when first_name and last_name are empty strings" do
member = %Member{
first_name: "",
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns email when first_name and last_name are whitespace only" do
member = %Member{
first_name: " ",
last_name: " \t ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "trims whitespace from name parts" do
member = %Member{
first_name: " John ",
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "handles one empty string and one nil" do
member = %Member{
first_name: "",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one nil and one empty string" do
member = %Member{
first_name: nil,
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one whitespace and one nil" do
member = %Member{
first_name: " ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one valid name and one whitespace" do
member = %Member{
first_name: "John",
last_name: " ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only first_name containing whitespace" do
member = %Member{
first_name: " John ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only last_name containing whitespace" do
member = %Member{
first_name: nil,
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
end
end

View file

@ -16,7 +16,6 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
house_number: "123",
postal_code: "12345",
city: "Berlin",
phone_number: "+49123456789",
join_date: ~D[2020-01-15]
})
|> Ash.create()

View file

@ -121,7 +121,6 @@ defmodule MvWeb.MemberLive.IndexTest do
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]