Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
02e5ce4376 chore(deps): update dependency gettext to v1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
2025-10-18 00:15:18 +00:00
156 changed files with 1397 additions and 27787 deletions

View file

@ -158,11 +158,11 @@
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []},
# Module documentation check (enabled after adding @moduledoc to all modules)
{Credo.Check.Readability.ModuleDoc, []}
{Credo.Check.Warning.WrongTestFileExtension, []}
],
disabled: [
# Checks disabled by the Mitgliederverwaltung Team
{Credo.Check.Readability.ModuleDoc, []},
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},

View file

@ -4,7 +4,7 @@ name: check
services:
- name: postgres
image: docker.io/library/postgres:17.6
image: docker.io/library/postgres:17.5
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -53,11 +53,9 @@ steps:
- mix hex.audit
# Provide hints for improving code quality
- mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:17.6
image: docker.io/library/postgres:17.5
commands:
# Wait for postgres to become available
- |
@ -102,53 +100,6 @@ volumes:
host:
path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: build-and-publish
trigger:
branch:
- main
event:
- push
- tag
steps:
- name: build-and-publish-container
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
auto_tag: true
auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8}
when:
event:
- tag
- name: build-and-publish-container-branch
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
when:
event:
- push
depends_on:
- check
---
kind: pipeline
type: docker

View file

@ -1,19 +1 @@
# Production Environment Variables for docker-compose.prod.yml
# Copy this file to .env and fill in the actual values
# Required: Phoenix secrets (generate with: mix phx.gen.secret)
SECRET_KEY_BASE=changeme-run-mix-phx.gen.secret
TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret
# Required: Hostname for URL generation
PHX_HOST=localhost
# Recommended: Association settings
ASSOCIATION_NAME="Sportsclub XYZ"
# Optional: OIDC Configuration
# These have defaults in docker-compose.prod.yml, only override if needed
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
OIDC_CLIENT_SECRET=

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.43.0
just 1.42.4

View file

@ -1,25 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- User-Member linking with fuzzy search autocomplete (#168)
- PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
- Bilingual UI (German/English) for member linking workflow
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
- CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter)
- German/English translations
### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} AS builder
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
@ -70,9 +70,9 @@ RUN apt-get update -y && \
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app

View file

@ -29,7 +29,6 @@ lint:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo
mix gettext.extract --check-up-to-date
audit:
mix sobelow --config
@ -85,9 +84,3 @@ clean:
mix clean
rm -rf .elixir_ls
rm -rf _build
# Remove Git merge conflict markers from gettext files
remove-gettext-conflicts:
#!/usr/bin/env bash
set -euo pipefail
find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \;

104
README.md
View file

@ -161,98 +161,34 @@ Now you can log in to Mila via OIDC!
## 🏗️ Architecture
**Tech Stack Overview:**
- **Backend:** Elixir + Phoenix + Ash Framework
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
- **Database:** PostgreSQL
- **Auth:** AshAuthentication (OIDC + password)
- **Backend:** Elixir, Phoenix, LiveView, Ash Framework
- **Frontend:** Phoenix LiveView + DaisyUI + Heroicons
- **Database:** PostgreSQL (via AshPostgres)
- **Auth:** AshAuthentication (OIDC + password strategy)
- **Mail:** Swoosh
- **i18n:** Gettext
**Code Structure:**
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
Code structure:
- `lib/mv/` — core Ash resources/domains (`Accounts`, `Membership`)
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
- `assets/` — Tailwind, JavaScript, static files
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
🗄️ **Database schema:** See [`docs/database-schema-readme.md`](docs/database-schema-readme.md)
- `assets/` — frontend assets (Tailwind, JS, etc.)
## 🧑‍💻 Development
**Common commands:**
```bash
just run # Start full dev environment
just test # Run test suite
just lint # Code style checks
just audit # Security audits
just reset-database # Reset local DB
```
Useful `just` commands:
- `just run` — start DB, Mailcrab, Rauthy, app
- `just test` — run tests
- `just lint` — run code style checks (credo, formatter)
- `just audit` — run security audits
- `just reset-database` — reset local DB
- `just regen-migrations <name>` — regenerate migrations
📚 **Full development guidelines:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
## 📦 Deployment
## 📦 Production Deployment
### Local Production Testing
For testing the production Docker build locally:
1. **Generate secrets:**
```bash
mix phx.gen.secret # for SECRET_KEY_BASE
mix phx.gen.secret # for TOKEN_SIGNING_SECRET
```
2. **Create `.env` file:**
```bash
# Copy template and edit
cp .env.example .env
nano .env
# Required variables:
SECRET_KEY_BASE=<your-generated-secret>
TOKEN_SIGNING_SECRET=<your-generated-secret>
PHX_HOST=localhost
# Optional (have defaults in docker-compose.prod.yml):
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client>
```
3. **Start development environment** (for Rauthy):
```bash
docker compose up -d
```
4. **Start production environment:**
```bash
docker compose -f docker-compose.prod.yml up
```
5. **Run database migrations:**
```bash
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
```
6. **Access the production app:**
- Production App: http://localhost:4001
- Uses same Rauthy instance as dev (localhost:8080)
**Note:** The local production setup uses `network_mode: host` to share localhost with the development Rauthy instance. For real production deployment, configure an external OIDC provider and remove `network_mode: host`.
### Real Production Deployment
For actual production deployment:
1. **Use an external OIDC provider** (not the local Rauthy)
2. **Update `docker-compose.prod.yml`:**
- Remove `network_mode: host`
- Set `OIDC_BASE_URL` to your production OIDC provider
- Configure proper Docker networks
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
4. **Use secure secrets management** (environment variables, Docker secrets, vault)
5. **Configure database backups**
A production release image is built via the provided `Dockerfile`.
Entrypoint: `/app/bin/server`.
More detailed deployment docs are planned.
## 🤝 Contributing

View file

@ -23,69 +23,9 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
// Hooks for LiveView components
let Hooks = {}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = {
mounted() {
this.handleEvent("copy_to_clipboard", ({text}) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(err => {
console.error("Clipboard write failed:", err)
})
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand("copy")
} catch (err) {
console.error("Fallback clipboard copy failed:", err)
}
document.body.removeChild(textArea)
}
})
}
}
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
Hooks.ComboBox = {
mounted() {
this.handleKeyDown = (e) => {
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && isDropdownOpen) {
e.preventDefault()
}
}
this.el.addEventListener("keydown", this.handleKeyDown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
}
}
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: Hooks
})
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail
const input = document.getElementById(id)
if (input) {
input.value = value
}
params: {_csrf_token: csrfToken}
})
// Show progress bar on live navigation and form submits

View file

@ -16,16 +16,5 @@ config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# AshAuthentication production configuration
# These must be set at compile-time (not in runtime.exs) because
# Application.compile_env!/3 is used in lib/accounts/user.ex
config :mv, :session_identifier, :jti
config :mv, :require_token_presence_for_authentication, true
# Token signing secret - using a placeholder that MUST be overridden
# at runtime via environment variable in config/runtime.exs
config :mv, :token_signing_secret, "REPLACE_ME_AT_RUNTIME"
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View file

@ -53,24 +53,12 @@ if config_env() == :prod do
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Rauthy OIDC configuration
config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri:
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
# Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs
token_signing_secret =
System.get_env("TOKEN_SIGNING_SECRET") ||
raise """
environment variable TOKEN_SIGNING_SECRET is missing.
You can generate one by calling: mix phx.gen.secret
"""
# AshAuthentication production configuration
config :mv, :session_identifier, :jti
config :mv, :token_signing_secret, token_signing_secret
config :mv, :require_token_presence_for_authentication, true
config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
@ -82,13 +70,7 @@ if config_env() == :prod do
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base,
# Allow connections from localhost and 127.0.0.1
check_origin: [
"//#{host}",
"//localhost:#{port}",
"//127.0.0.1:#{port}"
]
secret_key_base: secret_key_base
# ## SSL Support
#

View file

@ -1,38 +0,0 @@
services:
app:
image: git.local-it.org/local-it/mitgliederverwaltung:latest
container_name: mv-prod-app
# Use host network for local testing to access localhost:8080 (Rauthy)
# In real production, remove this and use external OIDC provider
network_mode: host
environment:
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
PHX_HOST: "${PHX_HOST}"
PORT: "4001"
PHX_SERVER: "true"
# Rauthy OIDC config - uses localhost because of host network mode
OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
depends_on:
- db-prod
restart: unless-stopped
db-prod:
image: postgres:16-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mv_prod
volumes:
- postgres_data_prod:/var/lib/postgresql/data
ports:
- "5001:5432"
restart: unless-stopped
volumes:
postgres_data_prod:

View file

@ -1,10 +1,11 @@
networks:
local:
rauthy-dev:
driver: bridge
services:
db:
image: postgres:17.6-alpine
image: postgres:17.5-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -38,8 +39,12 @@ services:
- LISTEN_SCHEME=http
- PUB_URL=localhost:8080
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
# Disable strict IP validation to allow access from multiple Docker networks
- SESSION_VALIDATE_IP=false
#- HIQLITE=false
#- PG_HOST=db
#- PG_PORT=5432
#- PG_USER=postgres
#- PG_PASSWORD=postgres
#- PG_DB_NAME=mv_dev
ports:
- "8080:8080"
depends_on:

View file

@ -1,463 +0,0 @@
# Database Schema Documentation
## Overview
This document provides a comprehensive overview of the Mila Membership Management System database schema.
## Quick Links
- **DBML File:** [`database_schema.dbml`](./database_schema.dbml)
- **Visualize Online:**
- [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file
- [dbdocs.io](https://dbdocs.io) - Generate interactive documentation
## Schema Statistics
| Metric | Count |
|--------|-------|
| **Tables** | 5 |
| **Domains** | 2 (Accounts, Membership) |
| **Relationships** | 3 |
| **Indexes** | 15+ |
| **Triggers** | 1 (Full-text search) |
## Tables Overview
### Accounts Domain
#### `users`
- **Purpose:** User authentication and session management
- **Rows (Estimated):** Low to Medium (typically 10-50% of members)
- **Key Features:**
- Dual authentication (Password + OIDC)
- Optional 1:1 link to members
- Email as source of truth when linked
#### `tokens`
- **Purpose:** JWT token storage for AshAuthentication
- **Rows (Estimated):** Medium to High (multiple tokens per user)
- **Key Features:**
- Token lifecycle management
- Revocation support
- Multiple token purposes
### Membership Domain
#### `members`
- **Purpose:** Club member master data
- **Rows (Estimated):** High (core entity)
- **Key Features:**
- Complete member profile
- Full-text search via tsvector
- Bidirectional email sync with users
- Flexible address and contact data
#### `custom_field_values`
- **Purpose:** Dynamic custom member attributes
- **Rows (Estimated):** Variable (N per member)
- **Key Features:**
- Union type value storage (JSONB)
- Multiple data types supported
- One custom field value per custom field per member
#### `custom_fields`
- **Purpose:** Schema definitions for custom_field_values
- **Rows (Estimated):** Low (admin-defined)
- **Key Features:**
- Type definitions
- Immutable and required flags
- Centralized custom field management
## Key Relationships
```
User (0..1) ←→ (0..1) Member
Tokens (N)
Member (1) → (N) Properties
CustomField (1)
```
### Relationship Details
1. **User ↔ Member (Optional 1:1, both sides optional)**
- A User can have 0 or 1 Member (`user.member_id` can be NULL)
- A Member can have 0 or 1 User (optional `has_one` relationship)
- Both entities can exist independently
- Email synchronization when linked (User.email is source of truth)
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
2. **Member → Properties (1:N)**
- One member, many custom_field_values
- `ON DELETE CASCADE` - custom_field_values deleted with member
- Composite unique constraint (member_id, custom_field_id)
3. **CustomFieldValue → CustomField (N:1)**
- Properties reference type definition
- `ON DELETE RESTRICT` - cannot delete type if in use
- Type defines data structure
## Important Business Rules
### Email Synchronization
- **User.email** is the source of truth when linked
- On linking: Member.email ← User.email (overwrite)
- After linking: Changes sync bidirectionally
- Validation prevents email conflicts
### Authentication Strategies
- **Password:** Email + hashed_password
- **OIDC:** Email + oidc_id (Rauthy provider)
- At least one method required per user
### Member Constraints
- First name and last name required (min 1 char)
- Email unique, validated format (5-254 chars)
- Join date cannot be in future
- Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}`
- Postal code: 5 digits
### CustomFieldValue System
- Maximum one custom field value per custom field per member
- Value stored as union type in JSONB
- Supported types: string, integer, boolean, date, email
- Types can be marked as immutable or required
## Indexes
### Performance Indexes
**members:**
- `search_vector` (GIN) - Full-text search (tsvector)
- `first_name` (GIN trgm) - Fuzzy search on first name
- `last_name` (GIN trgm) - Fuzzy search on last name
- `email` (GIN trgm) - Fuzzy search on email
- `city` (GIN trgm) - Fuzzy search on city
- `street` (GIN trgm) - Fuzzy search on street
- `notes` (GIN trgm) - Fuzzy search on notes
- `email` (B-tree) - Exact email lookups
- `last_name` (B-tree) - Name sorting
- `join_date` (B-tree) - Date filtering
- `paid` (partial B-tree) - Payment status queries
**custom_field_values:**
- `member_id` - Member custom field value lookups
- `custom_field_id` - Type-based queries
- Composite `(member_id, custom_field_id)` - Uniqueness
**tokens:**
- `subject` - User token lookups
- `expires_at` - Token cleanup
- `purpose` - Purpose-based queries
**users:**
- `email` (unique) - Login lookups
- `oidc_id` (unique) - OIDC authentication
- `member_id` (unique) - Member linkage
## Full-Text Search
### Implementation
- **Trigger:** `members_search_vector_trigger()`
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
- **Index Type:** GIN (Generalized Inverted Index)
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes
- **Weight C:** phone_number, city, street, house_number, postal_code
- **Weight D (lowest):** join_date, exit_date
### Usage Example
```sql
SELECT * FROM members
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
```
## Fuzzy Search (Trigram-based)
### Implementation
- **Extension:** `pg_trgm` (PostgreSQL Trigram)
- **Index Type:** GIN with `gin_trgm_ops` operator class
- **Similarity Threshold:** 0.2 (default, configurable)
- **Added:** November 2025 (PR #187, closes #162)
### How It Works
Fuzzy search combines multiple search strategies:
1. **Full-text search** - Primary filter using tsvector
2. **Trigram similarity** - `similarity(field, query) > threshold`
3. **Word similarity** - `word_similarity(query, field) > threshold`
4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings
5. **Modulo operator** - `query % field` for quick similarity check
### Indexed Fields for Fuzzy Search
- `first_name` - GIN trigram index
- `last_name` - GIN trigram index
- `email` - GIN trigram index
- `city` - GIN trigram index
- `street` - GIN trigram index
- `notes` - GIN trigram index
### Usage Example (Ash Action)
```elixir
# In LiveView or context
Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2)
# Or using Ash Query directly
Member
|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2})
|> Mv.Membership.read!()
```
### Usage Example (SQL)
```sql
-- Trigram similarity search
SELECT * FROM members
WHERE similarity(first_name, 'john') > 0.2
OR similarity(last_name, 'doe') > 0.2
ORDER BY similarity(first_name, 'john') DESC;
-- Word similarity (better for partial matches)
SELECT * FROM members
WHERE word_similarity('john', first_name) > 0.2;
-- Quick similarity check with % operator
SELECT * FROM members
WHERE 'john' % first_name;
```
### Performance Considerations
- **GIN indexes** speed up trigram operations significantly
- **Similarity threshold** of 0.2 balances precision and recall
- **Combined approach** (FTS + trigram) provides best results
- Lower threshold = more results but less specific
## Database Extensions
### Required PostgreSQL Extensions
1. **uuid-ossp**
- Purpose: UUID generation functions
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
2. **citext**
- Purpose: Case-insensitive text type
- Used for: `users.email` (case-insensitive email matching)
3. **pg_trgm**
- Purpose: Trigram-based fuzzy text search and similarity matching
- Used for: Fuzzy member search with similarity scoring
- Operators: `%` (similarity), `word_similarity()`, `similarity()`
- Added in: Migration `20251001141005_add_trigram_to_members.exs`
### Installation
```sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
```
## Migration Strategy
### Ash Migrations
This project uses Ash Framework's migration system:
```bash
# Generate new migration
mix ash.codegen --name add_new_feature
# Apply migrations
mix ash.setup
# Rollback migrations
mix ash_postgres.rollback -n 1
```
### Migration Files Location
```
priv/repo/migrations/
├── 20250421101957_initialize_extensions_1.exs
├── 20250528163901_initial_migration.exs
├── 20250617090641_member_fields.exs
├── 20250620110850_add_accounts_domain.exs
├── 20250912085235_AddSearchVectorToMembers.exs
├── 20250926180341_add_unique_email_to_members.exs
├── 20251001141005_add_trigram_to_members.exs
└── 20251016130855_add_constraints_for_user_member_and_property.exs
```
## Data Integrity
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use |
### Validation Layers
1. **Database Level:**
- CHECK constraints
- NOT NULL constraints
- UNIQUE indexes
- Foreign key constraints
2. **Application Level (Ash):**
- Custom validators
- Email format validation (EctoCommons.EmailValidator)
- Business rule validation
- Cross-entity validation
3. **UI Level:**
- Client-side form validation
- Real-time feedback
- Error messages
## Performance Considerations
### Query Patterns
**High Frequency:**
- Member search (uses GIN index on search_vector)
- Member list with filters (uses indexes on join_date, paid)
- User authentication (uses unique index on email/oidc_id)
- CustomFieldValue lookups by member (uses index on member_id)
**Medium Frequency:**
- Member CRUD operations
- CustomFieldValue updates
- Token validation
**Low Frequency:**
- CustomField management
- User-Member linking
- Bulk operations
### Optimization Tips
1. **Use indexes:** All critical query paths have indexes
2. **Preload relationships:** Use Ash's `load` to avoid N+1
3. **Pagination:** Use keyset pagination (configured by default)
4. **Partial indexes:** `members.paid` index only non-NULL values
5. **Search optimization:** Full-text search via tsvector, not LIKE
## Visualization
### Using dbdiagram.io
1. Visit [https://dbdiagram.io](https://dbdiagram.io)
2. Click "Import" → "From file"
3. Upload `database_schema.dbml`
4. View interactive diagram with relationships
### Using dbdocs.io
1. Install dbdocs CLI: `npm install -g dbdocs`
2. Generate docs: `dbdocs build database_schema.dbml`
3. View generated documentation
### VS Code Extension
Install "DBML Language" extension to view/edit DBML files with:
- Syntax highlighting
- Inline documentation
- Error checking
## Security Considerations
### Sensitive Data
**Encrypted:**
- `users.hashed_password` (bcrypt)
**Should Not Log:**
- hashed_password
- tokens (jti, purpose, extra_data)
**Personal Data (GDPR):**
- All member fields (name, email, address)
- User email
- Token subject
### Access Control
- Implement through Ash policies
- Row-level security considerations for future
- Audit logging for sensitive operations
## Backup Recommendations
### Critical Tables (Priority 1)
- `members` - Core business data
- `users` - Authentication data
- `custom_fields` - Schema definitions
### Important Tables (Priority 2)
- `custom_field_values` - Member custom data
- `tokens` - Can be regenerated but good to backup
### Backup Strategy
```bash
# Full database backup
pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump
# Restore
pg_restore -d mv_prod backup_20251110.dump
```
## Testing
### Test Database
- Separate test database: `mv_test`
- Sandbox mode via Ecto.Adapters.SQL.Sandbox
- Reset between tests
### Seed Data
```bash
# Load seed data
mix run priv/repo/seeds.exs
```
## Future Considerations
### Potential Additions
1. **Audit Log Table**
- Track changes to members
- Compliance and history tracking
2. **Payment Tracking**
- Payment history table
- Transaction records
- Fee calculation
3. **Document Storage**
- Member documents/attachments
- File metadata table
4. **Email Queue**
- Outbound email tracking
- Delivery status
5. **Roles & Permissions**
- User roles (admin, treasurer, member)
- Permission management
## Resources
- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash)
- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres)
- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io)
- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/)
---
**Last Updated:** 2025-11-13
**Schema Version:** 1.1
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)

View file

@ -1,359 +0,0 @@
// Mila - Membership Management System
// Database Schema Documentation
//
// This file can be used with:
// - https://dbdiagram.io
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
// Version: 1.2
// Last Updated: 2025-11-13
Project mila_membership_management {
database_type: 'PostgreSQL'
Note: '''
# Mila Membership Management System
A membership management application for small to mid-sized clubs.
## Key Features:
- User authentication (OIDC + Password with secure account linking)
- Member management with flexible custom fields
- Bidirectional email synchronization between users and members
- Full-text search capabilities (tsvector)
- Fuzzy search with trigram matching (pg_trgm)
- GDPR-compliant data management
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
- citext (case-insensitive text)
- pg_trgm (trigram-based fuzzy search)
'''
}
// ============================================
// ACCOUNTS DOMAIN
// ============================================
Table users {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
indexes {
email [unique, name: 'users_unique_email_index']
oidc_id [unique, name: 'users_unique_oidc_id_index']
member_id [unique, name: 'users_unique_member_index']
}
Note: '''
**User Authentication Table**
Handles user login accounts with two authentication strategies:
1. Password-based authentication (email + hashed_password)
2. OIDC/SSO authentication (email + oidc_id)
**Relationship with Members:**
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
- A user can have 0 or 1 member (user.member_id can be NULL)
- A member can have 0 or 1 user (optional has_one relationship)
- Both entities can exist independently
- When linked, user.email is the source of truth
- Email changes sync bidirectionally between user ↔ member
**Constraints:**
- At least one auth method required (password OR oidc_id)
- Email must be unique across all users
- OIDC ID must be unique if present
- Member can only be linked to one user (enforced by unique index)
**Deletion Behavior:**
- When member is deleted → user.member_id set to NULL (user preserved)
- When user is deleted → member.user relationship cleared (member preserved)
'''
}
Table tokens {
jti text [pk, not null, note: 'JWT ID - unique token identifier']
subject text [not null, note: 'Token subject (usually user ID)']
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
extra_data jsonb [null, note: 'Additional token metadata']
created_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
}
Note: '''
**AshAuthentication Token Management**
Stores JWT tokens for authentication and authorization.
**Token Purposes:**
- `access`: Short-lived access tokens for API requests
- `refresh`: Long-lived tokens for obtaining new access tokens
- `password_reset`: Temporary tokens for password reset flow
- `email_confirmation`: Temporary tokens for email verification
**Token Lifecycle:**
- Tokens are created during login/registration
- Can be revoked by deleting the record
- Expired tokens should be cleaned up periodically
- `store_all_tokens? true` enables token tracking
'''
}
// ============================================
// MEMBERSHIP DOMAIN
// ============================================
Table members {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
first_name text [not null, note: 'Member first name (min length: 1)']
last_name text [not null, note: 'Member last name (min length: 1)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
paid boolean [null, note: 'Payment status flag']
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
join_date date [null, note: 'Date when member joined club (cannot be in future)']
exit_date date [null, note: 'Date when member left club (must be after join_date)']
notes text [null, note: 'Additional notes about member']
city text [null, note: 'City of residence']
street text [null, note: 'Street name']
house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
indexes {
email [unique, name: 'members_unique_email_index']
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)']
first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search']
city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search']
street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search']
notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
}
Note: '''
**Club Member Master Data**
Core entity for membership management containing:
- Personal information (name, email)
- Contact details (phone, address)
- Membership status (join/exit dates, payment status)
- Additional notes
**Email Synchronization:**
When a member is linked to a user:
- User.email is the source of truth (overwrites member.email on link)
- Subsequent changes to either email sync bidirectionally
- Validates that email is not already used by another unlinked user
**Search Capabilities:**
1. Full-Text Search (tsvector):
- `search_vector` is auto-updated via trigger
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
- GIN index for fast text search
2. Fuzzy Search (pg_trgm):
- Trigram-based similarity matching
- 6 GIN trigram indexes on searchable fields
- Configurable similarity threshold (default 0.2)
- Supports typos and partial matches
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with custom_field_values (custom dynamic fields)
**Validation Rules:**
- first_name, last_name: min 1 character
- email: 5-254 characters, valid email format
- join_date: cannot be in future
- exit_date: must be after join_date (if both present)
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
- postal_code: exactly 5 digits
'''
}
Table custom_field_values {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
member_id uuid [not null, note: 'Link to member']
custom_field_id uuid [not null, note: 'Link to custom field definition']
indexes {
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
member_id [name: 'custom_field_values_member_id_idx']
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
}
Note: '''
**Dynamic Custom Member Field Values**
Provides flexible, extensible attributes for members beyond the fixed schema.
**Value Storage:**
- Stored as JSONB map with type discrimination
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
- Allows multiple data types in single column
**Supported Types:**
- `string`: Text data
- `integer`: Numeric data
- `boolean`: True/False flags
- `date`: Date values
- `email`: Validated email addresses
**Constraints:**
- Each member can have only ONE custom field value per custom field
- Custom field values are deleted when member is deleted (CASCADE)
- Custom field cannot be deleted if custom field values exist (RESTRICT)
**Use Cases:**
- Custom membership numbers
- Additional contact methods
- Club-specific attributes
- Flexible data model without schema migrations
'''
}
Table custom_fields {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
description text [null, note: 'Human-readable description']
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
required boolean [not null, default: false, note: 'If true, all members must have this custom field']
indexes {
name [unique, name: 'custom_fields_unique_name_index']
slug [unique, name: 'custom_fields_unique_slug_index']
}
Note: '''
**CustomFieldValue Type Definitions**
Defines the schema and behavior for custom member custom_field_values.
**Attributes:**
- `name`: Unique identifier for the custom field
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
- `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
- `required`: Enforces that all members must have this custom field
**Slug Generation:**
- Automatically generated from `name` on creation
- Immutable after creation (does not change when name is updated)
- Lowercase, spaces replaced with hyphens, special characters removed
- UTF-8 support (ä → a, ß → ss, etc.)
- Used for human-readable identifiers (CSV export/import, API, etc.)
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
**Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all custom fields
- `slug` must be unique across all custom fields
- `slug` cannot be empty (validated on creation)
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
**Examples:**
- Membership Number (string, immutable, required) → slug: "membership-number"
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
- Certification Date (date, immutable, optional) → slug: "certification-date"
'''
}
// ============================================
// RELATIONSHIPS
// ============================================
// Optional 1:1 User ↔ Member Link
// - A user can have 0 or 1 linked member (optional)
// - A member can have 0 or 1 linked user (optional)
// - Both can exist independently
// - ON DELETE SET NULL: User preserved when member deleted
// - Email Synchronization: When linking occurs, user.email becomes source of truth
Ref: users.member_id - members.id [delete: set null]
// Member → Properties (1:N)
// - One member can have multiple custom_field_values
// - Each custom field value belongs to exactly one member
// - ON DELETE CASCADE: Properties deleted when member deleted
// - UNIQUE constraint: One custom field value per custom field per member
Ref: custom_field_values.member_id > members.id [delete: cascade]
// CustomFieldValue → CustomField (N:1)
// - Many custom_field_values can reference one custom field
// - CustomFieldValue type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
// ============================================
// ENUMS
// ============================================
// Valid data types for custom field values
// Determines how CustomFieldValue.value is interpreted
Enum custom_field_value_type {
string [note: 'Text data']
integer [note: 'Numeric data']
boolean [note: 'True/False flags']
date [note: 'Date values']
email [note: 'Validated email addresses']
}
// Token purposes for different authentication flows
Enum token_purpose {
access [note: 'Short-lived access tokens']
refresh [note: 'Long-lived refresh tokens']
password_reset [note: 'Password reset tokens']
email_confirmation [note: 'Email verification tokens']
}
// ============================================
// TABLE GROUPS
// ============================================
TableGroup accounts_domain {
users
tokens
Note: '''
**Accounts Domain**
Handles user authentication and session management using AshAuthentication.
Supports multiple authentication strategies (Password, OIDC).
'''
}
TableGroup membership_domain {
members
custom_field_values
custom_fields
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
'''
}

File diff suppressed because it is too large Load diff

View file

@ -1,49 +0,0 @@
## Core Rules
1. **User.email is source of truth** - Always overrides member email when linking
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
---
## Decision Tree
```
Action: Create/Update/Link Entity with Email X
├─ Does Email X violate DB constraint (same table)?
│ └─ YES → ❌ FAIL (two users or two members with same email)
├─ Is Entity currently linked? (or being linked?)
│ │
│ ├─ NO (unlinked entity)
│ │ └─ ✅ SUCCESS (no custom validation)
│ │
│ └─ YES (linked or linking)
│ │
│ ├─ Action: Update Linked User Email
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
│ │
│ ├─ Action: Update Linked Member Email
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
│ │
│ ├─ Action: Link User to Member (both directions)
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
│ │ └─ Otherwise → ✅ SUCCESS + override member email
```
## Sync Triggers
| Action | Sync Direction | When |
|--------|---------------|------|
| Update linked user email | User → Member | Email changed |
| Update linked member email | Member → User | Email changed |
| Link user to member | User → Member | Always (override) |
| Link member to user | User → Member | Always (override) |
| Unlink | None | Emails stay as-is |

View file

@ -1,756 +0,0 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
**Last Updated:** 2025-11-10
**Status:** Planning Phase
---
## Table of Contents
1. [Phase 1: Feature Area Breakdown](#phase-1-feature-area-breakdown)
2. [Phase 2: API Endpoint Definition](#phase-2-api-endpoint-definition)
3. [Phase 3: Implementation Task Creation](#phase-3-implementation-task-creation)
4. [Phase 4: Task Organization and Prioritization](#phase-4-task-organization-and-prioritization)
---
## Phase 1: Feature Area Breakdown
### Feature Areas
#### 1. **Authentication & Authorization** 🔐
**Current State:**
- ✅ OIDC authentication (Rauthy)
- ✅ Password-based authentication
- ✅ User sessions and tokens
- ✅ Basic authentication flows
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
- ✅ **Secure OIDC email collision handling** (PR #192)
- ✅ **Automatic linking for passwordless users** (PR #192)
**Closed Issues:**
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
**Open Issues:**
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
**Missing Features:**
- ❌ Role-based access control (RBAC)
- ❌ Permission system
- ❌ Password reset flow
- ❌ Email verification
- ❌ Two-factor authentication (future)
**Related Issues:**
- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
---
#### 2. **Member Management** 👥
**Current State:**
- ✅ Member CRUD operations
- ✅ Member profile with personal data
- ✅ Address management
- ✅ Membership status tracking
- ✅ Full-text search (PostgreSQL tsvector)
- ✅ **Fuzzy search with trigram matching** (PR #187, closes #162)
- ✅ **Combined FTS + trigram search** (PR #187)
- ✅ **6 GIN trigram indexes** for fuzzy matching (PR #187)
- ✅ Sorting by basic fields
- ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
**Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
**Open Issues:**
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
- [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority)
- [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority)
- [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority)
- [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement)
**Missing Features:**
- ❌ Advanced filters (date ranges, multiple criteria)
- ❌ Pagination (currently all members loaded)
- ❌ Bulk operations (bulk delete, bulk update)
- ❌ Member import/export (CSV, Excel)
- ❌ Member profile photos/avatars
- ❌ Member history/audit log
- ❌ Duplicate detection
---
#### 3. **Custom Fields (CustomFieldValue System)** 🔧
**Current State:**
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
- ✅ CustomFieldValue type management
- ✅ Dynamic custom field value assignment to members
- ✅ Union type storage (JSONB)
- ✅ Default field visibility configuration
**Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
**Open Issues:**
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
- ❌ Field groups/categories
- ❌ Conditional fields (show field X if field Y = value)
- ❌ Field validation rules (min/max, regex patterns)
- ❌ Required custom fields
- ❌ Multi-select fields
- ❌ File upload fields
- ❌ Sorting by custom fields
- ❌ Searching by custom fields
---
#### 4. **User Management** 👤
**Current State:**
- ✅ User CRUD operations
- ✅ User list view
- ✅ User profile view
- ✅ Admin password setting
- ✅ User-Member relationship
**Missing Features:**
- ❌ User roles assignment UI
- ❌ User permissions management
- ❌ User activity log
- ❌ User invitation system
- ❌ User onboarding flow
- ❌ Self-service profile editing
- ❌ Password change flow
---
#### 5. **Navigation & UX** 🧭
**Current State:**
- ✅ Basic navigation structure
- ✅ Navbar with profile button
- ✅ Member list as landing page
- ✅ Breadcrumbs (basic)
**Open Issues:**
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
**Missing Features:**
- ❌ Dashboard/Home page
- ❌ Quick actions menu
- ❌ Recent activity widget
- ❌ Keyboard shortcuts
- ❌ Mobile navigation
- ❌ Context-sensitive help
- ❌ Onboarding tooltips
---
#### 6. **Internationalization (i18n)** 🌍
**Current State:**
- ✅ Gettext integration
- ✅ German translations
- ✅ English translations
- ✅ Translation files for auth, errors, default
**Open Issues:**
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
**Missing Features:**
- ❌ Language switcher UI
- ❌ User-specific language preferences
- ❌ Date/time localization
- ❌ Number formatting (currency, decimals)
- ❌ Complete translation coverage
- ❌ RTL support (future)
---
#### 7. **Payment & Fees Management** 💰
**Current State:**
- ✅ Basic "paid" boolean field on members
- ⚠️ No payment tracking
**Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
**Missing Features:**
- ❌ Membership fee configuration
- ❌ Payment records/transactions
- ❌ Payment history per member
- ❌ Payment reminders
- ❌ Payment status tracking (pending, paid, overdue)
- ❌ Invoice generation
- ❌ vereinfacht.digital API integration
- ❌ SEPA direct debit support
- ❌ Payment reports
**Related Milestones:**
- Import transactions via vereinfacht API
---
#### 8. **Admin Panel & Configuration** ⚙️
**Current State:**
- ✅ AshAdmin integration (basic)
- ⚠️ No user-facing admin UI
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
- ❌ Global settings management
- ❌ Club/Organization profile
- ❌ Email templates configuration
- ❌ CustomFieldValue type management UI (user-facing)
- ❌ Role and permission management UI
- ❌ System health dashboard
- ❌ Audit log viewer
- ❌ Backup/restore functionality
**Related Milestones:**
- As Admin I can configure settings globally
---
#### 9. **Communication & Notifications** 📧
**Current State:**
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
- ⚠️ No member communication features
**Missing Features:**
- ❌ Email broadcast to members
- ❌ Email templates (customizable)
- ❌ Email to member groups/filters
---
#### 10. **Reporting & Analytics** 📊
**Current State:**
- ❌ No reporting features
**Missing Features:**
- ❌ Member statistics dashboard
- ❌ Membership growth charts
- ❌ Payment reports
- ❌ Custom report builder
- ❌ Export to PDF/CSV/Excel
- ❌ Scheduled reports
- ❌ Data visualization
---
#### 11. **Data Import/Export** 📥📤
**Current State:**
- ✅ Seed data script
- ⚠️ No user-facing import/export
**Missing Features:**
- ❌ CSV import for members
- ❌ Excel import for members
- ❌ Import validation and preview
- ❌ Import error handling
- ❌ Bulk data export
- ❌ Backup export
- ❌ Data migration tools
---
#### 12. **Testing & Quality Assurance** 🧪
**Current State:**
- ✅ ExUnit test suite
- ✅ Unit tests for resources
- ✅ Integration tests for email sync
- ✅ LiveView tests
- ✅ Component tests
- ✅ CI/CD pipeline (Drone)
**Missing Features:**
- ❌ E2E tests (browser automation)
- ❌ Performance testing
- ❌ Load testing
- ❌ Security penetration testing
- ❌ Accessibility testing automation
- ❌ Visual regression testing
- ❌ Test coverage reporting
---
#### 13. **Infrastructure & DevOps** 🚀
**Current State:**
- ✅ Docker Compose for development
- ✅ Production Dockerfile
- ✅ Drone CI/CD pipeline
- ✅ Renovate for dependency updates
- ⚠️ No staging environment
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
- ❌ Staging environment
- ❌ Automated deployment
- ❌ Database backup automation
- ❌ Monitoring and alerting
- ❌ Error tracking (Sentry, etc.)
- ❌ Log aggregation
- ❌ Health checks and uptime monitoring
**Related Milestones:**
- We have a staging environment
- We implement security measures
---
#### 14. **Security & Compliance** 🔒
**Current State:**
- ✅ OIDC authentication
- ✅ Password hashing (bcrypt)
- ✅ CSRF protection
- ✅ SQL injection prevention (Ecto)
- ✅ Sobelow security scans
- ✅ Dependency auditing
**Missing Features:**
- ❌ Role-based access control (see #1)
- ❌ Audit logging
- ❌ GDPR compliance features (data export, deletion)
- ❌ Session management (timeout, concurrent sessions)
- ❌ Rate limiting
- ❌ IP whitelisting/blacklisting
- ❌ Security headers configuration
- ❌ Data retention policies
**Related Milestones:**
- We implement security measures
---
#### 15. **Accessibility & Usability**
**Current State:**
- ✅ Semantic HTML
- ✅ Basic ARIA labels
- ⚠️ Needs comprehensive audit
**Open Issues:**
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
**Missing Features:**
- ❌ Comprehensive accessibility audit (WCAG 2.1 Level AA)
- ❌ Keyboard navigation improvements
- ❌ Screen reader optimization
- ❌ High contrast mode
- ❌ Font size adjustments
- ❌ Focus management
- ❌ Skip links
- ❌ Error announcements
---
### Feature Area Summary
| Feature Area | Current Status | Priority | Complexity |
|--------------|----------------|----------|------------|
| **Authentication & Authorization** | 60% complete | **High** | Medium |
| **Member Management** | 85% complete | **High** | Low-Medium |
| **Custom Fields** | 50% complete | **High** | Medium |
| **User Management** | 60% complete | Medium | Low |
| **Navigation & UX** | 50% complete | Medium | Low |
| **Internationalization** | 70% complete | Low | Low |
| **Payment & Fees** | 5% complete | **High** | High |
| **Admin Panel** | 20% complete | Medium | Medium |
| **Communication** | 30% complete | Medium | Medium |
| **Reporting** | 0% complete | Medium | Medium-High |
| **Import/Export** | 10% complete | Low | Medium |
| **Testing & QA** | 60% complete | Medium | Low-Medium |
| **Infrastructure** | 70% complete | Medium | Medium |
| **Security** | 50% complete | **High** | Medium-High |
| **Accessibility** | 40% complete | Medium | Medium |
---
### Open Milestones (From Issues)
1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed)
2. ✅ **I can search through the list of members - fulltext** (Closed) - #162 implemented (Fuzzy Search), #154 needs refinement
3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153
4. 🔄 **We have a intuitive navigation structure** (Open)
5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151
6. 🔄 **As Admin I can configure settings globally** (Open)
7. ✅ **Accounts & Logins** (Partially closed) - #171 implemented (OIDC linking), #169/#168 still open
8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161
9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156
10. 🔄 **We have a staging environment** (Open)
11. 🔄 **We implement security measures** (Open)
---
---
## Phase 2: API Endpoint Definition
### Endpoint Types
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
1. **LiveView Endpoints** - Mount points and event handlers
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
3. **Ash Resource Actions** - Backend data layer API
### Authentication Requirements Legend
- 🔓 **Public** - No authentication required
- 🔐 **Authenticated** - Requires valid user session
- 👤 **User Role** - Requires specific user role
- 🛡️ **Admin Only** - Requires admin privileges
---
### 1. Authentication & Authorization Endpoints
#### HTTP Controller Endpoints
| Method | Route | Purpose | Auth | Request | Response |
|--------|-------|---------|------|---------|----------|
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/reset` | Request password reset | 🔓 | `{email}` | Success message + email sent |
| `GET` | `/auth/user/password/reset/:token` | Show reset password form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/reset/:token` | Submit new password | 🔓 | `{password, password_confirmation}` | Redirect to login |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |
#### **NEW: Role & Permission Actions** (Issue #191, #190, #151)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Role` | `:create` | Create new role | 🛡️ | `{name, description, permissions}` | `{:ok, role}` |
| `Role` | `:list` | List all roles | 🔐 | - | `[%Role{}]` |
| `Role` | `:update` | Update role | 🛡️ | `{id, name, permissions}` | `{:ok, role}` |
| `Role` | `:delete` | Delete role | 🛡️ | `{id}` | `{:ok, role}` |
| `User` | `:assign_role` | Assign role to user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
| `User` | `:remove_role` | Remove role from user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
| `Permission` | `:list` | List all permissions | 🔐 | - | `[%Permission{}]` |
| `Permission` | `:check` | Check user permission | 🔐 | `{user_id, resource, action}` | `{:ok, boolean}` |
---
### 2. Member Management Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Query Params | Events |
|-------|---------|------|--------------|--------|
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value` |
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value`, `remove_custom_field_value` |
#### LiveView Event Handlers
| Event | Purpose | Params | Response |
|-------|---------|--------|----------|
| `search` | Trigger search | `%{"search" => query}` | Update member list |
| `sort` | Sort member list | `%{"field" => field}` | Update sorted list |
| `delete` | Delete member | `%{"id" => id}` | Redirect to list |
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
| `unlink_user` | Unlink user from member | - | Update member view |
| `add_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form |
| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_id" => id}` | Update form |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:create_member` | Create member | 🔐 | `{first_name, last_name, email, ...}` | `{:ok, member}` |
| `Member` | `:read` | List/search members | 🔐 | `{search, sort_by, limit, offset}` | `[%Member{}]` |
| `Member` | `:update_member` | Update member | 🔐 | `{id, attrs}` | `{:ok, member}` |
| `Member` | `:destroy` | Delete member | 🔐 | `{id}` | `{:ok, member}` |
| `Member` | `:search_fulltext` | Full-text search | 🔐 | `{query}` | `[%Member{}]` |
| `Member` | `:link_to_user` | Link member to user | 🔐 | `{member_id, user_id}` | `{:ok, member}` |
| `Member` | `:unlink_from_user` | Unlink from user | 🔐 | `{member_id}` | `{:ok, member}` |
#### **NEW: Enhanced Search & Filter Actions** (Issue #162, #154, #165)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` |
| `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` |
| `Member` | `:sort_by_custom_field` | Sort by custom field | 🔐 | `{custom_field_id, direction}` | `[%Member{}]` |
| `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
| `Member` | `:import` | Import from CSV | 🛡️ | `{file, mapping}` | `{:ok, imported_count, errors}` |
---
### 3. Custom Fields (CustomFieldValue System) Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` |
| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` |
| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` |
| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` |
| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` |
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` |
| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` |
| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` |
| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` |
| `CustomFieldValue` | `:validate_value` | Validate custom field value | 🔐 | `{custom_field_id, value}` | `{:ok, valid}` or `{:error, reason}` |
---
### 4. User Management Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/users` | User list | 🛡️ | `new`, `edit`, `delete`, `assign_role` |
| `/users/new` | Create user form | 🛡️ | `save`, `cancel` |
| `/users/:id` | User detail view | 🔐 | `edit`, `delete`, `change_password` |
| `/users/:id/edit` | Edit user form | 🔐 | `save`, `cancel`, `link_member` |
| `/profile` | Current user profile | 🔐 | `edit`, `change_password` |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:create_user` | Create user (admin) | 🛡️ | `{email, member_id?}` | `{:ok, user}` |
| `User` | `:read` | List users | 🛡️ | - | `[%User{}]` |
| `User` | `:update_user` | Update user | 🔐 | `{id, email, member_id?}` | `{:ok, user}` |
| `User` | `:destroy` | Delete user | 🛡️ | `{id}` | `{:ok, user}` |
| `User` | `:admin_set_password` | Set password (admin) | 🛡️ | `{id, password}` | `{:ok, user}` |
| `User` | `:change_password` | Change own password | 🔐 | `{current_password, new_password}` | `{:ok, user}` |
#### **NEW: Combined User/Member Management** (Issue #169, #168)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:create_with_member` | Create user + member together | 🛡️ | `{user: {...}, member: {...}}` | `{:ok, %{user, member}}` |
| `User` | `:invite_user` | Send invitation email | 🛡️ | `{email, role_id, member_id?}` | `{:ok, invitation}` |
| `User` | `:accept_invitation` | Accept invitation | 🔓 | `{token, password}` | `{:ok, user}` |
---
### 5. Navigation & UX Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/` | Dashboard/Home | 🔐 | - |
| `/dashboard` | Dashboard view | 🔐 | Contextual based on role |
#### HTTP Controller Endpoints
| Method | Route | Purpose | Auth | Request | Response |
|--------|-------|---------|------|---------|----------|
| `GET` | `/health` | Health check | 🔓 | - | `{"status": "ok"}` |
| `GET` | `/` | Root redirect | - | - | Redirect to dashboard or login |
---
### 6. Internationalization Endpoints
#### HTTP Controller Endpoints
| Method | Route | Purpose | Auth | Request | Response |
|--------|-------|---------|------|---------|----------|
| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
---
### 7. Payment & Fees Management Endpoints
#### LiveView Endpoints (NEW - Issue #156)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/payments` | Payment list | 🔐 | `new`, `record_payment`, `send_reminder` |
| `/payments/:id` | Payment detail | 🔐 | `edit`, `delete`, `mark_paid` |
| `/fees` | Fee configuration | 🛡️ | `create`, `edit`, `delete` |
| `/invoices` | Invoice list | 🔐 | `generate`, `download`, `send` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Fee` | `:create` | Create fee type | 🛡️ | `{name, amount, frequency}` | `{:ok, fee}` |
| `Fee` | `:read` | List fees | 🔐 | - | `[%Fee{}]` |
| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` |
| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` |
| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` |
| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` |
| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` |
| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` |
---
### 8. Admin Panel & Configuration Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/admin` | Admin dashboard | 🛡️ | - |
| `/admin/settings` | Global settings | 🛡️ | `save` |
| `/admin/organization` | Organization profile | 🛡️ | `save` |
| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` |
| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Setting` | `:get` | Get setting value | 🔐 | `{key}` | `value` |
| `Setting` | `:set` | Set setting value | 🛡️ | `{key, value}` | `{:ok, setting}` |
| `Setting` | `:list` | List all settings | 🛡️ | - | `[%Setting{}]` |
| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` |
| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` |
| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` |
---
### 9. Communication & Notifications Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/communications` | Communication history | 🔐 | `new`, `view` |
| `/communications/new` | Create email broadcast | 🔐 | `select_recipients`, `preview`, `send` |
| `/notifications` | User notifications | 🔐 | `mark_read`, `mark_all_read` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `EmailBroadcast` | `:create` | Create broadcast | 🔐 | `{subject, body, recipient_filter}` | `{:ok, broadcast}` |
| `EmailBroadcast` | `:send` | Send broadcast | 🔐 | `{id}` | `{:ok, sent_count}` |
| `EmailTemplate` | `:create` | Create template | 🛡️ | `{name, subject, body}` | `{:ok, template}` |
| `EmailTemplate` | `:render` | Render template | 🔐 | `{id, variables}` | `rendered_html` |
| `Notification` | `:create` | Create notification | System | `{user_id, type, message}` | `{:ok, notification}` |
| `Notification` | `:list_for_user` | Get user notifications | 🔐 | `{user_id}` | `[%Notification{}]` |
| `Notification` | `:mark_read` | Mark as read | 🔐 | `{id}` | `{:ok, notification}` |
---
### 10. Reporting & Analytics Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/reports` | Reports dashboard | 🔐 | `generate`, `schedule` |
| `/reports/members` | Member statistics | 🔐 | `filter`, `export` |
| `/reports/payments` | Payment reports | 🔐 | `filter`, `export` |
| `/reports/custom` | Custom report builder | 🛡️ | `build`, `save`, `run` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Report` | `:generate_member_stats` | Member statistics | 🔐 | `{date_range, filters}` | Statistics object |
| `Report` | `:generate_payment_stats` | Payment statistics | 🔐 | `{date_range}` | Statistics object |
| `Report` | `:export_to_csv` | Export report to CSV | 🔐 | `{report_type, filters}` | CSV file |
| `Report` | `:export_to_pdf` | Export report to PDF | 🔐 | `{report_type, filters}` | PDF file |
| `Report` | `:schedule` | Schedule recurring report | 🛡️ | `{report_type, frequency, recipients}` | `{:ok, schedule}` |
---
### 11. Data Import/Export Endpoints
#### LiveView Endpoints (NEW)
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/import` | Data import wizard | 🛡️ | `upload`, `map_fields`, `preview`, `import` |
| `/export` | Data export tool | 🔐 | `select_data`, `configure`, `export` |
#### Ash Resource Actions (NEW)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `Member` | `:import_csv` | Import members from CSV | 🛡️ | `{file, field_mapping}` | `{:ok, imported, errors}` |
| `Member` | `:validate_import` | Validate import data | 🛡️ | `{file, field_mapping}` | `{:ok, validation_results}` |
| `Member` | `:export_csv` | Export members to CSV | 🔐 | `{filters}` | CSV file |
| `Member` | `:export_excel` | Export members to Excel | 🔐 | `{filters}` | Excel file |
| `Database` | `:export_backup` | Full database backup | 🛡️ | - | Backup file |
| `Database` | `:import_backup` | Restore from backup | 🛡️ | `{file}` | `{:ok, restored}` |
---
---
**References:**
- Open Issues: https://git.local-it.org/local-it/mitgliederverwaltung/issues
- Project Board: Sprint 8 (23.10 - 13.11)
- Architecture: See [`CODE_GUIDELINES.md`](../CODE_GUIDELINES.md)
- Database Schema: See [`database-schema-readme.md`](database-schema-readme.md)

View file

@ -1,207 +0,0 @@
# OIDC Account Linking Implementation
## Overview
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
## Architecture
### Key Components
#### 1. Security Fix: `lib/accounts/user.ex`
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
```elixir
read :sign_in_with_rauthy do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
# SECURITY: Filter by oidc_id, NOT by email!
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
end
```
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
Custom error raised when OIDC login conflicts with existing password account.
**Fields**:
- `user_id`: ID of the existing user
- `oidc_user_info`: OIDC user information for account linking
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
Validates email uniqueness during OIDC registration.
**Scenarios**:
1. **User exists with matching `oidc_id`**: Allow (upsert)
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
- Password-protected users must verify their password
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
4. **No user exists**: Allow (new user creation)
#### 4. Account Linking Action: `lib/accounts/user.ex`
```elixir
update :link_oidc_id do
description "Links an OIDC ID to an existing user after password verification"
accept []
argument :oidc_id, :string, allow_nil?: false
argument :oidc_user_info, :map, allow_nil?: false
# ... implementation
end
```
**Features**:
- Links `oidc_id` to existing user
- Updates email if it differs from OIDC provider
- Syncs email changes to linked member
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
Refactored for better complexity and maintainability.
**Key improvements**:
- Reduced cyclomatic complexity from 11 to below 9
- Better separation of concerns with helper functions
- Comprehensive documentation
**Flow**:
1. Detects `PasswordVerificationRequired` error
2. Stores OIDC info in session
3. Redirects to account linking page
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
Interactive UI for password verification and account linking.
**Flow**:
1. Retrieves OIDC info from session
2. **Auto-links passwordless users** immediately (no password prompt)
3. Displays password verification form for password-protected users
4. Verifies password using AshAuthentication
5. Links OIDC account on success
6. Redirects to complete OIDC login
7. **Logs all security-relevant events** (successful/failed linking attempts)
### Locale Persistence
**Problem**: Locale was lost on logout (session cleared).
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
**Changes**:
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
**Security Features**:
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
- `secure: true` - Cookie only transmitted over HTTPS in production
- `same_site: "Lax"` - CSRF protection
## Security Considerations
### 1. OIDC ID Matching
- **Before**: Matched by email (vulnerable to account takeover)
- **After**: Matched by `oidc_id` (secure)
### 2. Account Linking Flow
- Password verification required before linking (for password-protected users)
- Passwordless users are auto-linked immediately (secure, as they have no password)
- OIDC info stored in session (not in URL/query params)
- CSRF protection on all forms
- All linking attempts logged for audit trail
### 3. Email Updates
- Email updates from OIDC provider are applied during linking
- Email changes sync to linked member (if exists)
### 4. Error Handling
- Internal errors are logged but not exposed to users (prevents information disclosure)
- User-friendly error messages shown in UI
- Security-relevant events logged with appropriate levels:
- `Logger.info` for successful operations
- `Logger.warning` for failed authentication attempts
- `Logger.error` for system errors
## Usage Examples
### Scenario 1: New OIDC User
```elixir
# User signs in with OIDC for the first time
# → New user created with oidc_id
```
### Scenario 2: Existing OIDC User
```elixir
# User with oidc_id signs in via OIDC
# → Matched by oidc_id, email updated if changed
```
### Scenario 3: Password User + OIDC Login
```elixir
# User with password account tries OIDC login
# → PasswordVerificationRequired raised
# → Redirected to /auth/link-oidc-account
# → User enters password
# → Password verified and logged
# → oidc_id linked to account
# → Successful linking logged
# → Redirected to complete OIDC login
```
### Scenario 4: Passwordless User + OIDC Login
```elixir
# User without password (invited user) tries OIDC login
# → PasswordVerificationRequired raised
# → Redirected to /auth/link-oidc-account
# → System detects passwordless user
# → oidc_id automatically linked (no password prompt)
# → Auto-linking logged
# → Redirected to complete OIDC login
```
## API
### Custom Actions
#### `link_oidc_id`
Links an OIDC ID to existing user after password verification.
**Arguments**:
- `oidc_id` (required): OIDC sub/id from provider
- `oidc_user_info` (required): Full OIDC user info map
**Returns**: Updated user with linked `oidc_id`
**Side Effects**:
- Updates email if different from OIDC provider
- Syncs email to linked member (if exists)
## References
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,506 +0,0 @@
# Roles and Permissions - Architecture Overview
**Project:** Mila - Membership Management System
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
**Version:** 2.0
**Last Updated:** 2025-11-13
**Status:** Architecture Design - MVP Approach
---
## Purpose of This Document
This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
---
## Table of Contents
1. [Overview](#overview)
2. [Requirements Summary](#requirements-summary)
3. [Evaluated Approaches](#evaluated-approaches)
4. [Selected Architecture](#selected-architecture)
5. [Permission System Design](#permission-system-design)
6. [User-Member Linking Strategy](#user-member-linking-strategy)
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
8. [Migration Strategy](#migration-strategy)
9. [Related Documents](#related-documents)
---
## Overview
The Mila membership management system requires a flexible authorization system that controls:
- **Who** can access **what** resources
- **Which** pages users can view
- **How** users interact with their own vs. others' data
### Key Design Principles
1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
2. **Performance:** No database queries for permission checks in MVP
3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
4. **Security:** Explicit action-based authorization with no ambiguity
5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
### Core Concepts
**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
**User:** Each user has exactly one Role, inheriting that Role's Permission Set
**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
---
## Evaluated Approaches
During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
### Approach 1: JSONB in Roles Table
Store all permissions as a single JSONB column directly in the roles table.
**Advantages:**
- Simplest database schema (single table)
- Very flexible structure
- No additional tables needed
- Fast to implement
**Disadvantages:**
- Poor queryability (can't efficiently filter by specific permissions)
- No referential integrity
- Difficult to validate structure
- Hard to audit permission changes
- Can't leverage database indexes effectively
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
---
### Approach 2: Normalized Database Tables
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
**Advantages:**
- Fully queryable with SQL
- Runtime configurable permissions
- Strong referential integrity
- Easy to audit changes
- Can index for performance
**Disadvantages:**
- Complex database schema (4+ tables)
- DB queries required for every permission check
- Requires ETS cache for performance
- Needs admin UI for permission management
- Longer implementation time (4-5 weeks)
- Overkill for fixed set of 4 permission sets
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
---
### Approach 3: Custom Authorizer
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
**Advantages:**
- Complete control over authorization logic
- Can implement any custom behavior
- Not constrained by Ash Policy DSL
**Disadvantages:**
- Significantly more code to write and maintain
- Loses benefits of Ash's declarative policies
- Harder to test than built-in policy system
- Mixes declarative and imperative approaches
- Must reimplement filter generation for queries
- Higher bug risk
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
---
### Approach 4: Simple Role Enum
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
**Advantages:**
- Very simple to implement (< 1 week)
- No extra tables needed
- Fast performance
- Easy to understand
**Disadvantages:**
- No separation between roles and permissions
- Can't add new roles without code changes
- No dynamic permission configuration
- Not extensible to field-level permissions
- Violates separation of concerns (role = job function, not permission set)
- Difficult to maintain as requirements grow
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
---
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
Permission Sets hardcoded in Elixir module, only Roles table in database.
**Advantages:**
- Fast implementation (2-3 weeks vs 4-5 weeks)
- Maximum performance (zero DB queries, < 1 microsecond)
- Simple to test (pure functions)
- Code-reviewable permissions (visible in Git)
- No migration needed for existing data
- Clearly defined 4 permission sets as required
- Clear migration path to database-backed solution (Phase 3)
- Maintains separation of roles and permission sets
**Disadvantages:**
- Permissions not editable at runtime (only role assignment possible)
- New permissions require code deployment
- Not suitable if permissions change frequently (> 1x/week)
- Limited to the 4 predefined permission sets
**Why Selected:**
- MVP requirement is for 4 fixed permission sets (not custom ones)
- No stated requirement for runtime permission editing
- Performance is critical for authorization checks
- Fast time-to-market (2-3 weeks)
- Clear upgrade path when runtime configuration becomes necessary
**Migration Path:**
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
---
## Requirements Summary
### Four Predefined Permission Sets
1. **own_data** - Access only to own user account and linked member profile
2. **read_only** - Read access to all members and custom fields
3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
4. **admin** - Unrestricted access to all resources including user management
### Example Roles
- **Mitglied (Member)** - Uses "own_data" permission set, default role
- **Vorstand (Board)** - Uses "read_only" permission set
- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
- **Buchhaltung (Accounting)** - Uses "read_only" permission set
- **Admin** - Uses "admin" permission set
### Authorization Levels
**Resource Level (MVP):**
- Controls create, read, update, destroy actions on resources
- Resources: Member, User, Property, PropertyType, Role
**Page Level (MVP):**
- Controls access to LiveView pages
- Example: "/members/new" requires Member.create permission
**Field Level (Phase 2 - Future):**
- Controls read/write access to specific fields
- Example: Only Treasurer can see payment_history field
### Special Cases
1. **Own Credentials:** Users can always edit their own email and password
2. **Linked Member Email:** Only admins can edit email of members linked to users
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
---
## Selected Architecture
### Conceptual Model
```
Elixir Module: PermissionSets
↓ (defines)
Permission Set (:own_data, :read_only, :normal_user, :admin)
↓ (referenced by)
Role (stored in DB: "Vorstand" → "read_only")
↓ (assigned to)
User (each user has one role_id)
```
### Database Schema (MVP)
**Single Table: roles**
Contains:
- id (UUID)
- name (e.g., "Vorstand")
- description
- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
- is_system_role (boolean, protects critical roles)
**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
### Why This Approach?
**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
**Maximum Performance:**
- Zero database queries for permission checks
- Pure function calls (< 1 microsecond)
- No caching needed
**Code Review:**
- Permissions visible in Git diffs
- Easy to review changes
- No accidental runtime modifications
**Clear Upgrade Path:**
- Phase 1 (MVP): Hardcoded
- Phase 2: Add field-level permissions
- Phase 3: Migrate to database-backed with admin UI
**Meets Requirements:**
- Four predefined permission sets ✓
- Dynamic role creation ✓ (Roles in DB)
- Role-to-user assignment ✓
- No requirement for runtime permission changes stated
---
## Permission System Design
### Permission Structure
Each Permission Set contains:
**Resources:** List of resource permissions
- resource: "Member", "User", "Property", etc.
- action: :read, :create, :update, :destroy
- scope: :own, :linked, :all
- granted: true/false
**Pages:** List of accessible page paths
- Examples: "/", "/members", "/members/:id/edit"
- "*" for admin (all pages)
### Scope Definitions
**:own** - Only records where id == actor.id
- Example: User can read their own User record
**:linked** - Only records where user_id == actor.id
- Example: User can read Member linked to their account
**:all** - All records without restriction
- Example: Admin can read all Members
### How Authorization Works
1. User attempts action on resource (e.g., read Member)
2. System loads user's role from database
3. Role contains permission_set_name string
4. PermissionSets module returns permissions for that set
5. Custom Policy Check evaluates permissions against action
6. Access granted or denied based on scope
### Custom Policy Check
A reusable Ash Policy Check that:
- Reads user's permission_set_name from their role
- Calls PermissionSets.get_permissions/1
- Matches resource + action against permissions list
- Applies scope filters (own/linked/all)
- Returns authorized, forbidden, or filtered query
---
## User-Member Linking Strategy
### Problem Statement
Users need to create member profiles for themselves (self-service), but only admins should be able to:
- Link existing members to users
- Unlink members from users
- Create members pre-linked to arbitrary users
### Selected Approach: Separate Ash Actions
Instead of complex field-level validation, we use action-based authorization.
### Actions on Member Resource
**1. create_member_for_self** (All authenticated users)
- Automatically sets user_id = actor.id
- User cannot specify different user_id
- UI: "Create My Profile" button
**2. create_member** (Admin only)
- Can set user_id to any user or leave unlinked
- Full flexibility for admin
- UI: Admin member management form
**3. link_member_to_user** (Admin only)
- Updates existing member to set user_id
- Connects unlinked member to user account
**4. unlink_member_from_user** (Admin only)
- Sets user_id to nil
- Disconnects member from user account
**5. update** (Permission-based)
- Normal updates (name, address, etc.)
- user_id NOT in accept list (prevents manipulation)
- Available to users with Member.update permission
### Why Separate Actions?
**Explicit Semantics:** Each action has clear, single purpose
**Server-Side Security:** user_id set by server, not client input
**Better UX:** Different UI flows for different use cases
**Simple Policies:** Authorization at action level, not field level
**Easy Testing:** Each action independently testable
---
## Field-Level Permissions Strategy
### Status: Phase 2 (Future Implementation)
Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
### Problem Statement
Some scenarios require field-level control:
- **Read restrictions:** Hide payment_history from certain roles
- **Write restrictions:** Only treasurer can edit payment fields
- **Complexity:** Ash Policies work at resource level, not field level
### Selected Strategy
**For Read Restrictions:**
Use Ash Calculations or Custom Preparations
- Calculations: Dynamically compute field based on permissions
- Preparations: Filter select to only allowed fields
- Field returns nil or "[Hidden]" if unauthorized
**For Write Restrictions:**
Use Custom Validations
- Validate changeset against field permissions
- Similar to existing linked-member email validation
- Return error if field modification not allowed
### Why This Strategy?
**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
**Performance:** Calculations are lazy, Preparations run once per query
**Maintainable:** Clear validation logic, standard Ash patterns
**Extensible:** Easy to add new field restrictions
### Implementation Timeline
**Phase 1 (MVP):** No field-level permissions
**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
**Phase 3:** If migrating to database, add permission_set_fields table
---
## Migration Strategy
### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
**What's Included:**
- Roles table in database
- PermissionSets Elixir module with 4 predefined sets
- Custom Policy Check reading from module
- UI Authorization Helpers for LiveView
- Admin UI for role management (create, assign, delete roles)
**Limitations:**
- Permissions not editable at runtime
- New permissions require code deployment
- Only 4 permission sets available
**Benefits:**
- Fast implementation
- Maximum performance
- Simple testing and review
### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
**When Needed:** Business requires field-level restrictions
**Implementation:**
- Extend PermissionSets module with :fields key
- Add Ash Calculations for read restrictions
- Add custom validations for write restrictions
- Update UI Helpers
**Migration:** No database changes, pure code additions
### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
**When Needed:** Runtime permission configuration required
**Implementation:**
- Create permission tables in database
- Seed script to migrate hardcoded permissions
- Update PermissionSets module to query database
- Add ETS cache for performance
- Build admin UI for permission management
**Migration:** Seamless, no changes to existing Policies or UI code
### Decision Matrix: When to Migrate?
| Scenario | Recommended Phase |
|----------|-------------------|
| MVP with 4 fixed permission sets | Phase 1 |
| Need field-level restrictions | Phase 2 |
| Permission changes < 1x/month | Stay Phase 1 |
| Need runtime permission config | Phase 3 |
| Custom permission sets needed | Phase 3 |
| Permission changes > 1x/week | Phase 3 |
---
## Related Documents
**This Document (Overview):** High-level concepts, no code examples
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
---
## Summary
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
- **Performance:** Zero database queries for authorization
- **Clarity:** Permissions in Git, reviewable and testable
- **Flexibility:** Clear migration path to database-backed system
**User-Member linking** uses **separate Ash Actions** for clarity and security.
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
The approach balances pragmatism for MVP delivery with extensibility for future requirements.

View file

@ -66,17 +66,14 @@ defmodule Mv.Accounts.User do
end
actions do
# Default actions for framework/tooling integration:
# Default actions kept for framework/tooling integration:
# - :create -> Used by AshAdmin's generated "Create" UI and by generic
# AshPhoenix helpers that assume a default create action.
# It does NOT manage the :member relationship. For admin
# flows that may link an existing member, use :create_user.
# - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
#
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_rauthy (for OIDC-based registration)
defaults [:read, :destroy]
defaults [:read, :create, :destroy]
# Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
@ -92,12 +89,6 @@ defmodule Mv.Accounts.User do
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Sync email changes to linked member (User → Member)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end
create :create_user do
@ -120,9 +111,6 @@ defmodule Mv.Accounts.User do
# If no member provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
update :update_user do
@ -149,12 +137,6 @@ defmodule Mv.Accounts.User do
# If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate
)
# Sync email changes and handle linking (User → Member)
# Runs when email OR member relationship changes
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
end
# Admin action for direct password changes in admin panel
@ -171,41 +153,6 @@ defmodule Mv.Accounts.User do
change AshAuthentication.Strategy.Password.HashPasswordChange
end
# Action to link an OIDC account to an existing password-only user
# This is called after the user has verified their password
update :link_oidc_id do
description "Links an OIDC ID to an existing user after password verification"
accept []
argument :oidc_id, :string, allow_nil?: false
argument :oidc_user_info, :map, allow_nil?: false
require_atomic? false
change fn changeset, _ctx ->
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
# Get the new email from OIDC user_info
new_email = Map.get(oidc_user_info, "preferred_username")
changeset
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
# Update email if it differs from OIDC provider
# change_attribute/3 already checks if value matches existing value
|> then(fn cs ->
if new_email do
Ash.Changeset.change_attribute(cs, :email, new_email)
else
cs
end
end)
end
# Sync email changes to member if email was updated
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end
read :get_by_subject do
description "Get a user by the subject claim in a JWT"
argument :subject, :string, allow_nil?: false
@ -218,18 +165,13 @@ defmodule Mv.Accounts.User do
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
# SECURITY: Filter by oidc_id, NOT by email!
# This ensures that OIDC sign-in only works for users who have already
# linked their account via OIDC. Password-only users (oidc_id = nil)
# cannot be accessed via OIDC login without password verification.
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
end
create :register_with_rauthy do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
# Upsert based on oidc_id (primary match for existing OIDC users)
upsert_identity :unique_oidc_id
validate &__MODULE__.validate_oidc_id_present/2
@ -243,15 +185,6 @@ defmodule Mv.Accounts.User do
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end
# Check for email collisions with existing accounts
# This validation must run AFTER email and oidc_id are set above
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
validate Mv.Accounts.User.Validations.OidcEmailCollision
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
end
@ -262,10 +195,6 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8"
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
# Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation
validate fn changeset, _ ->
@ -326,13 +255,6 @@ defmodule Mv.Accounts.User do
attributes do
uuid_primary_key :id
# IMPORTANT: Email Synchronization
# When user and member are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :ci_string do
allow_nil? false
public? true

View file

@ -1,33 +0,0 @@
defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do
@moduledoc """
Custom error raised when an OIDC login attempts to use an email that already exists
in the system with a password-only account (no oidc_id set).
This error indicates that the user must verify their password before the OIDC account
can be linked to the existing password account.
"""
use Splode.Error,
fields: [:user_id, :oidc_user_info],
class: :invalid
@type t :: %__MODULE__{
user_id: String.t(),
oidc_user_info: map()
}
@doc """
Returns a human-readable error message.
## Parameters
- error: The error struct containing user_id and oidc_user_info
"""
def message(%{user_id: user_id, oidc_user_info: user_info}) do
email = Map.get(user_info, "preferred_username", "unknown")
oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown")
"""
Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}).
To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password.
"""
end
end

View file

@ -1,172 +0,0 @@
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
@moduledoc """
Validation that checks for email collisions during OIDC registration.
This validation prevents unauthorized account takeovers and enforces proper
account linking flows based on user state.
## Scenarios:
1. **User exists with matching oidc_id**:
- Allow (upsert will update the existing user)
2. **User exists with different oidc_id**:
- Hard error: Cannot link multiple OIDC providers to same account
- No linking possible - user must use original OIDC provider
3. **User exists without oidc_id** (password-protected OR passwordless):
- Raise PasswordVerificationRequired error
- User is redirected to LinkOidcAccountLive which will:
- Show password form if user has password
- Auto-link immediately if user is passwordless
4. **No user exists with this email**:
- Allow (new user will be created)
"""
use Ash.Resource.Validation
require Logger
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(changeset, _opts, _context) do
# Get the email and oidc_id from the changeset
email = Ash.Changeset.get_attribute(changeset, :email)
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
user_info = Ash.Changeset.get_argument(changeset, :user_info)
# Only validate if we have both email and oidc_id (from OIDC registration)
if email && oidc_id && user_info do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
existing_oidc_user =
case Mv.Accounts.User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one() do
{:ok, user} -> user
_ -> nil
end
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
else
:ok
end
end
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
# Find existing user with this email
case Mv.Accounts.User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one() do
{:ok, nil} ->
# No user exists with this email - OK to create new user
:ok
{:ok, user_with_email} ->
# User exists with this email - check if it's an upsert or registration
is_upsert = not is_nil(existing_oidc_user)
if is_upsert do
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
else
handle_create_scenario(user_with_email, new_oidc_id, user_info)
end
{:error, error} ->
# Database error - log for debugging but don't expose internals to user
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
end
end
# Handle email update for existing OIDC user
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
cond do
# Same user updating their own record
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
:ok
# Different user exists with target email
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
handle_email_conflict(user_with_email, user_info)
# Should not reach here
true ->
{:error, field: :email, message: "Unexpected error during email update"}
end
end
# Handle email conflict during upsert
defp handle_email_conflict(user_with_email, user_info) do
email = Map.get(user_info, "preferred_username", "unknown")
email_user_oidc_id = user_with_email.oidc_id
# Check if target email belongs to another OIDC user
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
different_oidc_error(email)
else
email_taken_error(email)
end
end
# Handle new OIDC user registration scenarios
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
email_user_oidc_id = user_with_email.oidc_id
cond do
# Same oidc_id (should not happen in practice, but allow for safety)
email_user_oidc_id == new_oidc_id ->
:ok
# Different oidc_id exists (hard error)
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
email_user_oidc_id != new_oidc_id ->
email = Map.get(user_info, "preferred_username", "unknown")
different_oidc_error(email)
# No oidc_id (require account linking)
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
{:error,
PasswordVerificationRequired.exception(
user_id: user_with_email.id,
oidc_user_info: user_info
)}
# Should not reach here
true ->
{:error, field: :email, message: "Unexpected error during OIDC registration"}
end
end
# Generate error for different OIDC account conflict
defp different_oidc_error(email) do
{:error,
field: :email,
message:
"Email '#{email}' is already linked to a different OIDC account. " <>
"Cannot link multiple OIDC providers to the same account."}
end
# Generate error for email already taken
defp email_taken_error(email) do
{:error,
field: :email,
message:
"Cannot update email to '#{email}': This email is already registered to another account. " <>
"Please change your email in the identity provider."}
end
@impl true
def atomic?(), do: false
@impl true
def describe(_opts) do
[
message: "OIDC email collision detected",
vars: []
]
end
end

View file

@ -1,150 +0,0 @@
defmodule Mv.Membership.CustomField do
@moduledoc """
Ash resource defining the schema for custom member fields.
## Overview
CustomFields define the "schema" for custom fields in the membership system.
Each CustomField specifies the name, data type, and behavior of a custom field
that can be attached to members via CustomFieldValue resources.
## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
## Supported Value Types
- `:string` - Text data (max 10,000 characters)
- `:integer` - Numeric data (64-bit integers)
- `:boolean` - True/false flags
- `:date` - Date values (no time component)
- `:email` - Validated email addresses (max 254 characters)
## Relationships
- `has_many :custom_field_values` - All custom field values of this type
## Constraints
- Name must be unique across all custom fields
- Name maximum length: 100 characters
- Deleting a custom field will cascade delete all associated custom field values
## Calculations
- `assigned_members_count` - Returns the number of distinct members with values for this custom field
## Examples
# Create a new custom field
CustomField.create!(%{
name: "phone_mobile",
value_type: :string,
description: "Mobile phone number"
})
# Create a required custom field
CustomField.create!(%{
name: "emergency_contact",
value_type: :string,
required: true
})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "custom_fields"
repo Mv.Repo
end
actions do
defaults [:read, :update]
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
create :create do
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
destroy :destroy_with_values do
primary? true
end
read :prepare_deletion do
argument :id, :uuid, allow_nil?: false
filter expr(id == ^arg(:id))
prepare build(load: [:assigned_members_count])
end
end
attributes do
uuid_primary_key :id
attribute :name, :string,
allow_nil?: false,
public?: true,
constraints: [
max_length: 100,
trim?: true
]
attribute :slug, :string,
allow_nil?: false,
public?: true,
writable?: false,
constraints: [
max_length: 100,
trim?: true
]
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
attribute :description, :string,
allow_nil?: true,
public?: true,
constraints: [
max_length: 500,
trim?: true
]
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false
attribute :show_in_overview, :boolean,
default: true,
allow_nil?: false,
public?: true,
description: "If true, this custom field will be displayed in the member overview table"
end
relationships do
has_many :custom_field_values, Mv.Membership.CustomFieldValue
end
calculations do
calculate :assigned_members_count,
:integer,
expr(
fragment(
"(SELECT COUNT(DISTINCT member_id) FROM custom_field_values WHERE custom_field_id = ?)",
id
)
)
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
end

View file

@ -1,118 +0,0 @@
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
@moduledoc """
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
## Behavior
- **On Create**: Generates a slug from the name attribute using slugify
- **On Update**: Slug remains unchanged (immutable after creation)
- **Slug Generation**: Uses the `slugify` library to convert name to slug
- Converts to lowercase
- Replaces spaces with hyphens
- Removes special characters
- Handles UTF-8 characters (e.g., ä a, ß ss)
- Trims leading/trailing hyphens
- Truncates to max 100 characters
## Examples
# Create with automatic slug generation
CustomField.create!(%{name: "Mobile Phone"})
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
# German umlauts are converted
CustomField.create!(%{name: "Café Müller"})
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
# Slug is immutable on update
custom_field = CustomField.create!(%{name: "Original"})
CustomField.update!(custom_field, %{name: "New Name"})
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
## Implementation Note
This change only runs on `:create` actions. The slug is immutable by design,
as changing slugs would break external references (e.g., CSV imports/exports).
"""
use Ash.Resource.Change
@doc """
Generates a slug from the changeset's `name` attribute.
Only runs on create actions. Returns the changeset unchanged if:
- The action is not :create
- The name is not being changed
- The name is nil or empty
## Parameters
- `changeset` - The Ash changeset
## Returns
The changeset with the `:slug` attribute set to the generated slug.
"""
def change(changeset, _opts, _context) do
# Only generate slug on create, not on update (immutability)
if changeset.action_type == :create do
case Ash.Changeset.get_attribute(changeset, :name) do
nil ->
changeset
name when is_binary(name) ->
slug = generate_slug(name)
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
end
else
# On update, don't touch the slug (immutable)
changeset
end
end
@doc """
Generates a URL-friendly slug from a given string.
Uses the `slugify` library to create a clean, lowercase slug with:
- Spaces replaced by hyphens
- Special characters removed
- UTF-8 characters transliterated (ä a, ß ss, etc.)
- Multiple consecutive hyphens reduced to single hyphen
- Leading/trailing hyphens removed
- Maximum length of 100 characters
## Examples
iex> generate_slug("Mobile Phone")
"mobile-phone"
iex> generate_slug("Café Müller")
"cafe-muller"
iex> generate_slug("TEST NAME")
"test-name"
iex> generate_slug("E-Mail & Address!")
"e-mail-address"
iex> generate_slug("Multiple Spaces")
"multiple-spaces"
iex> generate_slug("-Test-")
"test"
iex> generate_slug("Straße")
"strasse"
"""
def generate_slug(name) when is_binary(name) do
slug = Slug.slugify(name)
case slug do
nil -> ""
"" -> ""
slug when is_binary(slug) -> String.slice(slug, 0, 100)
end
end
def generate_slug(_), do: ""
end

View file

@ -1,110 +0,0 @@
defmodule Mv.Membership.CustomFieldValue do
@moduledoc """
Ash resource representing a custom field value for a member.
## Overview
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
dynamic custom fields to be attached to members. Each custom field value links a
member to a custom field and stores the actual value.
## Value Storage
Values are stored using Ash's union type with JSONB storage format:
```json
{
"type": "string",
"value": "example"
}
```
## Supported Types
- `:string` - Text data
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses (custom type)
## Relationships
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
- `belongs_to :custom_field` - The custom field definition (CASCADE delete)
## Constraints
- Each member can have only one custom field value per custom field (unique composite index)
- Custom field values are deleted when the associated member is deleted (CASCADE)
- Custom field values are deleted when the associated custom field is deleted (CASCADE)
- String values maximum length: 10,000 characters
- Email values maximum length: 254 characters (RFC 5321)
## Future Features
- Type-matching validation (value type must match custom field's value_type) - to be implemented
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "custom_field_values"
repo Mv.Repo
references do
reference :member, on_delete: :delete
reference :custom_field, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :custom_field_id]
read :by_custom_field_id do
argument :custom_field_id, :uuid, allow_nil?: false
filter expr(custom_field_id == ^arg(:custom_field_id))
end
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [
type: :boolean
],
date: [
type: :date
],
integer: [
type: :integer
],
string: [
type: :string,
constraints: [
max_length: 10_000,
trim?: true
]
],
email: [
type: Mv.Membership.Email
]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :custom_field, Mv.Membership.CustomField
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one custom field value per custom field
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
identities do
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
end
end

View file

@ -1,38 +1,4 @@
defmodule Mv.Membership.Email do
@moduledoc """
Custom Ash type for validated email addresses.
## Overview
This type extends `:string` with email-specific validation constraints.
It ensures that email values stored in CustomFieldValue resources are valid email
addresses according to a standard regex pattern.
## Validation Rules
- **Optional**: `nil` and empty strings are allowed (custom fields are optional)
- Minimum length: 5 characters (for non-empty values)
- Maximum length: 254 characters (RFC 5321 maximum)
- Pattern: Standard email format (username@domain.tld)
- Automatic trimming of leading/trailing whitespace (empty strings become `nil`)
## Usage
This type is used in the CustomFieldValue union type for custom fields with
`value_type: :email` in CustomField definitions.
## Example
# In a custom field definition
CustomField.create!(%{
name: "work_email",
value_type: :email
})
# Valid values
"user@example.com"
"first.last@company.co.uk"
# Invalid values
"not-an-email" # Missing @ and domain
"a@b" # Too short
"""
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
@match_regex Regex.compile!(@match_pattern)
@min_length 5
@ -47,18 +13,11 @@ defmodule Mv.Membership.Email do
max_length: @max_length
]
@impl true
def cast_input(nil, _), do: {:ok, nil}
@impl true
def cast_input(value, _) when is_binary(value) do
value = String.trim(value)
cond do
# Empty string after trim becomes nil (optional field)
value == "" ->
{:ok, nil}
String.length(value) < @min_length ->
:error

View file

@ -1,51 +1,8 @@
defmodule Mv.Membership.Member do
@moduledoc """
Ash resource representing a club member.
## Overview
Members are the core entity in the membership management system. Each member
can have:
- Personal information (name, email, phone, address)
- Optional link to a User account (1:1 relationship)
- Dynamic custom field values via CustomField system
- Full-text searchable profile
## Email Synchronization
When a member is linked to a user account, emails are automatically synchronized
bidirectionally. User.email is the source of truth on initial link.
See `Mv.EmailSync` for details.
## Relationships
- `has_many :custom_field_values` - Dynamic custom fields
- `has_one :user` - Optional authentication account link
## Validations
- Required: first_name, last_name, email
- 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
## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, and contact fields.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
import Ash.Expr
# Module constants
@member_search_limit 10
@default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields
# This ensures consistency across the codebase
@member_fields Mv.Constants.member_fields()
postgres do
table "members"
repo Mv.Repo
@ -56,15 +13,29 @@ defmodule Mv.Membership.Member do
create :create_member do
primary? true
# Custom field values can be created along with member
argument :custom_field_values, {:array, :map}
# Properties can be created along with member
argument :properties, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept @member_fields
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, type: :create)
change manage_relationship(:properties, type: :create)
# Manage the user relationship during member creation
change manage_relationship(:user, :user,
@ -77,27 +48,35 @@ defmodule Mv.Membership.Member do
# If no user provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
update :update_member do
primary? true
# Required because custom validation function cannot be done atomically
require_atomic? false
# Custom field values can be updated or created along with member
argument :custom_field_values, {:array, :map}
# Properties can be updated or created along with member
argument :properties, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept @member_fields
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
change manage_relationship(:properties, on_match: :update, on_no_match: :create)
# Manage the user relationship during member update
change manage_relationship(:user, :user,
@ -110,134 +89,9 @@ defmodule Mv.Membership.Member do
# If no user provided, remove existing relationship (allows user removal)
on_missing: :unrelate
)
# Sync member email to user when email changes (Member → User)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
where [changing(:email)]
end
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
argument :similarity_threshold, :float, allow_nil?: true
prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || ""
# Use default similarity threshold if not provided
# Lower value leads to more results but also more unspecific results
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
pat = "%" <> q2 <> "%"
# FTS as main filter and fuzzy search just for first name, last name and strees
query
|> Ash.Query.filter(
expr(
# Substring on numeric-like fields (best effort, supports middle substrings)
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
contains(postal_code, ^q2) or
contains(house_number, ^q2) or
contains(phone_number, ^q2) or
contains(email, ^q2) or
contains(city, ^q2) or ilike(city, ^pat) or
fragment("? % first_name", ^q2) or
fragment("? % last_name", ^q2) or
fragment("? % street", ^q2) or
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
)
)
else
query
end
end
end
# Action to find members available for linking to a user account
# Returns only unlinked members (user_id == nil), limited to 10 results
#
# Filtering behavior:
# - If search_query provided: fuzzy search on names and email
# - If no search_query: return all unlinked members (up to limit)
# - user_email should be handled by caller with filter_by_email_match/2
read :available_for_linking do
argument :user_email, :string, allow_nil?: true
argument :search_query, :string, allow_nil?: true
prepare fn query, _ctx ->
user_email = Ash.Query.get_argument(query, :user_email)
search_query = Ash.Query.get_argument(query, :search_query)
query
|> Ash.Query.filter(is_nil(user))
|> apply_linking_filters(user_email, search_query)
|> Ash.Query.limit(@member_search_limit)
end
end
end
@doc """
Filters members list based on email match priority.
Priority logic:
1. If email matches a member: return ONLY that member (highest priority)
2. If email doesn't match: return all members (for display in dropdown)
This is used with :available_for_linking action to implement email-priority behavior:
- user_email matches Only this member
- user_email does NOT match + NO search_query All unlinked members
- user_email does NOT match + search_query provided search_query filtered members
## Parameters
- `members` - List of Member structs (from :available_for_linking action)
- `user_email` - Email string to match against member emails
## Returns
- List of Member structs (either single email match or all members)
## Examples
iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
iex> filter_by_email_match(members, "test@example.com")
[%Member{email: "test@example.com"}]
iex> filter_by_email_match(members, "nomatch@example.com")
[%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
"""
@spec filter_by_email_match([t()], String.t()) :: [t()]
def filter_by_email_match(members, user_email)
when is_list(members) and is_binary(user_email) do
email_match = Enum.find(members, &(&1.email == user_email))
if email_match do
# Email match found - return only this member (highest priority)
[email_match]
else
# No email match - return all members unchanged
members
end
end
@spec filter_by_email_match(any(), any()) :: any()
def filter_by_email_match(members, _user_email), do: members
validations do
# Required fields are covered by allow_nil? false
@ -246,10 +100,6 @@ defmodule Mv.Membership.Member do
validate present(:last_name)
validate present(:email)
# Email uniqueness check for all actions that change the email attribute
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member
@ -284,6 +134,11 @@ defmodule Mv.Membership.Member do
end
end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)],
@ -334,18 +189,15 @@ defmodule Mv.Membership.Member do
constraints min_length: 1
end
# IMPORTANT: Email Synchronization
# When member and user are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :string do
allow_nil? false
constraints min_length: 5, max_length: 254
end
attribute :birth_date, :date do
allow_nil? true
end
attribute :paid, :boolean do
allow_nil? true
end
@ -389,7 +241,7 @@ defmodule Mv.Membership.Member do
end
relationships do
has_many :custom_field_values, Mv.Membership.CustomFieldValue
has_many :properties, Mv.Membership.Property
# 1:1 relationship - Member can optionally have one User
# This references the User's member_id attribute
# The relationship is optional (allow_nil? true by default)
@ -400,102 +252,4 @@ defmodule Mv.Membership.Member do
identities do
identity :unique_email, [:email]
end
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.
Wraps the `:search` action with convenient opts-based argument passing.
Searches across first_name, last_name, email, and other text fields using
full-text search combined with trigram similarity.
## Parameters
- `query` - Ash.Query.t() to apply search to
- `opts` - Keyword list or map with search options:
- `:query` or `"query"` - Search string
- `:fields` or `"fields"` - Optional field restrictions
## Returns
- Modified Ash.Query.t() with search filters applied
## Examples
iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!()
[%Member{first_name: "Greta", ...}]
iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant
[%Member{first_name: "Greta", ...}]
"""
@spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t()
def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string()
if String.trim(q) == "" do
query
else
args =
case opts[:fields] || opts["fields"] do
nil -> %{query: q}
fields -> %{query: q, fields: fields}
end
Ash.Query.for_read(query, :search, args)
end
end
# Private helper to apply filters for :available_for_linking action
# user_email: may be nil/empty when creating new user, or populated when editing
# search_query: optional search term for fuzzy matching
#
# Logic: (email == user_email) OR (fuzzy_search on search_query)
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
# - This allows a single filter expression instead of duplicating fuzzy search logic
#
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp apply_linking_filters(query, user_email, search_query) do
has_search = search_query && String.trim(search_query) != ""
# Use empty string instead of nil to simplify filter logic
trimmed_email = if user_email, do: String.trim(user_email), else: ""
if has_search do
# Search query provided: return email-match OR fuzzy-search candidates
trimmed_search = String.trim(search_query)
query
|> Ash.Query.filter(
expr(
# Email match candidate (for filter_by_email_match priority)
# If email is "", this is always false and fuzzy search takes over
# Fuzzy search candidates
email == ^trimmed_email or
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
fragment("? % first_name", ^trimmed_search) or
fragment("? % last_name", ^trimmed_search) or
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
fragment(
"word_similarity(?, last_name) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(first_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(last_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
contains(email, ^trimmed_search)
)
)
else
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
query
end
end
end

View file

@ -1,23 +1,4 @@
defmodule Mv.Membership do
@moduledoc """
Ash Domain for membership management.
## Resources
- `Member` - Club members with personal information and custom field values
- `CustomFieldValue` - Dynamic custom field values attached to members
- `CustomField` - Schema definitions for custom fields
- `Setting` - Global application settings (singleton)
## Public API
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`
## Admin Interface
The domain is configured with AshAdmin for management UI.
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
@ -33,128 +14,18 @@ defmodule Mv.Membership do
define :destroy_member, action: :destroy
end
resource Mv.Membership.CustomFieldValue do
define :create_custom_field_value, action: :create
define :list_custom_field_values, action: :read
define :update_custom_field_value, action: :update
define :destroy_custom_field_value, action: :destroy
resource Mv.Membership.Property do
define :create_property, action: :create
define :list_property, action: :read
define :update_property, action: :update
define :destroy_property, action: :destroy
end
resource Mv.Membership.CustomField do
define :create_custom_field, action: :create
define :list_custom_fields, action: :read
define :update_custom_field, action: :update
define :destroy_custom_field, action: :destroy_with_values
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
end
resource Mv.Membership.Setting do
# Note: create action exists but is not exposed via code interface
# It's only used internally as fallback in get_settings/0
# Settings should be created via seed script
define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
resource Mv.Membership.PropertyType do
define :create_property_type, action: :create
define :list_property_types, action: :read
define :update_property_type, action: :update
define :destroy_property_type, action: :destroy
end
end
# Singleton pattern: Get the single settings record
@doc """
Gets the global settings.
Settings should normally be created via the seed script (`priv/repo/seeds.exs`).
If no settings exist, this function will create them as a fallback using the
`ASSOCIATION_NAME` environment variable or "Club Name" as default.
## Returns
- `{:ok, settings}` - The settings record
- `{:ok, nil}` - No settings exist (should not happen if seeds were run)
- `{:error, error}` - Error reading settings
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> settings.club_name
"My Club"
"""
def get_settings do
# Try to get the first (and only) settings record
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} ->
# No settings exist - create as fallback (should normally be created via seed script)
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|> Ash.create!(domain: __MODULE__)
|> then(fn settings -> {:ok, settings} end)
{:ok, settings} ->
{:ok, settings}
{:error, error} ->
{:error, error}
end
end
@doc """
Updates the global settings.
## Parameters
- `settings` - The settings record to update
- `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`)
## 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_settings(settings, %{club_name: "New Club"})
iex> updated.club_name
"New Club"
"""
def update_settings(settings, attrs) do
settings
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
@doc """
Updates the member field visibility configuration.
This is a specialized action for updating only the member field visibility settings.
It validates that all keys are valid member fields and all values are booleans.
## Parameters
- `settings` - The settings record to update
- `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{"street" => false, "house_number" => false}`)
## 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_member_field_visibility(settings, %{"street" => false, "house_number" => false})
iex> updated.member_field_visibility
%{"street" => false, "house_number" => false}
"""
def update_member_field_visibility(settings, visibility_config) do
settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
end
end

View file

@ -0,0 +1,51 @@
defmodule Mv.Membership.Property do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "properties"
repo Mv.Repo
references do
reference :member, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :property_type_id]
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [type: :boolean],
date: [type: :date],
integer: [type: :integer],
string: [type: :string],
email: [type: Mv.Membership.Email]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :property_type, Mv.Membership.PropertyType
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one property per property type
# For example: A member can have only one "email" property, one "phone" property, etc.
identities do
identity :unique_property_per_member, [:member_id, :property_type_id]
end
end

View file

@ -0,0 +1,44 @@
defmodule Mv.Membership.PropertyType do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "property_types"
repo Mv.Repo
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:name, :value_type, :description, :immutable, :required]
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `Property.value` is interpreted as"
attribute :description, :string, allow_nil?: true, public?: true
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false
end
relationships do
has_many :properties, Mv.Membership.Property
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -1,138 +0,0 @@
defmodule Mv.Membership.Setting do
@moduledoc """
Ash resource representing global application settings.
## Overview
Settings is a singleton resource that stores global configuration for the association,
such as the club name and branding information. There should only ever be one settings
record in the database.
## Attributes
- `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
The resource is designed to be read and updated, but not created or destroyed
through normal CRUD operations. Initial settings should be seeded.
## Environment Variable Support
The `club_name` can be set via the `ASSOCIATION_NAME` environment variable.
If set, the environment variable value is used as a fallback when no database
value exists. Database values always take precedence over environment variables.
## Examples
# Get current settings
{:ok, settings} = Mv.Membership.get_settings()
settings.club_name # => "My Club"
# Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
# Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "settings"
repo Mv.Repo
end
resource do
description "Global application settings (singleton resource)"
end
actions do
defaults [:read]
# Internal create action - not exposed via code interface
# Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script
create :create do
accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
require_atomic? false
accept [:club_name, :member_field_visibility]
end
update :update_member_field_visibility do
description "Updates the visibility configuration for member fields in the overview"
require_atomic? false
accept [:member_field_visibility]
end
end
validations do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
# Validate member_field_visibility map structure and content
validate fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
# Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value)
end)
# Validate all keys are valid member fields
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
end
attributes do
uuid_primary_key :id
attribute :club_name, :string,
allow_nil?: false,
public?: true,
description: "The name of the association/club",
constraints: [
trim?: true,
min_length: 1
]
attribute :member_field_visibility, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
timestamps()
end
end

View file

@ -10,20 +10,6 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
alias Mv.Mailer
@doc """
Sends a confirmation email to a new user.
This function is called automatically by AshAuthentication when a new
user registers and needs to confirm their email address.
## Parameters
- `user` - The user record who needs to confirm their email
- `token` - The confirmation token to include in the email link
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
new()

View file

@ -10,20 +10,6 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
alias Mv.Mailer
@doc """
Sends a password reset email to a user.
This function is called automatically by AshAuthentication when a user
requests a password reset.
## Parameters
- `user` - The user record requesting the password reset
- `token` - The password reset token to include in the email link
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
new()

View file

@ -1,95 +0,0 @@
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
@moduledoc """
Validates that the user's email is not already used by another member.
Only validates when:
- User is already linked to a member (member_id != nil) AND email is changing
- User is being linked to a member (member relationship is changing)
This allows creating users with the same email as unlinked members.
"""
use Ash.Resource.Validation
@doc """
Validates email uniqueness across linked User-Member pairs.
This validation ensures that when a user is linked to a member, their email
does not conflict with another member's email. It only runs when necessary
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
## Parameters
- `changeset` - The Ash changeset being validated
- `_opts` - Options passed to the validation (unused)
- `_context` - Ash context map (unused)
## Returns
- `:ok` if validation passes or should be skipped
- `{:error, field: :email, message: ..., value: ...}` if validation fails
"""
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
is_linked? = not is_nil(member_id)
# Only validate if:
# 1. User is linked AND email is changing
# 2. User is being linked/unlinked (member relationship changing)
should_validate? = (is_linked? and email_changing?) or member_changing?
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(new_email, member_id_to_exclude)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
# Extract member_id from relationship changes for new links
member_id_to_exclude = get_member_id_from_changeset(changeset)
check_email_uniqueness(current_email, member_id_to_exclude)
end
else
:ok
end
end
# Extract member_id from changeset, checking relationship changes first
# This is crucial for new links where member_id is in manage_relationship changes
defp get_member_id_from_changeset(changeset) do
# Try to get from relationships (for new links via manage_relationship)
case Map.get(changeset.relationships, :member) do
[{[%{id: id}], _opts}] when not is_nil(id) ->
# Found in relationships - this is a new link
id
_ ->
# Fall back to attribute (for existing links)
Ash.Changeset.get_attribute(changeset, :member_id)
end
end
defp check_email_uniqueness(email, exclude_member_id) do
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -1,22 +0,0 @@
defmodule Mv.Constants do
@moduledoc """
Module for defining constants and atoms.
"""
@member_fields [
:first_name,
:last_name,
:email,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
def member_fields, do: @member_fields
end

View file

@ -1,52 +0,0 @@
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
@moduledoc """
Synchronizes Member.email User.email
Trigger conditions are configured in resources via `where` clauses:
- Member resource: Use `where: [changing(:email)]`
Used by Member resource for bidirectional email sync.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@doc """
Implements the email synchronization from Member to User.
This function is called automatically by Ash when the configured trigger
conditions are met (see `@moduledoc` for trigger details).
## Parameters
- `changeset` - The Ash changeset being processed
- `_opts` - Options passed to the change (unused)
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
## Returns
Modified changeset with email synchronization applied, or original changeset
if recursion detected.
"""
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
new_email = Ash.Changeset.get_attribute(changeset, :email)
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result
end
end)
end
end

View file

@ -1,77 +0,0 @@
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
@moduledoc """
Synchronizes User.email Member.email
User.email is always the source of truth.
Trigger conditions are configured in resources via `where` clauses:
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
- Member resource: Use `where: [changing(:user)]`
Can be used by both User and Member resources.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@doc """
Implements the email synchronization from User to Member.
This function is called automatically by Ash when the configured trigger
conditions are met (see `@moduledoc` for trigger details).
## Parameters
- `changeset` - The Ash changeset being processed
- `_opts` - Options passed to the change (unused)
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
## Returns
Modified changeset with email synchronization applied, or original changeset
if recursion detected.
"""
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
%Mv.Membership.Member{} ->
# Member-side: Override member email in result with user email
Helpers.override_with_linked_email(result, user.email)
%Mv.Accounts.User{} ->
# User-side: Sync user email to linked member in DB
Helpers.sync_email_to_linked_record(result, member, user.email)
end
else
_ -> result
end
end)
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end
end
end

View file

@ -1,93 +0,0 @@
defmodule Mv.EmailSync.Helpers do
@moduledoc """
Shared helper functions for email synchronization between User and Member.
Handles the complexity of `around_transaction` callback results and
provides clean abstractions for email updates within transactions.
"""
require Logger
import Ecto.Changeset
@doc """
Extracts the record from an Ash action result.
Handles both 2-tuple `{:ok, record}` and 4-tuple
`{:ok, record, changeset, notifications}` patterns.
"""
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
def extract_record({:ok, record}), do: {:ok, record}
def extract_record({:error, _} = error), do: error
@doc """
Updates the result with a new record while preserving the original structure.
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
If it was a 2-tuple, returns a 2-tuple with the updated record.
"""
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
{:ok, new_record, changeset, notifications}
end
def update_result_record({:ok, _old_record}, new_record) do
{:ok, new_record}
end
@doc """
Updates an email field directly via Ecto within the current transaction.
This bypasses Ash's action system to ensure the update happens in the
same database transaction as the parent action.
"""
def update_email_via_ecto(record, new_email) do
record
|> cast(%{email: to_string(new_email)}, [:email])
|> Mv.Repo.update()
end
@doc """
Synchronizes email to a linked record if it exists.
Returns the original result unchanged, or an error if sync fails.
"""
def sync_email_to_linked_record(result, linked_record, new_email) do
with {:ok, _source} <- extract_record(result),
record when not is_nil(record) <- linked_record,
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
# Successfully synced - return original result unchanged
result
else
nil ->
# No linked record - return original result
result
{:error, error} ->
# Sync failed - log and propagate error to rollback transaction
Logger.error("Email sync failed: #{inspect(error)}")
{:error, error}
end
end
@doc """
Overrides the record's email with the linked email if emails differ.
Returns updated result with new record, or original result if no update needed.
"""
def override_with_linked_email(result, linked_email) do
with {:ok, record} <- extract_record(result),
true <- record.email != to_string(linked_email),
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
# Email was different - return result with updated record
update_result_record(result, updated_record)
else
false ->
# Emails already match - no update needed
result
{:error, error} ->
# Override failed - log and propagate error
Logger.error("Email override failed: #{inspect(error)}")
{:error, error}
end
end
end

View file

@ -1,40 +0,0 @@
defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
"""
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do
case Ash.get(Mv.Membership.Member, id) do
{:ok, member} -> member
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error
end
end
end

View file

@ -1,74 +0,0 @@
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
@moduledoc """
Validates that the member's email is not already used by another user.
Only validates when:
- Member is already linked to a user (user != nil) AND email is changing
- Member is being linked to a user (user relationship is changing)
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
@doc """
Validates email uniqueness across linked Member-User pairs.
This validation ensures that when a member is linked to a user, their email
does not conflict with another user's email. It only runs when necessary
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
## Parameters
- `changeset` - The Ash changeset being validated
- `_opts` - Options passed to the validation (unused)
- `_context` - Ash context map (unused)
## Returns
- `:ok` if validation passes or should be skipped
- `{:error, field: :email, message: ..., value: ...}` if validation fails
"""
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
# Do NOT validate when member is being linked (email will be overridden from user)
should_validate? = is_linked? and email_changing?
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
case Ash.load(member_data, :user) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end
end
end

View file

@ -5,7 +5,7 @@ defmodule Mv.Repo do
@impl true
def installed_extensions do
# Add extensions here, and the migration generator will install them.
["ash-functions", "citext", "pg_trgm"]
["ash-functions", "citext"]
end
# Don't open unnecessary transactions

View file

@ -1,23 +1,4 @@
defmodule Mv.Secrets do
@moduledoc """
Secret provider for AshAuthentication.
## Purpose
Provides runtime configuration secrets for Ash Authentication strategies,
particularly for OIDC (Rauthy) authentication.
## Configuration Source
Secrets are read from the `:rauthy` key in the application configuration,
which is typically set in `config/runtime.exs` from environment variables:
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
- `OIDC_BASE_URL`
- `OIDC_REDIRECT_URI`
## Usage
This module is automatically called by AshAuthentication when resolving
secrets for the User resource's OIDC strategy.
"""
use AshAuthentication.Secret
def secret_for(

View file

@ -1,16 +1,4 @@
defmodule MvWeb.AuthOverrides do
@moduledoc """
UI customizations for AshAuthentication Phoenix components.
## Overrides
- `SignIn` - Restricts form width to prevent full-width display
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- `HorizontalRule` - Translates "or" text to German
## Documentation
For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
"""
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext

View file

@ -42,11 +42,7 @@ defmodule MvWeb.CoreComponents do
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom,
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -60,20 +56,16 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="z-50 toast toast-top toast-end"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
@ -188,7 +180,7 @@ defmodule MvWeb.CoreComponents do
end)
~H"""
<fieldset class="mb-2 fieldset">
<fieldset class="fieldset mb-2">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
@ -200,11 +192,7 @@ defmodule MvWeb.CoreComponents do
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
@ -214,15 +202,9 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<fieldset class="mb-2 fieldset">
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
@ -241,15 +223,9 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<fieldset class="mb-2 fieldset">
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
@ -268,15 +244,9 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<fieldset class="mb-2 fieldset">
<fieldset class="fieldset mb-2">
<label>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
@ -348,13 +318,6 @@ defmodule MvWeb.CoreComponents do
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
attr :dynamic_cols, :list,
default: [],
doc: "list of dynamic column definitions with :custom_field and :render functions"
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
slot :col, required: true do
attr :label, :string
end
@ -372,16 +335,6 @@ defmodule MvWeb.CoreComponents do
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :for={dyn_col <- @dynamic_cols}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
field={"custom_field_#{dyn_col[:custom_field].id}"}
label={dyn_col[:custom_field].name}
sort_field={@sort_field}
sort_order={@sort_order}
/>
</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
@ -396,23 +349,6 @@ defmodule MvWeb.CoreComponents do
>
{render_slot(col, @row_item.(row))}
</td>
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
if rendered == "" do
""
else
rendered
end
else
""
end}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
@ -547,7 +483,7 @@ defmodule MvWeb.CoreComponents do
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd>
</div>
</dl>

View file

@ -65,9 +65,7 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />

View file

@ -6,24 +6,17 @@ defmodule MvWeb.Layouts.Navbar do
use Gettext, backend: MvWeb.Gettext
use MvWeb, :verified_routes
alias Mv.Membership
attr :current_user, :map,
required: true,
doc: "The current user - navbar is only shown when user is present"
def navbar(assigns) do
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost text-xl">{@club_name}</a>
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
</ul>
</div>
@ -95,9 +88,7 @@ defmodule MvWeb.Layouts.Navbar do
{gettext("Profil")}
</.link>
</li>
<li>
<.link navigate={~p"/settings"}>{gettext("Settings")}</.link>
</li>
<li><a>{gettext("Settings")}</a></li>
<li>
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
</li>
@ -107,13 +98,4 @@ defmodule MvWeb.Layouts.Navbar do
</header>
"""
end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
end
end

View file

@ -1,21 +1,9 @@
require Logger
defmodule MvWeb.AuthController do
@moduledoc """
Handles authentication callbacks for password and OIDC authentication.
This controller manages:
- Successful authentication (password, OIDC, password reset, email confirmation)
- Authentication failures with appropriate error handling
- OIDC account linking flow when email collision occurs
- Sign out functionality
"""
use MvWeb, :controller
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
def success(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
@ -35,144 +23,26 @@ defmodule MvWeb.AuthController do
|> redirect(to: return_to)
end
@doc """
Handles authentication failures and routes to appropriate error handling.
Manages:
- OIDC email collisions (triggers password verification flow)
- Generic OIDC authentication failures
- Unconfirmed account errors
- Generic authentication failures
"""
def failure(conn, activity, reason) do
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
Logger.error(%{conn: conn, reason: reason})
case {activity, reason} do
{{:rauthy, _action}, reason} ->
handle_rauthy_failure(conn, reason)
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
handle_authentication_failed(conn, caused_by)
_ ->
redirect_with_error(conn, gettext("Incorrect email or password"))
end
end
# Handle all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors)
end
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
caused_by: caused_by
}) do
case caused_by do
%Ash.Error.Invalid{errors: errors} ->
handle_oidc_email_collision(conn, errors)
_ ->
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
end
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
message =
case {activity, reason} do
{_,
%AshAuthentication.Errors.AuthenticationFailed{
caused_by: %Ash.Error.Forbidden{
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
}
}} ->
gettext("""
You have already signed in another way, but have not confirmed your account.
You can confirm your account using the link we sent to you, or by resetting your password.
""")
redirect_with_error(conn, message)
else
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
end
defp handle_authentication_failed(conn, _other) do
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
# Handle OIDC email collision - user needs to verify password to link accounts
defp handle_oidc_email_collision(conn, errors) do
case find_password_verification_error(errors) do
%PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} ->
redirect_to_account_linking(conn, user_id, oidc_user_info)
nil ->
# Check if it's a "different OIDC account" error or email uniqueness error
error_message = extract_meaningful_error_message(errors)
redirect_with_error(conn, error_message)
end
end
# Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors
meaningful_error =
Enum.find_value(errors, fn
%Ash.Error.Changes.InvalidAttribute{message: message, field: :email}
when is_binary(message) ->
cond do
# Email update conflict during OIDC login
String.contains?(message, "Cannot update email to") and
String.contains?(message, "already registered to another account") ->
gettext(
"Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
)
# Different OIDC account error
String.contains?(message, "already linked to a different OIDC account") ->
gettext(
"This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
)
true ->
nil
end
%Ash.Error.Changes.InvalidAttribute{message: message}
when is_binary(message) ->
# Return any other meaningful message
if String.length(message) > 20 and
not String.contains?(message, "has already been taken") do
message
else
nil
end
_ ->
nil
end)
meaningful_error || gettext("Unable to sign in. Please try again.")
gettext("Incorrect email or password")
end
# Find PasswordVerificationRequired error in error list
defp find_password_verification_error(errors) do
Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1))
end
# Redirect to account linking page with OIDC info stored in session
defp redirect_to_account_linking(conn, user_id, oidc_user_info) do
conn
|> put_session(:oidc_linking_user_id, user_id)
|> put_session(:oidc_linking_user_info, oidc_user_info)
|> put_flash(
:info,
gettext(
"An account with this email already exists. Please verify your password to link your OIDC account."
)
)
|> redirect(to: ~p"/auth/link-oidc-account")
end
# Generic error redirect helper
defp redirect_with_error(conn, message) do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")

View file

@ -1,296 +0,0 @@
defmodule MvWeb.LinkOidcAccountLive do
@moduledoc """
LiveView for linking an OIDC account to an existing password account.
This page is shown when a user tries to log in via OIDC using an email
that already exists with a password-only account. The user must verify
their password before the OIDC account can be linked.
## Flow
1. User attempts OIDC login with email that has existing password account
2. System raises `PasswordVerificationRequired` error
3. AuthController redirects here with user_id and oidc_user_info in session
4. User enters password to verify identity
5. On success, oidc_id is linked to user account
6. User is redirected to complete OIDC login
"""
use MvWeb, :live_view
require Ash.Query
require Logger
@impl true
def mount(_params, session, socket) do
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
{:ok, auto_link_passwordless_user(socket, user, oidc_user_info)}
else
# Show password form for password-protected user
{:ok, initialize_socket(socket, user, oidc_user_info)}
end
else
nil ->
{:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))}
{:error, _} ->
{:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))}
end
end
defp passwordless?(user) do
is_nil(user.hashed_password)
end
defp reload_user!(user_id) do
Mv.Accounts.User
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!()
end
defp reset_password_form(socket) do
assign(socket, :form, to_form(%{"password" => ""}))
end
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
{:ok, updated_user} ->
Logger.info(
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
)
socket
|> put_flash(
:info,
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
{:error, error} ->
Logger.warning(
"Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}"
)
error_message = extract_user_friendly_error(error)
socket
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
end
end
defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do
# Check for specific error types
Enum.find_value(errors, fn
%Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} ->
if String.contains?(message, "already been taken") do
dgettext(
"auth",
"This OIDC account is already linked to another user. Please contact support."
)
else
nil
end
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
if String.contains?(message, "already been taken") do
dgettext(
"auth",
"The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
)
else
nil
end
_ ->
nil
end) ||
dgettext("auth", "Failed to link account. Please try again or contact support.")
end
defp extract_user_friendly_error(_error) do
dgettext("auth", "Failed to link account. Please try again or contact support.")
end
defp initialize_socket(socket, user, oidc_user_info) do
socket
|> assign(:user, user)
|> assign(:oidc_user_info, oidc_user_info)
|> assign(:password, "")
|> assign(:error, nil)
|> reset_password_form()
end
defp redirect_with_error(socket, message) do
socket
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
end
@impl true
def handle_event("validate", %{"password" => password}, socket) do
{:noreply, assign(socket, :password, password)}
end
@impl true
def handle_event("submit", %{"password" => password}, socket) do
user = socket.assigns.user
oidc_user_info = socket.assigns.oidc_user_info
# Verify the password using AshAuthentication
case verify_password(user.email, password) do
{:ok, verified_user} ->
# Password correct - link the OIDC account
link_oidc_account(socket, verified_user, oidc_user_info)
{:error, _reason} ->
# Password incorrect - log security event
Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}")
{:noreply,
socket
|> assign(:error, dgettext("auth", "Incorrect password. Please try again."))
|> reset_password_form()}
end
end
defp verify_password(email, password) do
# Use AshAuthentication password strategy to verify
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
if password_strategy do
AshAuthentication.Strategy.Password.Actions.sign_in(
password_strategy,
%{
"email" => email,
"password" => password
},
[]
)
else
{:error, "Password authentication not configured"}
end
end
defp link_oidc_account(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Update the user with the OIDC ID
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
{:ok, updated_user} ->
# After successful linking, redirect to OIDC login
# Since the user now has an oidc_id, the next OIDC login will succeed
Logger.info(
"OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
)
{:noreply,
socket
|> put_flash(
:info,
dgettext(
"auth",
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
)
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
{:error, error} ->
Logger.warning(
"Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}"
)
error_message = extract_user_friendly_error(error)
{:noreply,
socket
|> assign(:error, error_message)
|> reset_password_form()}
end
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm mt-16">
<%!-- Language Selector --%>
<nav aria-label={dgettext("auth", "Language selection")} class="flex justify-center mb-4">
<form method="post" action="/set_locale" class="text-sm">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm select-bordered"
aria-label={dgettext("auth", "Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>🇩🇪 Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>🇬🇧 English</option>
</select>
</form>
</nav>
<main>
<.header class="text-center">
{dgettext("auth", "Link OIDC Account")}
<:subtitle>
{dgettext(
"auth",
"An account with email %{email} already exists. Please enter your password to link your OIDC account.",
email: @user.email
)}
</:subtitle>
</.header>
<.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
<div class="space-y-6">
<div>
<.input
field={@form[:password]}
type="password"
label={dgettext("auth", "Password")}
required
/>
</div>
<%= if @error do %>
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{@error}</p>
</div>
<% end %>
<div>
<.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full">
{dgettext("auth", "Link Account")}
</.button>
</div>
</div>
</.form>
<div class="mt-4 text-center text-sm">
<.link navigate={~p"/sign-in"} class="text-brand hover:underline">
{dgettext("auth", "Cancel")}
</.link>
</div>
</main>
</div>
"""
end
end

View file

@ -1,146 +0,0 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do
use MvWeb, :live_component
@impl true
def update(%{query: query}, socket) do
def update(_assigns, socket) do
socket =
socket
|> assign_new(:query, fn -> query || "" end)
|> assign_new(:query, fn -> "" end)
|> assign_new(:placeholder, fn -> gettext("Search...") end)
{:ok, socket}
@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do
@impl true
def render(assigns) do
~H"""
<form phx-submit="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<label class="input">
<svg
class="h-[1em] opacity-50"
@ -44,9 +44,6 @@ defmodule MvWeb.Components.SearchBarComponent do
placeholder={@placeholder}
value={@query}
name="query"
data-testid="search-input"
phx-change="search"
phx-target={@myself}
phx-debounce="300"
/>
</label>

View file

@ -1,64 +0,0 @@
defmodule MvWeb.Components.SortHeaderComponent do
@moduledoc """
Sort Header that can be used as column header and sorts a table:
Props:
- field: atom() # Ash Field for sorting
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="btn btn-ghost select-none"
phx-click="sort"
phx-value-field={@field}
phx-target={@myself}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</button>
</div>
"""
end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
send(self(), {:sort, field_str})
{:noreply, socket}
end
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
end

View file

@ -1,142 +0,0 @@
defmodule MvWeb.CustomFieldLive.Form do
@moduledoc """
LiveView form for creating and editing custom fields (admin).
## Features
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Form Fields
**Required:**
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
- value_type - Data type (:string, :integer, :boolean, :date, :email)
**Optional:**
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this custom field (default: false)
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
## Value Type Selection
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field)
## Security
Custom field management is restricted to admin users.
"""
use MvWeb, :live_view
@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 custom_field records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
</.button>
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
custom_field =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomField, id)
end
action = if is_nil(custom_field), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field: custom_field)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
notify_parent({:saved, custom_field})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field), do: ~p"/custom_fields"
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
end

View file

@ -1,199 +0,0 @@
defmodule MvWeb.CustomFieldLive.Index do
@moduledoc """
LiveView for managing custom field definitions (admin).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
## Displayed Information
- Name: Unique identifier for the custom field
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether custom field values can be changed after creation
- Required: Whether all members must have this custom field (future feature)
## Events
- `prepare_delete` - Opens deletion confirmation modal with member count
- `confirm_delete` - Executes deletion after slug verification
- `cancel_delete` - Cancels deletion and closes modal
- `update_slug_confirmation` - Updates slug input state
## Security
Custom field management is restricted to admin users.
Deletion requires entering the custom field's slug to prevent accidental deletions.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom fields
<:actions>
<.button variant="primary" navigate={~p"/custom_fields/new"}>
<.icon name="hero-plus" /> New Custom field
</.button>
</:actions>
</.header>
<.table
id="custom_fields"
rows={@streams.custom_fields}
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
<:action :let={{_id, custom_field}}>
<div class="sr-only">
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
</div>
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
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="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="h-5 w-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="text-sm mt-2">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation">
<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="input input-bordered w-full"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom fields")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
# Delete the custom field (CASCADE will handle custom field values)
case Ash.destroy(custom_field) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Custom field deleted successfully")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
end
else
{:noreply,
socket
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end

View file

@ -1,75 +0,0 @@
defmodule MvWeb.CustomFieldLive.Show do
@moduledoc """
LiveView for displaying a single custom field's details (admin).
## Features
- Display custom field definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to custom field list
## Displayed Information
- ID: Internal UUID identifier
- Slug: URL-friendly identifier (auto-generated, immutable)
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this custom field
## Navigation
- Back to custom field list
- Edit custom field
## Security
Custom field details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field {@custom_field.slug}
<:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_fields"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field.id}</:item>
<:item title="Slug">
{@custom_field.slug}
<p class="mt-2 text-sm leading-6 text-zinc-600">
{gettext("Auto-generated identifier (immutable)")}
</p>
</:item>
<:item title="Name">{@custom_field.name}</:item>
<:item title="Description">{@custom_field.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Custom field")
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
end
end

View file

@ -1,86 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Index do
@moduledoc """
LiveView for displaying and managing custom field values.
## Features
- List all custom field values with their values and types
- Show which member each custom field value belongs to
- Display custom field information
- Navigate to custom field value details and edit forms
- Delete custom field values
## Relationships
Each custom field value is linked to:
- A member (the custom field value owner)
- A custom field (defining value type and behavior)
## Events
- `delete` - Remove a custom field value from the database
## Note
Custom field values are typically managed through the member edit form.
This view provides a global overview of all custom field values.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom field values
<:actions>
<.button variant="primary" navigate={~p"/custom_field_values/new"}>
<.icon name="hero-plus" /> New Custom field value
</.button>
</:actions>
</.header>
<.table
id="custom_field_values"
rows={@streams.custom_field_values}
row_click={
fn {_id, custom_field_value} ->
JS.navigate(~p"/custom_field_values/#{custom_field_value}")
end
}
>
<:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id}</:col>
<:action :let={{_id, custom_field_value}}>
<div class="sr-only">
<.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show</.link>
</div>
<.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit</.link>
</:action>
<:action :let={{id, custom_field_value}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
Ash.destroy!(custom_field_value)
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
end
end

View file

@ -1,67 +0,0 @@
defmodule MvWeb.CustomFieldValueLive.Show do
@moduledoc """
LiveView for displaying a single custom field value's details.
## Features
- Display custom field value and type
- Show linked member
- Show custom field definition
- Navigate to edit form
- Return to custom field value list
## Displayed Information
- Custom field value (formatted based on type)
- Custom field name and description
- Member information (who owns this custom field value)
- Custom field value metadata (ID, timestamps if added)
## Navigation
- Back to custom field value list
- Edit custom field value
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_field_values"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field value
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field_value.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> 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"
end

View file

@ -1,97 +0,0 @@
defmodule MvWeb.GlobalSettingsLive do
@moduledoc """
LiveView for managing global application settings (Vereinsdaten).
## Features
- Edit the association/club name
- Real-time form validation
- Success/error feedback
## Settings
- `club_name` - The name of the association/club (required)
## Events
- `validate` - Real-time form validation
- `save` - Save settings changes
## Note
Settings is a singleton resource - there is only one settings record.
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
"""
use MvWeb, :live_view
alias Mv.Membership
@impl true
def mount(_params, _session, socket) do
{:ok, settings} = Membership.get_settings()
{:ok,
socket
|> assign(:page_title, gettext("Club Settings"))
|> assign(:settings, settings)
|> assign_form()}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Club Settings")}
<:subtitle>
{gettext("Manage global settings for the association.")}
</:subtitle>
</.header>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:club_name]}
type="text"
label={gettext("Association Name")}
required
/>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Settings")}
</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def handle_event("validate", %{"setting" => setting_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
{:ok, updated_settings} ->
socket =
socket
|> assign(:settings, updated_settings)
|> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(
settings,
:update,
api: Membership,
as: "setting",
forms: [auto?: true]
)
assign(socket, form: to_form(form))
end
end

View file

@ -1,33 +1,4 @@
defmodule MvWeb.MemberLive.Form do
@moduledoc """
LiveView form for creating and editing members.
## Features
- Create new members with personal information
- Edit existing member details
- Manage custom properties (dynamic fields)
- Real-time validation with visual feedback
- Link/unlink user accounts
## Form Fields
**Required:**
- first_name, last_name, email
**Optional:**
- phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date
- paid status
- notes
## Custom Field Values
Members can have dynamic custom field values defined by CustomFields.
The form dynamically renders inputs based on available CustomFields.
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update member)
- Custom field value management events for adding/removing custom fields
"""
use MvWeb, :live_view
@impl true
@ -37,7 +8,7 @@ defmodule MvWeb.MemberLive.Form do
<.header>
{@page_title}
<:subtitle>
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
{gettext("Use this form to manage member records and their properties.")}
</:subtitle>
</.header>
@ -45,6 +16,7 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
@ -55,11 +27,10 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:house_number]} label={gettext("House Number")} />
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
<% type =
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.inputs_for :let={f_property} field={@form[:properties]}>
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
<.inputs_for :let={value_form} field={f_property[:value]}>
<% input_type =
cond do
type && type.value_type == :boolean -> "checkbox"
@ -70,8 +41,8 @@ defmodule MvWeb.MemberLive.Form do
</.inputs_for>
<input
type="hidden"
name={f_custom_field_value[:custom_field_id].name}
value={f_custom_field_value[:custom_field_id].value}
name={f_property[:property_type_id].name}
value={f_property[:property_type_id].value}
/>
</.inputs_for>
@ -86,16 +57,16 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def mount(params, _session, socket) do
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
{:ok, property_types} = Mv.Membership.list_property_types()
initial_custom_field_values =
Enum.map(custom_fields, fn cf ->
initial_properties =
Enum.map(property_types, fn pt ->
%{
"custom_field_id" => cf.id,
"property_type_id" => pt.id,
"value" => %{
"type" => cf.value_type,
"type" => pt.value_type,
"value" => nil,
"_union_type" => Atom.to_string(cf.value_type)
"_union_type" => Atom.to_string(pt.value_type)
}
}
end)
@ -112,8 +83,8 @@ defmodule MvWeb.MemberLive.Form do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:custom_fields, custom_fields)
|> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(:property_types, property_types)
|> assign(:initial_properties, initial_properties)
|> assign(member: member)
|> assign(:page_title, page_title)
|> assign_form()}
@ -156,25 +127,25 @@ defmodule MvWeb.MemberLive.Form do
defp assign_form(%{assigns: %{member: member}} = socket) do
form =
if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
{:ok, member} = Ash.load(member, properties: [:property_type])
existing_custom_field_values =
member.custom_field_values
|> Enum.map(& &1.custom_field_id)
existing_properties =
member.properties
|> Enum.map(& &1.property_type_id)
is_missing_custom_field_value = fn i ->
not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id"))
is_missing_property = fn i ->
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
end
params = %{
"custom_field_values" =>
Enum.map(member.custom_field_values, fn cfv ->
"properties" =>
Enum.map(member.properties, fn prop ->
%{
"custom_field_id" => cfv.custom_field_id,
"property_type_id" => prop.property_type_id,
"value" => %{
"_union_type" => Atom.to_string(cfv.value.type),
"type" => cfv.value.type,
"value" => cfv.value.value
"_union_type" => Atom.to_string(prop.value.type),
"type" => prop.value.type,
"value" => prop.value.value
}
}
end)
@ -190,13 +161,12 @@ defmodule MvWeb.MemberLive.Form do
forms: [auto?: true]
)
missing_custom_field_values =
Enum.filter(socket.assigns[:initial_custom_field_values], is_missing_custom_field_value)
missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property)
Enum.reduce(
missing_custom_field_values,
missing_properties,
form,
&AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1)
&AshPhoenix.Form.add_form(&2, [:properties], params: &1)
)
else
AshPhoenix.Form.for_create(
@ -204,7 +174,7 @@ defmodule MvWeb.MemberLive.Form do
:create_member,
api: Mv.Membership,
as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
params: %{"properties" => socket.assigns[:initial_properties]},
forms: [auto?: true]
)
end

File diff suppressed because it is too large Load diff

View file

@ -2,57 +2,23 @@
<.header>
{gettext("Members")}
<:actions>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={
"mailto:?bcc=" <>
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|> Enum.join(", ")
|> URI.encode())
}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
</:actions>
</.header>
<div class="flex flex-wrap gap-4 items-center">
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
</div>
<.table
id="members"
rows={@members}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
@ -64,7 +30,7 @@
type="checkbox"
name="select_all"
phx-click="select_all"
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
aria-label={gettext("Select all members")}
role="checkbox"
/>
@ -76,7 +42,7 @@
name={member.id}
phx-click="select_member"
phx-value-id={member.id}
checked={MapSet.member?(@selected_members, member.id)}
checked={member.id in @selected_members}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select member")}
@ -86,154 +52,24 @@
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_first_name}
field={:first_name}
label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
sort_button(%{
field: :first_name,
label: gettext("Name"),
sort_field: @sort_field,
sort_order: @sort_order
})
}
>
{member.first_name} {member.last_name}
</:col>
<:col
:let={member}
:if={:email in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{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}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.join_date}
</:col>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
</:col>
<:col :let={member} label={gettext("Email")}>{member.email}</:col>
<:col :let={member} label={gettext("Street")}>{member.street}</:col>
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col>
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col>
<:col :let={member} label={gettext("City")}>{member.city}</:col>
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col>
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -1,74 +0,0 @@
defmodule MvWeb.MemberLive.Index.Formatter do
@moduledoc """
Formats custom field values for display in the member overview table.
Handles different value types (string, integer, boolean, date, email) and
formats them appropriately for display in the UI.
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats a custom field value for display.
Handles different input formats:
- `nil` - Returns empty string
- `%Ash.Union{}` - Extracts value and type from union type
- Map (JSONB format) - Extracts type and value from map keys
- Direct value - Uses custom_field.value_type to determine format
## Examples
iex> format_custom_field_value(nil, %CustomField{value_type: :string})
""
iex> format_custom_field_value("test", %CustomField{value_type: :string})
"test"
iex> format_custom_field_value(true, %CustomField{value_type: :boolean})
"Yes"
"""
def format_custom_field_value(nil, _custom_field), do: ""
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
format_value_by_type(value, type, custom_field)
end
def format_custom_field_value(value, custom_field) when is_map(value) do
# Handle map format from JSONB
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
format_value_by_type(val, type, custom_field)
end
def format_custom_field_value(value, custom_field) do
format_value_by_type(value, custom_field.value_type, custom_field)
end
# Format value based on type
defp format_value_by_type(value, :string, _), do: to_string(value)
defp format_value_by_type(value, :integer, _), do: to_string(value)
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
# Return empty string if value is empty
if String.trim(value) == "", do: "", else: value
end
defp format_value_by_type(value, :email, _), do: to_string(value)
defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes")
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Date.to_string(date)
_ -> value
end
end
defp format_value_by_type(value, _type, _), do: to_string(value)
end

View file

@ -1,26 +1,4 @@
defmodule MvWeb.MemberLive.Show do
@moduledoc """
LiveView for displaying a single member's details.
## Features
- Display all member information (personal, contact, address)
- Show linked user account (if exists)
- Display custom field values
- Navigate to edit form
- Return to member list
## Displayed Information
- Basic: name, email, dates (join, exit)
- Contact: phone number
- Address: street, house number, postal code, city
- Status: paid flag
- Relationships: linked user account
- Custom: dynamic custom field values from CustomFields
## Navigation
- Back to member list
- Edit member (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
import Ash.Query
@ -48,6 +26,7 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>
@ -74,14 +53,14 @@ defmodule MvWeb.MemberLive.Show do
</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.generic_list items={
Enum.map(@member.custom_field_values, fn cfv ->
Enum.map(@member.properties, fn p ->
{
# name
cfv.custom_field && cfv.custom_field.name,
p.property_type && p.property_type.name,
# value
case cfv.value do
case p.value do
%{value: v} -> v
v -> v
end
@ -102,7 +81,7 @@ defmodule MvWeb.MemberLive.Show do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load([:user, custom_field_values: [:custom_field]])
|> load([:user, properties: [:property_type]])
member = Ash.read_one!(query)

View file

@ -1,35 +1,4 @@
defmodule MvWeb.CustomFieldValueLive.Form do
@moduledoc """
LiveView form for creating and editing custom field values.
## Features
- Create new custom field values with member and type selection
- Edit existing custom field values
- Value input adapts to custom field type (string, integer, boolean, date, email)
- Real-time validation
## Form Fields
**Required:**
- member - Select which member owns this custom field value
- custom_field - Select the type (defines value type)
- value - The actual value (input type depends on custom field type)
## Value Types
The form dynamically renders appropriate inputs based on custom field type:
- String: text input
- Integer: number input
- Boolean: checkbox
- Date: date picker
- Email: email input with validation
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field value)
## Note
Custom field values are typically managed through the member edit form,
not through this standalone form.
"""
defmodule MvWeb.PropertyLive.Form do
use MvWeb, :live_view
@impl true
@ -38,19 +7,17 @@ defmodule MvWeb.CustomFieldValueLive.Form do
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage Custom Field Value records in your database.")}
</:subtitle>
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
</.header>
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
<!-- Custom Field Selection -->
<.form for={@form} id="property-form" phx-change="validate" phx-submit="save">
<!-- Property Type Selection -->
<.input
field={@form[:custom_field_id]}
field={@form[:property_type_id]}
type="select"
label={gettext("Custom field")}
options={custom_field_options(@custom_fields)}
prompt={gettext("Choose a custom field")}
label={gettext("Property type")}
options={property_type_options(@property_types)}
prompt={gettext("Choose a property type")}
/>
<!-- Member Selection -->
@ -63,18 +30,18 @@ defmodule MvWeb.CustomFieldValueLive.Form do
/>
<!-- Value Input - handles Union type -->
<%= if @selected_custom_field do %>
<.union_value_input form={@form} custom_field={@selected_custom_field} />
<%= if @selected_property_type do %>
<.union_value_input form={@form} property_type={@selected_property_type} />
<% else %>
<div class="text-sm text-gray-600">
{gettext("Please select a custom field first")}
{gettext("Please select a property type first")}
</div>
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field value")}
{gettext("Save Property")}
</.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
<.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
@ -82,8 +49,8 @@ defmodule MvWeb.CustomFieldValueLive.Form do
# Helper function for Union-Value Input
defp union_value_input(assigns) do
# Extract the current value from the CustomFieldValue
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
# Extract the current value from the Property
current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type)
assigns = assign(assigns, :current_value, current_value)
~H"""
@ -92,7 +59,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do
{gettext("Value")}
</label>
<%= case @custom_field.value_type do %>
<%= case @property_type.value_type do %>
<% :string -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
@ -125,16 +92,16 @@ defmodule MvWeb.CustomFieldValueLive.Form do
</.inputs_for>
<% _ -> %>
<div class="text-sm text-red-600">
{gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
{gettext("Unsupported value type: %{type}", type: @property_type.value_type)}
</div>
<% end %>
</div>
"""
end
# Helper function to extract the current value from the CustomFieldValue
# Helper function to extract the current value from the Property
defp extract_current_value(
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
%Mv.Membership.Property{value: %Ash.Union{value: value}},
_value_type
) do
value
@ -162,27 +129,27 @@ defmodule MvWeb.CustomFieldValueLive.Form do
@impl true
def mount(params, _session, socket) do
custom_field_value =
property =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field])
id -> Ash.get!(Mv.Membership.Property, id) |> Ash.load!([:property_type])
end
action = if is_nil(custom_field_value), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field value"
action = if is_nil(property), do: "New", else: "Edit"
page_title = action <> " " <> "Property"
# Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField)
# Load all PropertyTypes and Members for the selection fields
property_types = Ash.read!(Mv.Membership.PropertyType)
members = Ash.read!(Mv.Membership.Member)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field_value: custom_field_value)
|> assign(property: property)
|> assign(:page_title, page_title)
|> assign(:custom_fields, custom_fields)
|> assign(:property_types, property_types)
|> assign(:members, members)
|> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field)
|> assign(:selected_property_type, property && property.property_type)
|> assign_form()}
end
@ -190,43 +157,43 @@ defmodule MvWeb.CustomFieldValueLive.Form do
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
# Find the selected CustomField
selected_custom_field =
case custom_field_value_params["custom_field_id"] do
def handle_event("validate", %{"property" => property_params}, socket) do
# Find the selected PropertyType
selected_property_type =
case property_params["property_type_id"] do
"" -> nil
nil -> nil
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
id -> Enum.find(socket.assigns.property_types, &(&1.id == id))
end
# Set the Union type based on the selected CustomField
# Set the Union type based on the selected PropertyType
updated_params =
if selected_custom_field do
union_type = to_string(selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
if selected_property_type do
union_type = to_string(selected_property_type.value_type)
put_in(property_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
property_params
end
{:noreply,
socket
|> assign(:selected_custom_field, selected_custom_field)
|> assign(:selected_property_type, selected_property_type)
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
end
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
# Set the Union type based on the selected CustomField
def handle_event("save", %{"property" => property_params}, socket) do
# Set the Union type based on the selected PropertyType
updated_params =
if socket.assigns.selected_custom_field do
union_type = to_string(socket.assigns.selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
if socket.assigns.selected_property_type do
union_type = to_string(socket.assigns.selected_property_type.value_type)
put_in(property_params, ["value", "_union_type"], union_type)
else
custom_field_value_params
property_params
end
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})
{:ok, property} ->
notify_parent({:saved, property})
action =
case socket.assigns.form.source.type do
@ -237,11 +204,8 @@ defmodule MvWeb.CustomFieldValueLive.Form do
socket =
socket
|> put_flash(
:info,
gettext("Custom field value %{action} successfully", action: action)
)
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
|> put_flash(:info, gettext("Property %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property))
{:noreply, socket}
@ -252,11 +216,11 @@ defmodule MvWeb.CustomFieldValueLive.Form do
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do
defp assign_form(%{assigns: %{property: property}} = socket) do
form =
if custom_field_value do
# Determine the Union type based on the custom_field
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
if property do
# Determine the Union type based on the property_type
union_type = property.property_type && property.property_type.value_type
params =
if union_type do
@ -265,27 +229,20 @@ defmodule MvWeb.CustomFieldValueLive.Form do
%{}
end
AshPhoenix.Form.for_update(custom_field_value, :update,
as: "custom_field_value",
params: params
)
AshPhoenix.Form.for_update(property, :update, as: "property", params: params)
else
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
as: "custom_field_value"
)
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
defp return_path("show", custom_field_value),
do: ~p"/custom_field_values/#{custom_field_value.id}"
defp return_path("index", _property), do: ~p"/properties"
defp return_path("show", property), do: ~p"/properties/#{property.id}"
# Helper functions for selection options
defp custom_field_options(custom_fields) do
Enum.map(custom_fields, &{&1.name, &1.id})
defp property_type_options(property_types) do
Enum.map(property_types, &{&1.name, &1.id})
end
defp member_options(members) do

View file

@ -0,0 +1,60 @@
defmodule MvWeb.PropertyLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Properties
<:actions>
<.button variant="primary" navigate={~p"/properties/new"}>
<.icon name="hero-plus" /> New Property
</.button>
</:actions>
</.header>
<.table
id="properties"
rows={@streams.properties}
row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end}
>
<:col :let={{_id, property}} label="Id">{property.id}</:col>
<:action :let={{_id, property}}>
<div class="sr-only">
<.link navigate={~p"/properties/#{property}"}>Show</.link>
</div>
<.link navigate={~p"/properties/#{property}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property}}>
<.link
phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Properties")
|> stream(:properties, Ash.read!(Mv.Membership.Property))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property = Ash.get!(Mv.Membership.Property, id)
Ash.destroy!(property)
{:noreply, stream_delete(socket, :properties, property)}
end
end

View file

@ -0,0 +1,44 @@
defmodule MvWeb.PropertyLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/properties"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit Property
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:property, Ash.get!(Mv.Membership.Property, id))}
end
defp page_title(:show), do: "Show Property"
defp page_title(:edit), do: "Edit Property"
end

View file

@ -0,0 +1,105 @@
defmodule MvWeb.PropertyTypeLive.Form do
use MvWeb, :live_view
@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 property_type records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Property type")}
</.button>
<.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
property_type =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.PropertyType, id)
end
action = if is_nil(property_type), do: "New", else: "Edit"
page_title = action <> " " <> "Property type"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(property_type: property_type)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"property_type" => property_type_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))}
end
def handle_event("save", %{"property_type" => property_type_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
{:ok, property_type} ->
notify_parent({:saved, property_type})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Property type %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property_type))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property_type: property_type}} = socket) do
form =
if property_type do
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
else
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _property_type), do: ~p"/property_types"
defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}"
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.PropertyTypeLive.Index do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Property types
<:actions>
<.button variant="primary" navigate={~p"/property_types/new"}>
<.icon name="hero-plus" /> New Property type
</.button>
</:actions>
</.header>
<.table
id="property_types"
rows={@streams.property_types}
row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end}
>
<:col :let={{_id, property_type}} label="Id">{property_type.id}</:col>
<:col :let={{_id, property_type}} label="Name">{property_type.name}</:col>
<:col :let={{_id, property_type}} label="Description">{property_type.description}</:col>
<:action :let={{_id, property_type}}>
<div class="sr-only">
<.link navigate={~p"/property_types/#{property_type}"}>Show</.link>
</div>
<.link navigate={~p"/property_types/#{property_type}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property_type}}>
<.link
phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Property types")
|> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property_type = Ash.get!(Mv.Membership.PropertyType, id)
Ash.destroy!(property_type)
{:noreply, stream_delete(socket, :property_types, property_type)}
end
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.PropertyTypeLive.Show do
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/property_types"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/property_types/#{@property_type}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Property type
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property_type.id}</:item>
<:item title="Name">{@property_type.name}</:item>
<:item title="Description">{@property_type.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Property type")
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))}
end
end

View file

@ -1,36 +1,4 @@
defmodule MvWeb.UserLive.Form do
@moduledoc """
LiveView form for creating and editing users.
## Features
- Create new users with email
- Edit existing user details
- Optional password setting (checkbox to toggle)
- Link/unlink member accounts
- Email synchronization with linked members
## Form Fields
**Required:**
- email
**Optional:**
- password (for password authentication strategy)
- linked member (select from existing members)
## Password Management
- New users: Can optionally set password with confirmation
- Existing users: Can change password (no confirmation required, admin action)
- Checkbox toggles password section visibility
## Member Linking
Users can be linked to existing member accounts. When linked, emails are
synchronized bidirectionally with User.email as the source of truth.
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update user)
- `toggle_password_section` - Show/hide password fields
"""
use MvWeb, :live_view
@impl true
@ -121,130 +89,6 @@ defmodule MvWeb.UserLive.Form do
<% end %>
</div>
<!-- Member Linking Section -->
<div class="mt-6">
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{@user.member.first_name} {@user.member.last_name}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">{member.first_name} {member.last_name}</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="text-xs text-blue-600 mt-1">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
@ -259,7 +103,7 @@ defmodule MvWeb.UserLive.Form do
user =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -271,18 +115,9 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user)
|> assign(:page_title, page_title)
|> assign(:show_password_fields, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil)
|> assign(:unlink_member, false)
|> assign(:focused_member_index, nil)
|> load_initial_members()
|> assign_form()}
end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@ -299,201 +134,28 @@ defmodule MvWeb.UserLive.Form do
end
def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
# Reload members if email changed (for email-match priority)
socket =
if Map.has_key?(user_params, "email") do
user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
assign(socket, form: validated_form, available_members: members)
else
assign(socket, form: validated_form)
end
{:noreply, socket}
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
end
def handle_event("save", %{"user" => user_params}, socket) do
# First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
{:ok, user} ->
# Then handle member linking/unlinking as a separate step
result =
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil})
# No changes to member relationship
true ->
{:ok, user}
end
case result do
{:ok, updated_user} ->
notify_parent({:saved, updated_user})
notify_parent({:saved, user})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|> push_navigate(to: return_path(socket.assigns.return_to, user))
{:noreply, socket}
{:error, error} ->
# Show user-friendly error from member linking/unlinking
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
end
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end
def handle_event("search_members", %{"member_search" => query}, socket) do
socket =
socket
|> assign(:member_search_query, query)
|> load_available_members(query)
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
def handle_event("select_member", %{"id" => member_id}, socket) do
# Find the selected member to get their name
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
member_name =
if selected_member,
do: "#{selected_member.first_name} #{selected_member.last_name}",
else: ""
# Store the selected member ID and name in socket state and clear unlink flag
socket =
socket
|> assign(:selected_member_id, member_id)
|> assign(:selected_member_name, member_name)
|> assign(:unlink_member, false)
|> assign(:show_member_dropdown, false)
|> assign(:member_search_query, member_name)
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
{:noreply, socket}
end
def handle_event("unlink_member", _params, socket) do
# Set flag to unlink member on save
# Clear all member selection state and keep dropdown hidden
socket =
socket
|> assign(:unlink_member, true)
|> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil)
|> assign(:member_search_query, "")
|> assign(:show_member_dropdown, false)
|> load_initial_members()
{:noreply, socket}
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
# Helper to ignore keyboard events when dropdown is closed
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp return_if_dropdown_closed(socket, func) do
if socket.assigns.show_member_dropdown do
func.()
else
{:noreply, socket}
end
end
# Select the currently focused member from the dropdown
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp select_focused_member(socket) do
with index when not is_nil(index) <- socket.assigns.focused_member_index,
member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do
handle_event("select_member", %{"id" => member.id}, socket)
else
_ -> {:noreply, socket}
end
end
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
form =
if user do
@ -513,71 +175,6 @@ defmodule MvWeb.UserLive.Form do
assign(socket, form: to_form(form))
end
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
defp return_path("index", _user), do: ~p"/users"
defp return_path("show", user), do: ~p"/users/#{user.id}"
@spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp load_initial_members(socket) do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, "")
# Dropdown should ALWAYS be hidden initially
# It will only show when user focuses the input field (show_member_dropdown event)
socket
|> assign(available_members: members)
|> assign(show_member_dropdown: false)
end
@spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) ::
Phoenix.LiveView.Socket.t()
defp load_available_members(socket, query) do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, query)
assign(socket, available_members: members)
end
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
defp load_members_for_linking(user_email, search_query) do
user_email_str = if user_email, do: to_string(user_email), else: nil
search_query_str = if search_query && search_query != "", do: search_query, else: nil
query =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{
user_email: user_email_str,
search_query: search_query_str
})
case Ash.read(query, domain: Mv.Membership) do
{:ok, members} ->
# Apply email match filter if user_email is provided
if user_email_str do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
else
members
end
{:error, _} ->
[]
end
end
# Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
# Take first error and extract message
case List.first(errors) do
%{message: message} when is_binary(message) -> message
%{field: field, message: message} -> "#{field}: #{message}"
_ -> "Unknown error"
end
end
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: "Unknown error"
end

View file

@ -1,31 +1,10 @@
defmodule MvWeb.UserLive.Index do
@moduledoc """
LiveView for displaying and managing the user list.
## Features
- List all users with email and linked member
- Sort users by email (default)
- Delete users
- Navigate to user details and edit forms
- Bulk selection for future batch operations
## Relationships
Displays linked member information when a user is connected to a member account.
## Events
- `delete` - Remove a user from the database
- `select_user` - Toggle individual user selection
- `select_all` - Toggle selection of all visible users
## Security
User deletion requires admin permissions (enforced by Ash policies).
"""
use MvWeb, :live_view
import MvWeb.TableComponents
@impl true
def mount(_params, _session, socket) do
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
sorted = Enum.sort_by(users, & &1.email)
{:ok,

View file

@ -50,13 +50,6 @@
{user.email}
</:col>
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>
</:col>
<:action :let={user}>
<div class="sr-only">

View file

@ -1,29 +1,4 @@
defmodule MvWeb.UserLive.Show do
@moduledoc """
LiveView for displaying a single user's details.
## Features
- Display user information (email, OIDC ID)
- Show authentication methods (password, OIDC)
- Display linked member account (if exists)
- Navigate to edit form
- Return to user list
## Displayed Information
- Email address
- OIDC ID (if authenticated via OIDC)
- Password authentication status
- Linked member (name and email)
## Authentication Status
Shows which authentication methods are enabled for the user:
- Password authentication (has hashed_password)
- OIDC authentication (has oidc_id)
## Navigation
- Back to user list
- Edit user (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
@impl true

View file

@ -1,16 +1,4 @@
defmodule MvWeb.LiveHelpers do
@moduledoc """
Shared LiveView lifecycle hooks and helper functions.
## on_mount Hooks
- `:default` - Sets the user's locale from session (defaults to "de")
## Usage
Add to LiveView modules via:
```elixir
on_mount {MvWeb.LiveHelpers, :default}
```
"""
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)

View file

@ -4,13 +4,6 @@ defmodule MvWeb.LocaleController do
def set_locale(conn, %{"locale" => locale}) do
conn
|> put_session(:locale, locale)
# Store locale in a cookie that persists beyond the session
|> put_resp_cookie("locale", locale,
max_age: 365 * 24 * 60 * 60,
same_site: "Lax",
http_only: true,
secure: Application.get_env(:mv, :use_secure_cookies, false)
)
|> redirect(to: get_referer(conn) || "/")
end

View file

@ -55,17 +55,17 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/custom_fields", CustomFieldLive.Index, :index
live "/custom_fields/new", CustomFieldLive.Form, :new
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
live "/custom_fields/:id", CustomFieldLive.Show, :show
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
live "/property_types", PropertyTypeLive.Index, :index
live "/property_types/new", PropertyTypeLive.Form, :new
live "/property_types/:id/edit", PropertyTypeLive.Form, :edit
live "/property_types/:id", PropertyTypeLive.Show, :show
live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
live "/properties", PropertyLive.Index, :index
live "/properties/new", PropertyLive.Form, :new
live "/properties/:id/edit", PropertyLive.Form, :edit
live "/properties/:id", PropertyLive.Show, :show
live "/properties/:id/show/edit", PropertyLive.Show, :edit
live "/users", UserLive.Index, :index
live "/users/new", UserLive.Form, :new
@ -73,14 +73,9 @@ defmodule MvWeb.Router do
live "/users/:id", UserLive.Show, :show
live "/users/:id/show/edit", UserLive.Show, :edit
live "/settings", GlobalSettingsLive
post "/set_locale", LocaleController, :set_locale
end
# OIDC account linking - user needs to verify password (MUST be before auth_routes!)
live "/auth/link-oidc-account", LinkOidcAccountLive
# ASHAUTHENTICATION GENERATED AUTH ROUTES
auth_routes AuthController, Mv.Accounts.User, path: "/auth"
sign_out_route AuthController
@ -146,7 +141,6 @@ defmodule MvWeb.Router do
defp set_locale(conn, _opts) do
locale =
get_session(conn, :locale) ||
get_locale_from_cookie(conn) ||
extract_locale_from_headers(conn.req_headers)
Gettext.put_locale(MvWeb.Gettext, locale)
@ -156,13 +150,6 @@ defmodule MvWeb.Router do
|> assign(:locale, locale)
end
defp get_locale_from_cookie(conn) do
case conn.req_cookies do
%{"locale" => locale} when locale in ["en", "de"] -> locale
_ -> nil
end
end
# Get locale from user
defp extract_locale_from_headers(headers) do
headers

View file

@ -22,7 +22,7 @@ defmodule Mv.MixProject do
def application do
[
mod: {Mv.Application, []},
extra_applications: [:logger, :runtime_tools, :gettext]
extra_applications: [:logger, :runtime_tools]
]
end
@ -75,8 +75,7 @@ defmodule Mv.MixProject do
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"}
{:ecto_commons, "~> 0.3"}
]
end

View file

@ -16,7 +16,7 @@
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [: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", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [: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", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{: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.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [: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", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
@ -80,7 +80,7 @@
"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.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [: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", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

View file

@ -1,58 +0,0 @@
# User-Member Association - Test Status
## Test Files Created/Modified
### 1. test/membership/member_available_for_linking_test.exs (NEU)
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
**Grund**: Die `:available_for_linking` Action existiert noch nicht
Tests:
- ✗ returns only unlinked members and limits to 10
- ✗ limits results to 10 members even when more exist
- ✗ email match: returns only member with matching email when exists
- ✗ email match: returns all unlinked members when no email match
- ✗ search query: filters by first_name, last_name, and email
- ✗ email match takes precedence over search query
### 2. test/accounts/user_member_linking_test.exs (NEU)
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
Tests:
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
**Grund**: Member-Linking UI ist noch nicht implementiert
Neue Tests:
- ✗ shows linked member with unlink button when user has member
- ✗ shows member search field when user has no member
- ✗ selecting member and saving links member to user
- ✗ unlinking member and saving removes member from user
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
Neuer Test:
- ✗ displays linked member name in user list
## Zusammenfassung
**Tests gesamt**: 13
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
## Nächste Schritte
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
5. Füge Gettext-Übersetzungen hinzu
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.

View file

@ -36,8 +36,6 @@ msgstr ""
msgid "Need an account?"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#, elixir-autogen
msgid "Password"
msgstr ""
@ -64,79 +62,3 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""

View file

@ -35,8 +35,6 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
msgid "Need an account?"
msgstr "Konto anlegen?"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#, elixir-autogen
msgid "Password"
msgstr "Passwort"
@ -63,79 +61,3 @@ msgstr "Anmelden..."
msgid "Your password has successfully been reset"
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr "Konto verknüpfen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr "OIDC-Konto verknüpfen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr "Verknüpfen..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr "Sprachauswahl"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"

File diff suppressed because it is too large Load diff

View file

@ -155,7 +155,3 @@ msgstr "muss mindestens 8 Zeichen lang sein"
msgid "is required"
msgstr "ist erforderlich"
#: lib/mv_web/live/user_live/form.ex
msgid "Failed to link member: %{error}"
msgstr "Fehler beim Verknüpfen des Mitglieds: %{error}"

File diff suppressed because it is too large Load diff

View file

@ -32,8 +32,6 @@ msgstr ""
msgid "Need an account?"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#, elixir-autogen
msgid "Password"
msgstr ""
@ -60,79 +58,3 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -155,7 +155,3 @@ msgstr ""
msgid "is required"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
msgid "Failed to link member: %{error}"
msgstr ""

View file

@ -152,7 +152,3 @@ msgstr ""
msgid "is required"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
msgid "Failed to link member: %{error}"
msgstr ""

View file

@ -1,66 +0,0 @@
defmodule Mv.Repo.Migrations.AddTrigramToMembers 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
# activate trigram-extension
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
# -------------------------------------------------
# TrigramIndizes (GIN) for fields we want to search in
# -------------------------------------------------
#
# `gin_trgm_ops` ist the operator-class-name
#
execute("""
CREATE INDEX members_first_name_trgm_idx
ON members
USING GIN (first_name gin_trgm_ops);
""")
execute("""
CREATE INDEX members_last_name_trgm_idx
ON members
USING GIN (last_name gin_trgm_ops);
""")
execute("""
CREATE INDEX members_email_trgm_idx
ON members
USING GIN (email gin_trgm_ops);
""")
execute("""
CREATE INDEX members_city_trgm_idx
ON members
USING GIN (city gin_trgm_ops);
""")
execute("""
CREATE INDEX members_street_trgm_idx
ON members
USING GIN (street gin_trgm_ops);
""")
execute("""
CREATE INDEX members_notes_trgm_idx
ON members
USING GIN (notes gin_trgm_ops);
""")
end
def down do
execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;")
execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;")
execute("DROP INDEX IF EXISTS members_email_trgm_idx;")
execute("DROP INDEX IF EXISTS members_city_trgm_idx;")
execute("DROP INDEX IF EXISTS members_street_trgm_idx;")
execute("DROP INDEX IF EXISTS members_notes_trgm_idx;")
end
end

View file

@ -1,19 +0,0 @@
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFieldsExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
# execute("DROP EXTENSION IF EXISTS \"pg_trgm\"")
end
end

View file

@ -1,84 +0,0 @@
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFields 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
# Rename tables
rename table("property_types"), to: table("custom_fields")
rename table("properties"), to: table("custom_field_values")
# Rename the foreign key column
rename table("custom_field_values"), :property_type_id, to: :custom_field_id
# Drop old foreign key constraints
drop constraint(:custom_field_values, "properties_member_id_fkey")
drop constraint(:custom_field_values, "properties_property_type_id_fkey")
# Add new foreign key constraints with correct names and on_delete behavior
alter table(:custom_field_values) do
modify :member_id,
references(:members,
column: :id,
name: "custom_field_values_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public"
)
end
# Rename indexes
execute "ALTER INDEX IF EXISTS property_types_unique_name_index RENAME TO custom_fields_unique_name_index"
execute "ALTER INDEX IF EXISTS properties_unique_property_per_member_index RENAME TO custom_field_values_unique_custom_field_per_member_index"
end
def down do
# Rename indexes back
execute "ALTER INDEX IF EXISTS custom_fields_unique_name_index RENAME TO property_types_unique_name_index"
execute "ALTER INDEX IF EXISTS custom_field_values_unique_custom_field_per_member_index RENAME TO properties_unique_property_per_member_index"
# Drop new foreign key constraints
drop constraint(:custom_field_values, "custom_field_values_member_id_fkey")
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
# Add back old foreign key constraints
alter table(:custom_field_values) do
modify :member_id,
references(:members,
column: :id,
name: "properties_member_id_fkey",
type: :uuid,
prefix: "public"
)
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "properties_property_type_id_fkey",
type: :uuid,
prefix: "public"
)
end
# Rename the foreign key column back
rename table("custom_field_values"), :custom_field_id, to: :property_type_id
# Rename tables back
rename table("custom_fields"), to: table("property_types")
rename table("custom_field_values"), to: table("properties")
end
end

View file

@ -1,47 +0,0 @@
defmodule Mv.Repo.Migrations.AddSlugToCustomFields 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
# Step 1: Add slug column as nullable first
alter table(:custom_fields) do
add :slug, :text, null: true
end
# Step 2: Generate slugs for existing custom fields
execute("""
UPDATE custom_fields
SET slug = lower(
regexp_replace(
regexp_replace(
regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'),
'\\s+', '-', 'g'
),
'-+', '-', 'g'
)
)
WHERE slug IS NULL
""")
# Step 3: Make slug NOT NULL
alter table(:custom_fields) do
modify :slug, :text, null: false
end
# Step 4: Create unique index
create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
end
def down do
drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
alter table(:custom_fields) do
remove :slug
end
end
end

View file

@ -1,38 +0,0 @@
defmodule Mv.Repo.Migrations.ChangeCustomFieldDeleteCascade 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
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
alter table(:custom_field_values) do
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
alter table(:custom_field_values) do
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View file

@ -1,21 +0,0 @@
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields 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(:custom_fields) do
add :show_in_overview, :boolean, null: false, default: true
end
end
def down do
alter table(:custom_fields) do
remove :show_in_overview
end
end
end

View file

@ -1,31 +0,0 @@
defmodule Mv.Repo.Migrations.AddSettingsTable 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
create table(:settings, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :club_name, :text, null: 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
# Note: Singleton pattern is enforced at application level via get_settings/0
# which creates the record if it doesn't exist and only allows updates
end
def down do
drop table(:settings)
end
end

View file

@ -1,21 +0,0 @@
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings 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(:settings) do
add :member_field_visibility, :map
end
end
def down do
alter table(:settings) do
remove :member_field_visibility
end
end
end

View file

@ -1,69 +0,0 @@
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
@moduledoc """
Removes the birth_date column from the members table.
The birth_date field has been removed from the application because most users
don't record birthday data. Users who need this can use a custom field instead.
This migration also updates the search_vector trigger to remove birth_date.
"""
use Ecto.Migration
def up do
# Update the trigger function to remove birth_date from search_vector
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
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');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Remove the birth_date column
alter table(:members) do
remove :birth_date
end
end
def down do
# Add the birth_date column back
alter table(:members) do
add :birth_date, :date
end
# Restore the trigger function with birth_date
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
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.birth_date::text, '')), 'C') ||
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');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
end
end

Some files were not shown because too many files have changed in this diff Show more