Compare commits
289 commits
try-timeou
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a3cc4c47 | |||
| c381b86b5e | |||
| 9b0f269ab6 | |||
| f353f1cbc0 | |||
| e8f27690a1 | |||
| e95c1d6254 | |||
| 837f5fd5bf | |||
| 1866c79461 | |||
| 171a699326 | |||
| 86c032004e | |||
| a4239ce09b | |||
| c933144920 | |||
| e8ec620d57 | |||
| 349cee0ce6 | |||
| f12da8a359 | |||
| d54393d80b | |||
| 5e39fffce2 | |||
| 9a3cf74871 | |||
| 09e4b64663 | |||
| eb18209669 | |||
| 104faf7006 | |||
| 99a8d64344 | |||
| 086ecdcb1b | |||
| 40a4461d23 | |||
| a7481f6ab1 | |||
| d94f9ae42e | |||
| a5ce7cb921 | |||
| 942f2afd9e | |||
| 4af80a8305 | |||
| a4f3aa5d6f | |||
| 160c35c0ba | |||
| 82962a2f2a | |||
| 15d4c7d97f | |||
| 03d91d4029 | |||
| 762402adf9 | |||
| ca9e4accc8 | |||
| 45c2f3e2b3 | |||
| b9ff02b959 | |||
| a4ad1f7b27 | |||
| c4135308e6 | |||
| cb69521cda | |||
| f53a3ce3cc | |||
| 28f97184b3 | |||
| 86d9242d83 | |||
| 50433e607f | |||
| f79c9ac515 | |||
| 021b709e6a | |||
| f19b44f6a3 | |||
| 5eb7c9c4b2 | |||
| f430762555 | |||
| 137dca523a | |||
| b04d59e3c4 | |||
| c264ce122d | |||
| 7686b63d7f | |||
| a9c61f703d | |||
| f1d0526209 | |||
| eadf90b5fc | |||
| 697673ffb6 | |||
| 21812542ad | |||
| 05e2a298fe | |||
| fa738aae88 | |||
| b7a83d9298 | |||
| dbd7afbaf4 | |||
| 5deb102e45 | |||
| 0614592674 | |||
| 6385fbc831 | |||
| 3672ef0d03 | |||
| 0da030ecb7 | |||
| f601550526 | |||
| ad6ef169ac | |||
| a41d8498ac | |||
| 085f61d10d | |||
| d032f1ca0c | |||
| a3e986ae58 | |||
| 2515a679b8 | |||
| 8da22b3d88 | |||
| ae07e3efc2 | |||
| 3af52f2829 | |||
| a8f12d1c91 | |||
| 312ec19deb | |||
| 2a04fad4fe | |||
| 5595dc322c | |||
|
|
bda2aba06d | ||
| 69a978de0f | |||
| 4469421871 | |||
|
|
419b64270c | ||
| b4d780e04d | |||
| fc7b035123 | |||
| d71d5881cf | |||
| d914f5aa22 | |||
| 01b9ebd74b | |||
| 9f169b9835 | |||
| fbc3fc2a4d | |||
| 0ac39c646f | |||
| 96ca857e06 | |||
| 23e1afa994 | |||
| e4ddaf0dc3 | |||
| 5bd803a4b4 | |||
| 6987733707 | |||
| 1ce9915c7d | |||
| ea350ab315 | |||
| a98d921848 | |||
| 70c3ca82ea | |||
| 8025858060 | |||
| f9d6936274 | |||
| 60d3fa74fb | |||
| 52228ca5d5 | |||
| 081e44fc05 | |||
| e537f4eb31 | |||
| 7a8b069834 | |||
| cfc8900c5c | |||
| 81ce204502 | |||
| f0a8dfcc21 | |||
| edd8657c92 | |||
| 0b23b816fb | |||
| 8da2fe532e | |||
| 70685874e2 | |||
|
|
fb77cb5aa3 | ||
| 70d574813c | |||
|
|
30b61718a7 | ||
| a37c2f5d13 | |||
|
|
844f5a18d1 | ||
| f3be6ee198 | |||
| 3187d408c5 | |||
| 8fac974b1b | |||
| 7f15909cc6 | |||
| e0484a0533 | |||
| c71c7d6ed6 | |||
| 5516c7fe62 | |||
| 4ac56958b4 | |||
| 9751525a0c | |||
| faf80bfb4b | |||
| 88831685fc | |||
| 2c49018ab7 | |||
| e422e5f4ef | |||
| 2922a4d1ee | |||
| 615b4b866b | |||
| cde6a68591 | |||
| 73382c2c3f | |||
| d0b8cb672a | |||
| 5ba05f4c04 | |||
| c7c082b867 | |||
| 0f12befd11 | |||
| 91cf7cca6a | |||
| e5a6003ace | |||
| 49fd2181a7 | |||
| 02af136fd9 | |||
| ff9f98f8e7 | |||
| b7c93f19cb | |||
| f0be98316c | |||
| d614ad2219 | |||
| bfc078d5aa | |||
| c62b105518 | |||
| d060486d0d | |||
| eec1451743 | |||
| 89a48cbaf7 | |||
| fae1804fb1 | |||
| c8d7dd3e55 | |||
| 6417958ccc | |||
| aaa897c8dc | |||
| 951d01dc4d | |||
| 7af65d997b | |||
| 2d1d1c62dc | |||
| 249fd12db0 | |||
| 3a98f70ba5 | |||
| 2cab4b0de4 | |||
| 3f73a36076 | |||
| c49758fc46 | |||
| 4b31578f6c | |||
| e775fe118b | |||
| adb44241d9 | |||
| 8fd2ee067e | |||
| 62b37b9aa2 | |||
| 8edbbac95f | |||
| f29bbb02a2 | |||
| fca0194a7d | |||
| 623543b7bd | |||
| d95d4dc737 | |||
| 12419c5237 | |||
| 29f262e1a1 | |||
| 2b8d898429 | |||
| 76223b04e9 | |||
| bee4a7db66 | |||
| 339d37937a | |||
| c637b6b84f | |||
| 97fcae3e9d | |||
| 2d01c70c16 | |||
| 0a59cf5c33 | |||
| 9a7608f9a1 | |||
| 63040afee7 | |||
| c9d4254152 | |||
| 10ad32eb6f | |||
| e8bcd88ee1 | |||
| 9fc8c3b74a | |||
| 3891c33204 | |||
| 2408978180 | |||
| f681ca98b2 | |||
| e7668f1ef4 | |||
| 1fd1880424 | |||
| d5df2338a7 | |||
| 1c8c5ae83b | |||
| 94bcb5dc8c | |||
| d41d13d122 | |||
| e86c78a0dc | |||
| 8db24405fa | |||
| f3b213ecec | |||
| 68ceaced0c | |||
| b7ef69813b | |||
| 5715a22b0c | |||
| 0f51bc89c3 | |||
| b3b8b31c0f | |||
| e9ed61a8fd | |||
| 50c4ab049d | |||
| 717b8f5676 | |||
| 0d1b776e78 | |||
| cca2ca4632 | |||
| bbededf3b9 | |||
| d44c5bdf94 | |||
| 27b9cbe814 | |||
| 8933ad9d14 | |||
| 17fd5e13d5 | |||
| fec2f7b6f6 | |||
| c86781c32b | |||
| a1684f485c | |||
| 0f20e459e9 | |||
| d9491dea9c | |||
| daaa4dc345 | |||
| 8ffd842c38 | |||
| 1f21afeb72 | |||
| 3cdaa75fc1 | |||
| 482a335d36 | |||
| 68e6c74a67 | |||
| b60ab3f392 | |||
| ede3df12ef | |||
| 6c22d889a1 | |||
| 140e4a9054 | |||
| 1188320844 | |||
| 9d3c72acff | |||
| 7db609deec | |||
| 02245e6684 | |||
| 124857cc9c | |||
| bc2d91f9e7 | |||
| c33199465c | |||
| e1e0469e41 | |||
| f2bcf68da2 | |||
| 17488a6f42 | |||
| a94c0c0b14 | |||
| a23f999eee | |||
| e4e6cfdd47 | |||
| c46365576d | |||
| 376086ae0f | |||
| 5343b78750 | |||
| 32efe380b7 | |||
| a008cf381a | |||
| a5a4d66655 | |||
| 47284fee98 | |||
| 91839dc426 | |||
| be9d12f181 | |||
| 2619e3ea29 | |||
| 056fd04ddf | |||
| 01d901a61d | |||
| d37ba84a74 | |||
| 381e09dd1d | |||
| f3ca492b49 | |||
| 2f8df3f39d | |||
| 3ecbd964ba | |||
| 8430069b45 | |||
| 123227a50e | |||
| 83b104ecf3 | |||
| ec814a8c94 | |||
| 3c79d044d4 | |||
| f4554b8a4b | |||
| 397f7a7975 | |||
| cb932ad6ef | |||
| dbdac5870a | |||
| 01f62297fc | |||
| cbed65de66 | |||
| 3491b4b1ba | |||
| 2315f2588f | |||
| 31fc4f4d0c | |||
| 0fd1b7e142 | |||
| 0333f9e722 | |||
| 9b1aad884e | |||
| e47e266570 | |||
| d1fefcca7d | |||
| b5fc03e94f | |||
| ac13a39e7c | |||
| 002d723d0e | |||
| a25263b721 |
310 changed files with 35527 additions and 12221 deletions
|
|
@ -82,8 +82,14 @@
|
|||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
# AliasUsage only for lib and support; test files excluded (many nested module refs by design)
|
||||
{Credo.Check.Design.AliasUsage,
|
||||
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||
[
|
||||
priority: :low,
|
||||
if_nested_deeper_than: 2,
|
||||
if_called_more_often_than: 0,
|
||||
files: %{excluded: ["test/"]}
|
||||
]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
|
|
|
|||
101
.drone.yml
101
.drone.yml
|
|
@ -4,7 +4,7 @@ name: check-fast
|
|||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:18.3
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -37,28 +37,27 @@ steps:
|
|||
- name: lint
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
commands:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- timeout --signal=KILL 3m mix local.hex --force
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
- mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- timeout --signal=KILL 10m mix compile --warnings-as-errors
|
||||
- mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- timeout --signal=KILL 3m mix format --check-formatted
|
||||
- mix format --check-formatted
|
||||
# Security checks
|
||||
- timeout --signal=KILL 10m mix sobelow --config
|
||||
- mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- timeout --signal=KILL 10m mix deps.audit
|
||||
- mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- timeout --signal=KILL 10m mix hex.audit
|
||||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- timeout --signal=KILL 15m mix credo
|
||||
- mix credo --strict
|
||||
# Check that translations are up to date
|
||||
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:18.3
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -80,13 +79,12 @@ steps:
|
|||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
- mix deps.get
|
||||
# Run fast tests (excludes slow/performance and UI tests)
|
||||
- timeout --signal=KILL 20m mix test --exclude slow --exclude ui
|
||||
- mix test --exclude slow --exclude ui --max-cases 2
|
||||
|
||||
- name: rebuild-cache
|
||||
image: drillster/drone-volume-cache
|
||||
|
|
@ -111,7 +109,7 @@ name: check-full
|
|||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:18.3
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -146,28 +144,27 @@ steps:
|
|||
- name: lint
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
commands:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- timeout --signal=KILL 3m mix local.hex --force
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
- mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- timeout --signal=KILL 10m mix compile --warnings-as-errors
|
||||
- mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- timeout --signal=KILL 3m mix format --check-formatted
|
||||
- mix format --check-formatted
|
||||
# Security checks
|
||||
- timeout --signal=KILL 10m mix sobelow --config
|
||||
- mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- timeout --signal=KILL 10m mix deps.audit
|
||||
- mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- timeout --signal=KILL 10m mix hex.audit
|
||||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- timeout --signal=KILL 15m mix credo
|
||||
- mix credo --strict
|
||||
# Check that translations are up to date
|
||||
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
image: docker.io/library/postgres:18.3
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -189,13 +186,12 @@ steps:
|
|||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
- set -eu
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- timeout --signal=KILL 10m mix deps.get
|
||||
- mix deps.get
|
||||
# Run all tests (including slow/performance and UI tests)
|
||||
- timeout --signal=KILL 30m mix test
|
||||
- mix test
|
||||
|
||||
- name: rebuild-cache
|
||||
image: drillster/drone-volume-cache
|
||||
|
|
@ -223,24 +219,8 @@ trigger:
|
|||
- 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:
|
||||
|
|
@ -260,6 +240,33 @@ steps:
|
|||
depends_on:
|
||||
- check-fast
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-release
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- 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
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
depends_on:
|
||||
- check-fast
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
|
@ -277,7 +284,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.97
|
||||
image: renovate/renovate:43.59
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
28
.env.example
28
.env.example
|
|
@ -14,6 +14,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
|||
# Optional: Admin user (created/updated on container start via Release.seed_admin)
|
||||
# In production, set these so the first admin can log in. Change password without redeploy:
|
||||
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
|
||||
# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields).
|
||||
# ADMIN_EMAIL=admin@example.com
|
||||
# ADMIN_PASSWORD=secure-password
|
||||
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
|
||||
|
|
@ -22,11 +23,34 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
|||
# 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_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
||||
# OIDC_CLIENT_SECRET=your-oidc-client-secret
|
||||
|
||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
||||
# OIDC_ADMIN_GROUP_NAME=admin
|
||||
# OIDC_GROUPS_CLAIM=groups
|
||||
|
||||
# Optional: Show only OIDC sign-in on login page (hide password form).
|
||||
# When set to true and OIDC is configured, users see only the Single Sign-On button.
|
||||
# OIDC_ONLY=true
|
||||
|
||||
# Optional: Vereinfacht accounting integration (finance-contacts sync)
|
||||
# If set, these override values from Settings UI; those fields become read-only.
|
||||
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
|
||||
# VEREINFACHT_API_KEY=your-api-key
|
||||
# VEREINFACHT_CLUB_ID=2
|
||||
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
||||
|
||||
# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI.
|
||||
# Export current UI settings to .env: mix mv.export_smtp_to_env
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USERNAME=user
|
||||
# SMTP_PASSWORD=secret
|
||||
# SMTP_PASSWORD_FILE=/run/secrets/smtp_password
|
||||
# SMTP_SSL=tls
|
||||
# SMTP_VERIFY_PEER=false
|
||||
# MAIL_FROM_EMAIL=noreply@example.com
|
||||
# MAIL_FROM_NAME=Mila
|
||||
|
|
|
|||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -5,7 +5,43 @@ 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]
|
||||
## [1.1.1] - 2026-03-16
|
||||
|
||||
### Added
|
||||
- **FORCE_SEEDS** – Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user.
|
||||
- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
|
||||
- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
|
||||
|
||||
### Changed
|
||||
- **Seeds run only when needed** – Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run.
|
||||
- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
|
||||
|
||||
### Fixed
|
||||
- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
|
||||
|
||||
## [1.1.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone.
|
||||
- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
|
||||
- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
|
||||
- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
|
||||
- **Duplicate-email handling for join form** – If an applicant’s email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration).
|
||||
- **Reviewed-by display for join requests** – Approval UI shows who reviewed a request via a dedicated display field, without loading the User record.
|
||||
- **Improved field order and seeds for join request approval** – Approval screen field order improved; seed data updated for join-form and approval flows.
|
||||
- **Tests for SMTP mailer configuration** – Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView).
|
||||
|
||||
### Changed
|
||||
- **SMTP settings layout** – SMTP options reordered and grouped in global settings for clearer configuration.
|
||||
- **Join confirmation mail** – Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message.
|
||||
- **i18n** – Gettext catalogs updated for new and changed strings.
|
||||
|
||||
### Fixed
|
||||
- **Login page translation** – Corrected translation/locale handling on the sign-in page.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] and earlier
|
||||
|
||||
### Added
|
||||
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ We are building a membership management system (Mila) using the following techno
|
|||
7. [Documentation Standards](#7-documentation-standards)
|
||||
8. [Accessibility Guidelines](#8-accessibility-guidelines)
|
||||
|
||||
**Related documents:**
|
||||
- **UI / UX:** [`DESIGN_GUIDELINES.md`](../DESIGN_GUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
|
||||
- **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields).
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup and Architectural Conventions
|
||||
|
|
@ -81,9 +85,14 @@ lib/
|
|||
├── membership/ # Membership domain
|
||||
│ ├── membership.ex # Domain definition
|
||||
│ ├── member.ex # Member resource
|
||||
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
|
||||
│ ├── join_request/ # JoinRequest changes (Helpers, SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest, ApproveRequest, RejectRequest)
|
||||
│ ├── custom_field.ex # Custom field (definition) resource
|
||||
│ ├── custom_field_value.ex # Custom field value resource
|
||||
│ ├── setting.ex # Global settings (singleton resource)
|
||||
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)
|
||||
│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test)
|
||||
│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending)
|
||||
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
|
||||
│ ├── group.ex # Group resource
|
||||
│ ├── member_group.ex # MemberGroup join table resource
|
||||
│ └── email.ex # Email custom type
|
||||
|
|
@ -112,10 +121,17 @@ lib/
|
|||
│ ├── membership_fees/ # Membership fee business logic
|
||||
│ │ ├── cycle_generator.ex # Cycle generation algorithm
|
||||
│ │ └── calendar_cycles.ex # Calendar cycle calculations
|
||||
│ ├── vereinfacht/ # Vereinfacht accounting API integration
|
||||
│ │ ├── client.ex # HTTP client (finance-contacts: create, update, find by email)
|
||||
│ │ ├── vereinfacht.ex # Business logic (sync_member, sync_members_without_contact)
|
||||
│ │ ├── sync_flash.ex # Flash message helpers for sync results
|
||||
│ │ └── changes/ # Ash changes (SyncContact, sync linked member)
|
||||
│ ├── helpers.ex # Shared helper functions (ash_actor_opts)
|
||||
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
|
||||
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
|
||||
│ ├── application.ex # OTP application
|
||||
│ ├── mailer.ex # Email mailer
|
||||
│ ├── smtp/
|
||||
│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer
|
||||
│ ├── release.ex # Release tasks
|
||||
│ ├── repo.ex # Database repository
|
||||
│ ├── secrets.ex # Secret management
|
||||
|
|
@ -266,6 +282,16 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
```
|
||||
|
||||
### 1.2.1 Database Seeds
|
||||
|
||||
Seeds are split into **bootstrap** and **dev**. They run on every start (e.g. `just run`, Docker entrypoint) but **exit early** if already applied so startup stays fast and no duplicate data is created.
|
||||
|
||||
- **`priv/repo/seeds.exs`** – Entrypoint. If the admin user (ADMIN_EMAIL or default) already exists, skips entirely (unless `FORCE_SEEDS=true`); otherwise runs `seeds_bootstrap.exs` and, in dev/test, `seeds_dev.exs`.
|
||||
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
|
||||
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
|
||||
|
||||
In production, running `mix run priv/repo/seeds.exs` (or `Mv.Release.run_seeds/0`) executes only the bootstrap part when not yet applied (no dev seeds unless `RUN_DEV_SEEDS=true`). The “already applied” check uses `Mv.Release.bootstrap_seeds_applied?/0` (admin user exists). Set `FORCE_SEEDS=true` to re-run seeds even when already applied.
|
||||
|
||||
### 1.3 Domain-Driven Design
|
||||
|
||||
**Use Ash Domains for Context Boundaries:**
|
||||
|
|
@ -385,6 +411,8 @@ def process_user(user), do: {:ok, perform_action(user)}
|
|||
|
||||
### 2.3 Error Handling
|
||||
|
||||
**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
|
||||
|
||||
**Use Tagged Tuples:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -623,6 +651,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
```
|
||||
|
||||
**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
|
||||
|
||||
**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
|
||||
|
||||
**Component Design:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -977,9 +1009,9 @@ defmodule Mv.Accounts.User do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oauth2 :rauthy do
|
||||
oidc :oidc do
|
||||
client_id fn _, _ ->
|
||||
Application.fetch_env!(:mv, :rauthy)[:client_id]
|
||||
Application.fetch_env!(:mv, :oidc)[:client_id]
|
||||
end
|
||||
# ... other config
|
||||
end
|
||||
|
|
@ -1112,6 +1144,12 @@ let liveSocket = new LiveSocket("/live", Socket, {
|
|||
})
|
||||
```
|
||||
|
||||
**Vendor assets (third-party JS):**
|
||||
|
||||
Some JavaScript libraries are committed as vendored files in `assets/vendor/` (e.g. `topbar`, `sortable.js`) when they are not available as npm packages or we need a specific build. Document their origin and how to update them:
|
||||
|
||||
- **Sortable.js** (`assets/vendor/sortable.js`): From [SortableJS](https://github.com/SortableJS/Sortable), version noted in the file header (e.g. `/*! Sortable 1.15.6 - MIT ... */`). To update: download the desired release from the repo and replace the file; keep the header comment for traceability.
|
||||
|
||||
### 3.8 Code Quality: Credo
|
||||
|
||||
**Static Code Analysis:**
|
||||
|
|
@ -1228,36 +1266,71 @@ mix deps.update phoenix
|
|||
mix hex.outdated
|
||||
```
|
||||
|
||||
### 3.11 Email: Swoosh
|
||||
### 3.11 Email: Swoosh and Phoenix.Swoosh
|
||||
|
||||
**Mailer Configuration:**
|
||||
**Mailer and from address:**
|
||||
|
||||
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
|
||||
- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`.
|
||||
- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
|
||||
- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error.
|
||||
|
||||
**SMTP configuration:**
|
||||
|
||||
- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht).
|
||||
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
|
||||
- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox).
|
||||
- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier.
|
||||
- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
|
||||
- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
|
||||
- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
|
||||
- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically.
|
||||
- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send.
|
||||
- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config.
|
||||
- **TLS in OTP 27:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`.
|
||||
- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI.
|
||||
- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases).
|
||||
- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`.
|
||||
|
||||
**AshAuthentication senders:**
|
||||
|
||||
- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
|
||||
|
||||
**Join confirmation email:**
|
||||
|
||||
- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
|
||||
|
||||
**Unified layout (transactional emails):**
|
||||
|
||||
- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).
|
||||
- Templates live under `lib/mv_web/templates/emails/` (bodies) and `lib/mv_web/templates/emails/layouts/` (layout). Use Gettext in templates for i18n.
|
||||
- See `MvWeb.Emails.JoinConfirmationEmail`, `Mv.Accounts.User.Senders.SendNewUserConfirmationEmail`, `SendPasswordResetEmail` for the pattern; see `docs/email-layout-mockup.md` for layout structure.
|
||||
|
||||
**Sending with layout:**
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :mv
|
||||
end
|
||||
```
|
||||
use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
**Sending Emails:**
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(gettext("Subject"))
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||
|> render_body("template_name.html", %{assigns})
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Accounts.WelcomeEmail do
|
||||
use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
|
||||
import Swoosh.Email
|
||||
|
||||
def send(user) do
|
||||
new()
|
||||
|> to({user.name, user.email})
|
||||
|> from({"Mila", "noreply@mila.example.com"})
|
||||
|> subject("Welcome to Mila!")
|
||||
|> render_body("welcome.html", %{user: user})
|
||||
|> Mv.Mailer.deliver()
|
||||
end
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
|
||||
end
|
||||
```
|
||||
|
||||
### 3.12 Internationalization: Gettext
|
||||
|
||||
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
|
||||
|
||||
**Terminology (DE):** Use consistent terms in translations: “Benutzer*in” / “Benutzer*innen” (not “Nutzer*in”), “E-Mail” (with hyphen, capital M), “CSV-Datei” / “CSV-Import” (compound with hyphen). Keep placeholders (e.g. `%{count}`, `%{reason}`) in msgstr identical to msgid where applicable.
|
||||
|
||||
**Define Translations:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -1267,6 +1340,9 @@ gettext("Welcome to Mila")
|
|||
# With interpolation
|
||||
gettext("Hello, %{name}!", name: user.name)
|
||||
|
||||
# Plural: always pass count binding when message uses %{count}
|
||||
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
|
||||
|
||||
# Domain-specific translations
|
||||
dgettext("auth", "Sign in with email")
|
||||
```
|
||||
|
|
@ -1274,13 +1350,16 @@ dgettext("auth", "Sign in with email")
|
|||
**Extract and Merge:**
|
||||
|
||||
```bash
|
||||
# Extract new translatable strings
|
||||
mix gettext.extract
|
||||
# Extract new translatable strings and merge into existing .po files (recommended)
|
||||
mix gettext.extract --merge
|
||||
|
||||
# Merge into existing translations
|
||||
# Alternative: extract only, then merge separately
|
||||
mix gettext.extract
|
||||
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
||||
```
|
||||
|
||||
**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source.
|
||||
|
||||
### 3.13 Task Runner: Just
|
||||
|
||||
**Common Commands:**
|
||||
|
|
@ -1507,6 +1586,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
```
|
||||
|
||||
**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
|
||||
|
||||
#### 4.3.5 Component Tests
|
||||
|
||||
Test function components:
|
||||
|
|
@ -1853,7 +1934,7 @@ authentication do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oauth2 :rauthy do
|
||||
oidc :oidc do
|
||||
# OIDC configuration
|
||||
end
|
||||
end
|
||||
|
|
@ -1876,6 +1957,8 @@ policies do
|
|||
end
|
||||
```
|
||||
|
||||
**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
|
||||
|
||||
**Actor Handling in LiveViews:**
|
||||
|
||||
Always use the `current_actor/1` helper for consistent actor access:
|
||||
|
|
@ -2078,7 +2161,7 @@ plug :protect_from_forgery
|
|||
|
||||
```elixir
|
||||
# config/runtime.exs
|
||||
config :mv, :rauthy,
|
||||
config :mv, :oidc,
|
||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
base_url: System.get_env("OIDC_BASE_URL")
|
||||
|
|
@ -2707,7 +2790,9 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.2 ARIA Labels and Roles
|
||||
|
||||
**Use ARIA Attributes When Necessary:**
|
||||
**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.
|
||||
|
||||
**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
|
||||
|
||||
```heex
|
||||
<!-- Icon-only buttons need labels -->
|
||||
|
|
@ -2755,6 +2840,14 @@ Building accessible applications ensures that all users, including those with di
|
|||
<div phx-click="action">Click me</div>
|
||||
```
|
||||
|
||||
**Tables (Core Component `<.table>` with `row_click`):**
|
||||
|
||||
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||
|
||||
**Empty table cells (missing values):**
|
||||
|
||||
- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
|
||||
|
||||
**Tab Order:**
|
||||
|
||||
- Ensure logical tab order matches visual order
|
||||
|
|
@ -2764,7 +2857,11 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.4 Color and Contrast
|
||||
|
||||
**Ensure Sufficient Contrast:**
|
||||
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
|
||||
|
||||
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
|
||||
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
|
||||
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
|
||||
|
||||
```elixir
|
||||
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
||||
|
|
@ -2832,12 +2929,14 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
**Required Fields:**
|
||||
|
||||
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. The Vereinfacht integration does not add extra required member fields (the external API accepts a minimal payload when creating contacts and supports filter-by-email for lookup).
|
||||
|
||||
```heex
|
||||
<!-- Mark required fields -->
|
||||
<!-- Mark required fields (value from settings or always true for email) -->
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required
|
||||
required={@member_field_required_map[:first_name]}
|
||||
aria-required="true"
|
||||
/>
|
||||
```
|
||||
|
|
@ -2931,11 +3030,11 @@ end
|
|||
**Announce Dynamic Content:**
|
||||
|
||||
```heex
|
||||
<!-- Search results announcement -->
|
||||
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<%= if @searched do %>
|
||||
<span class="sr-only">
|
||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -2981,24 +3080,56 @@ end
|
|||
- [ ] Skip links are available
|
||||
- [ ] Tables have proper structure (th, scope, caption)
|
||||
- [ ] ARIA labels used for icon-only buttons
|
||||
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
||||
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
|
||||
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
|
||||
|
||||
### 8.11 DaisyUI Accessibility
|
||||
### 8.11 Modals and Dialogs
|
||||
|
||||
DaisyUI components are designed with accessibility in mind, but ensure:
|
||||
Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).
|
||||
|
||||
**Structure and semantics:**
|
||||
|
||||
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
|
||||
- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose.
|
||||
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).
|
||||
|
||||
**Focus management (WCAG 2.4.3):**
|
||||
|
||||
- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
|
||||
- If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
|
||||
- If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
|
||||
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.
|
||||
|
||||
**Layout and consistency:**
|
||||
|
||||
- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
|
||||
- Place Cancel (or neutral) first, primary/danger action second.
|
||||
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.
|
||||
|
||||
**Closing:**
|
||||
|
||||
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
||||
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
|
||||
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
|
||||
|
||||
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
||||
|
||||
### 8.12 DaisyUI Accessibility
|
||||
|
||||
DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:
|
||||
|
||||
```heex
|
||||
<!-- Modal accessibility -->
|
||||
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
||||
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
|
||||
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
|
||||
<div class="modal-box">
|
||||
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
||||
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
|
||||
<p><%= gettext("Are you sure?") %></p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
|
||||
<%= gettext("Cancel") %>
|
||||
</button>
|
||||
<button class="btn btn-error" phx-click="confirm-delete">
|
||||
<%= gettext("Delete") %>
|
||||
</button>
|
||||
</.button>
|
||||
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
457
DESIGN_GUIDELINES.md
Normal file
457
DESIGN_GUIDELINES.md
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
|
||||
|
||||
## Purpose
|
||||
This document defines Mila’s **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages:
|
||||
|
||||
- consistent DaisyUI usage
|
||||
- typography & spacing
|
||||
- button intent & labeling
|
||||
- list/search/filter UX
|
||||
- tables behavior (row click, tooltips, alignment)
|
||||
- flash/toast UX (position, stacking, auto-dismiss, tones)
|
||||
- standard page skeletons (index/detail/form)
|
||||
- microcopy conventions (German “du” tone)
|
||||
|
||||
> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `docs/CODE_GUIDELINES.md`.
|
||||
> This document focuses on **visual + UX** consistency and references engineering rules where needed.
|
||||
|
||||
---
|
||||
|
||||
## 1) Principles
|
||||
|
||||
### 1.1 Components first (no raw DaisyUI classes in views)
|
||||
- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`).
|
||||
- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents.
|
||||
- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc.
|
||||
|
||||
### 1.2 DaisyUI for look, Tailwind for layout
|
||||
- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`).
|
||||
- Tailwind: spacing, alignment, responsiveness.
|
||||
|
||||
### 1.3 Semantics over hard-coded colors
|
||||
- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …).
|
||||
- **MUST:** Express intent via component props / DaisyUI semantic variants.
|
||||
|
||||
---
|
||||
|
||||
## 2) Page Skeleton & “Chrome” (mandatory)
|
||||
|
||||
### 2.1 Standard page layout
|
||||
Every authenticated page should follow the same structure:
|
||||
|
||||
1) `<.header>` (title + optional subtitle + actions)
|
||||
2) content area with consistent vertical rhythm (`mt-6 space-y-6`)
|
||||
3) optional footer actions for forms
|
||||
|
||||
**MUST:** Use `<.header>` on every page (except login/public pages).
|
||||
**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.
|
||||
|
||||
### 2.2 Edit/New form header: Back button left (mandatory)
|
||||
|
||||
For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):
|
||||
|
||||
- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right).
|
||||
- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
|
||||
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
|
||||
- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right.
|
||||
|
||||
**Template for form pages:**
|
||||
```heex
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @resource)} variant="neutral">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
Page title (e.g. “Edit Member” or “New User”)
|
||||
<:subtitle>Short explanation.</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
```
|
||||
|
||||
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
|
||||
|
||||
### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm)
|
||||
|
||||
Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:
|
||||
|
||||
- **Component:** `Layouts.public_page` renders:
|
||||
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
|
||||
- Main content slot, Flash group. No sidebar, no authenticated-layout logic.
|
||||
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
|
||||
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
|
||||
- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`.
|
||||
- **Implementation:**
|
||||
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the library’s Banner is hidden via `show_banner: false`).
|
||||
- **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form.
|
||||
- **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `<Layouts.public_page flash={@flash}>` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in.
|
||||
|
||||
## 3) Typography (system)
|
||||
|
||||
Use these standard roles:
|
||||
|
||||
| Role | Use | Class |
|
||||
|---|---|---|
|
||||
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
|
||||
| Subtitle | helper under title | `text-sm text-base-content/85` |
|
||||
| Section title (H2) | section headings | `text-lg font-semibold` |
|
||||
| Helper text | under inputs | `text-sm text-base-content/85` |
|
||||
| Fine print | small hints | `text-xs text-base-content/80` |
|
||||
| Empty state | no data | `text-base-content/80 italic` |
|
||||
| Destructive text | danger | `text-error` |
|
||||
|
||||
**MUST:** Page titles via `<.header>`.
|
||||
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
|
||||
|
||||
**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `<span class="label-text">` as usual; no extra classes needed.
|
||||
|
||||
---
|
||||
|
||||
## 4) States: Loading, Empty, Error (mandatory consistency)
|
||||
|
||||
### 4.1 Loading state
|
||||
- **MUST:** Show a consistent loading indicator when data is not ready.
|
||||
- **MUST NOT:** Render empty states while loading (avoid flicker).
|
||||
- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area.
|
||||
|
||||
### 4.2 Empty state pattern
|
||||
Empty states must be consistent:
|
||||
- short message
|
||||
- optional primary CTA (“Create …”)
|
||||
- optional secondary help link
|
||||
|
||||
**Example:**
|
||||
```heex
|
||||
<div class="space-y-3">
|
||||
<p class="text-base-content/60 italic">No members yet.</p>
|
||||
<.button variant="primary" navigate={~p"/members/new"}>Create member</.button>
|
||||
</div>
|
||||
|
||||
### 4.3 Error state pattern
|
||||
- **MUST:** Use flash/toast for global errors.
|
||||
- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed.
|
||||
|
||||
---
|
||||
|
||||
## 5) Buttons (intent, labels, variants)
|
||||
|
||||
### 5.1 Decision rule: action vs status
|
||||
- **MUST:** Button labels describe **actions** (verb-first):
|
||||
- ✅ Save, Create member, Send invite, Import CSV
|
||||
- ❌ Active, Success, Done (status belongs elsewhere)
|
||||
- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs.
|
||||
|
||||
### 5.2 Standard variants (mandatory set)
|
||||
Buttons must be rendered via `<.button>` and mapped to DaisyUI internally.
|
||||
|
||||
**Supported variants:**
|
||||
- `primary` (main CTA)
|
||||
- `secondary` (supporting)
|
||||
- `neutral` (cancel/back)
|
||||
- `ghost` (low emphasis; table/toolbars)
|
||||
- `outline` (alternative CTA)
|
||||
- `danger` (destructive)
|
||||
- `link` (inline; rare)
|
||||
- `icon` (icon-only)
|
||||
|
||||
**Sizes:** `sm`, `md` (default), `lg` (rare)
|
||||
|
||||
### 5.3 Placement rules
|
||||
- Header CTA inside `<.header><:actions>`.
|
||||
- Form footer: primary right; cancel/secondary left.
|
||||
- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows).
|
||||
|
||||
### 5.4 Primary vs Secondary (UX consistency rules)
|
||||
|
||||
#### One primary action per screen
|
||||
- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import).
|
||||
- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary.
|
||||
|
||||
#### Primary vs Secondary meaning
|
||||
- Primary = the most important/most common action to complete the user task.
|
||||
- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis.
|
||||
|
||||
#### Order and placement (choose and apply consistently)
|
||||
We follow these ordering rules:
|
||||
- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**.
|
||||
- Forms: Decide once (primary-left OR primary-right) and apply everywhere.
|
||||
- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge).
|
||||
|
||||
#### Cancel/Back consistency
|
||||
- MUST: Cancel/Back is **never** styled as primary.
|
||||
- MUST: Cancel/Back placement is consistent across the app (same side, same label).
|
||||
|
||||
#### Implementation requirement
|
||||
- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props.
|
||||
- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc.
|
||||
|
||||
#### Ghost buttons (accessibility requirements)
|
||||
|
||||
Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but:
|
||||
|
||||
- MUST: Focus indicator is clearly visible (do not remove outlines).
|
||||
- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1).
|
||||
- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip.
|
||||
- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px).
|
||||
If these cannot be met, use `secondary`/`outline` instead of `ghost`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6) Forms (structure + interaction rules)
|
||||
|
||||
### 6.1 Structure
|
||||
- **MUST:** Forms are grouped into `<.form_section title="…">`.
|
||||
- **MUST:** All inputs via `<.input>`.
|
||||
|
||||
### 6.2 Validation timing (consistent UX)
|
||||
- **MUST:** Validate on submit always.
|
||||
- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”.
|
||||
- **MUST:** Define a consistent “when errors appear” rule:
|
||||
- Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere).
|
||||
|
||||
> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default).
|
||||
|
||||
### 6.3 Required fields
|
||||
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
|
||||
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
|
||||
|
||||
### 6.4 Form layout (settings / long forms)
|
||||
- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths).
|
||||
- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header).
|
||||
- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels.
|
||||
|
||||
---
|
||||
|
||||
## 7) Lists, Search & Filters (mandatory UX consistency)
|
||||
|
||||
### 7.1 Standard filter/search bar pattern
|
||||
- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere).
|
||||
- Recommended: top area above the table, aligned with page actions.
|
||||
- **MUST:** Always provide “Clear filters” when filters are active.
|
||||
- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently).
|
||||
|
||||
### 7.2 URL behavior (UX rule)
|
||||
- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs.
|
||||
- Use `push_navigate` for actual page transitions: details, edit, new.
|
||||
|
||||
---
|
||||
|
||||
## 8) Tables (mandatory UX)
|
||||
|
||||
### 8.1 Default behavior: row click opens details
|
||||
- **DEFAULT:** Clicking a row navigates to the details page.
|
||||
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
|
||||
- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index.
|
||||
|
||||
**IMPORTANT (correctness with our `<.table>` CoreComponent):**
|
||||
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
|
||||
|
||||
So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
|
||||
|
||||
✅ Correct pattern (one click handler that both stops propagation and triggers an event):
|
||||
```heex
|
||||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end}
|
||||
>
|
||||
<:col :let={m} label="Name">
|
||||
<%= m.last_name %>, <%= m.first_name %>
|
||||
</:col>
|
||||
|
||||
<:col :let={m} label="Newsletter">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={m.newsletter}
|
||||
phx-click={JS.push("toggle_newsletter", value: %{id: m.id}) |> JS.stop_propagation()}
|
||||
/>
|
||||
</:col>
|
||||
|
||||
<:action :let={m}>
|
||||
<.button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
navigate={~p"/members/#{m.id}/edit"}
|
||||
phx-click={JS.stop_propagation()}
|
||||
>
|
||||
Edit
|
||||
</.button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
Notes:
|
||||
- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it won’t trigger row navigation.
|
||||
- The Edit button also stops propagation to avoid accidental row navigation when clicked.
|
||||
|
||||
### 8.2 Tooltips (mandatory where needed)
|
||||
- **MUST:** Tooltips for:
|
||||
- icon-only actions
|
||||
- truncated content
|
||||
- status badges that require explanation
|
||||
- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent).
|
||||
- **MUST NOT:** Scatter ad-hoc tooltip markup in views.
|
||||
|
||||
### 8.3 Alignment & density conventions
|
||||
- **MUST:** Text columns left-aligned.
|
||||
- **MUST:** Numeric columns right-aligned.
|
||||
- **MUST:** Action column right-aligned.
|
||||
- **SHOULD:** Table density is consistent:
|
||||
- default density for most tables
|
||||
- a single “dense” option only if needed (via a prop, not per-page random classes)
|
||||
|
||||
### 8.4 Truncation standard
|
||||
- **MUST:** Truncate long values consistently (same max widths for name/email-like fields).
|
||||
- **MUST:** Tooltip reveals full value when truncated.
|
||||
|
||||
### 8.5 Loading/Lists/Tables: keep filters visible on desktop
|
||||
- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-<offset>)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling.
|
||||
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
|
||||
- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
|
||||
|
||||
### 8.6 Empty table cells (missing values)
|
||||
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
|
||||
- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells.
|
||||
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
|
||||
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
|
||||
|
||||
---
|
||||
|
||||
## 9) Flash / Toast messages (mandatory UX)
|
||||
|
||||
### 9.1 Location + stacking
|
||||
- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent).
|
||||
- **MUST:** Stack all flash messages with consistent spacing.
|
||||
- **SHOULD:** Newest appears on top.
|
||||
|
||||
### 9.2 Auto-dismiss
|
||||
- **MUST:** Flash messages disappear automatically:
|
||||
- info/success: 4–6s
|
||||
- warning: 6–8s
|
||||
- error: 8–12s (or manual dismiss for critical errors)
|
||||
- **MUST:** Keep a dismiss button for accessibility and user control.
|
||||
- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.
|
||||
|
||||
### 9.3 Variants (unified)
|
||||
- Supported semantic variants: `info`, `success`, `warning`, `error`.
|
||||
- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app.
|
||||
|
||||
### 9.4 Accessibility
|
||||
- Flash must work with screen readers (live region behavior belongs in the flash component implementation).
|
||||
- See `CODE_GUIDELINES.md` Accessibility → live regions.
|
||||
|
||||
---
|
||||
|
||||
## 10) Mutations & feedback patterns (create/update/delete/import)
|
||||
|
||||
### 10.1 Mutation feedback is always two-part
|
||||
For create/update/delete:
|
||||
- **MUST:** Show a toast/flash message
|
||||
- **MUST:** Show a visible UI update (navigate, row removed, values updated)
|
||||
|
||||
No “silent success”.
|
||||
|
||||
### 10.2 Destructive actions: one standard confirmation pattern
|
||||
- **MUST:** All destructive actions use the same confirm style and wording conventions.
|
||||
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible.
|
||||
|
||||
**Recommended copy style:**
|
||||
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
||||
- Buttons: `Cancel` (neutral) + `Delete` (danger).
|
||||
|
||||
### 10.3 Dialogs and modals (mandatory)
|
||||
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
|
||||
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
|
||||
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).
|
||||
|
||||
---
|
||||
|
||||
## 11) Detail pages (consistent structure)
|
||||
|
||||
Detail pages should not drift into random layouts.
|
||||
|
||||
**MUST:** Use consistent structure:
|
||||
- header with primary action (Edit)
|
||||
- sections/cards for grouped info
|
||||
- “Danger zone” section at bottom for destructive actions
|
||||
|
||||
---
|
||||
|
||||
## 12) Navigation rules (UX consistency)
|
||||
|
||||
- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs.
|
||||
- **MUST:** `push_navigate` for page transitions: detail/edit/new.
|
||||
- **SHOULD:** Back button behavior must feel predictable (URL reflects state).
|
||||
|
||||
---
|
||||
|
||||
## 13) Microcopy conventions (German “du” tone + glossary)
|
||||
|
||||
### 13.1 Tone
|
||||
- **MUST:** All German user-facing text uses informal address (“du”).
|
||||
- **MUST:** Use consistent verbs for common actions:
|
||||
- Save: “Speichern”
|
||||
- Cancel: “Abbrechen”
|
||||
- Delete: “Löschen”
|
||||
- Edit: “Bearbeiten”
|
||||
|
||||
### 13.2 Preferred terms (starter glossary)
|
||||
- Member: “Mitglied”
|
||||
- Fee/Contribution: “Beitrag”
|
||||
- Settings: “Einstellungen”
|
||||
- Group: “Gruppe”
|
||||
- Import/Export: “Import/Export”
|
||||
- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX)
|
||||
|
||||
Add to this glossary when new terminology appears.
|
||||
|
||||
---
|
||||
|
||||
## 14) Destructive actions: Delete flow (canonical)
|
||||
|
||||
This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.
|
||||
|
||||
### Tables: no row action buttons
|
||||
- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
|
||||
- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.
|
||||
|
||||
### Navigation: row click → details
|
||||
- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
|
||||
- **MUST NOT:** Use the table for primary edit/delete actions.
|
||||
|
||||
### Edit: from details header, not from table
|
||||
- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
|
||||
- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.
|
||||
|
||||
### Delete: only via “Danger zone”
|
||||
- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
|
||||
- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
|
||||
- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.
|
||||
|
||||
### Danger zone layout and wording (canonical pattern)
|
||||
- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
|
||||
- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
|
||||
- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
|
||||
- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.
|
||||
|
||||
### Confirmation and button semantics
|
||||
- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
|
||||
- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
|
||||
- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.
|
||||
|
||||
### Accessibility
|
||||
- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
|
||||
- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
|
||||
- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).
|
||||
|
||||
### Authorization visibility
|
||||
- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
|
||||
- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.
|
||||
|
||||
---
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -7,25 +7,25 @@
|
|||
# This file is based on these images:
|
||||
#
|
||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
|
||||
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
|
||||
#
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
|
||||
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
|
||||
ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
|
@ -64,7 +64,7 @@ RUN mix release
|
|||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Set the locale
|
||||
|
|
|
|||
3
Justfile
3
Justfile
|
|
@ -10,6 +10,7 @@ install-dependencies:
|
|||
mix deps.get
|
||||
|
||||
migrate-database:
|
||||
mix compile
|
||||
mix ash.setup
|
||||
|
||||
reset-database:
|
||||
|
|
@ -31,7 +32,7 @@ gettext:
|
|||
lint:
|
||||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
mix credo --strict
|
||||
# Check that all German translations are filled (UI must be in German)
|
||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
|
||||
|
||||
[](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
|
||||
[](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung)
|
||||

|
||||
|
||||
## 🚧 Project Status
|
||||
|
||||
⚠️ **Early development** — not production-ready. Expect breaking changes.
|
||||
⚠️ **First Version** — Expect breaking changes.
|
||||
Contributions and feedback are welcome!
|
||||
|
||||
## ✨ Overview
|
||||
|
|
@ -32,10 +32,10 @@ Most membership tools for clubs are either:
|
|||
|
||||
Our philosophy: **software should help people spend less time on administration and more time on their community.**
|
||||
|
||||
## 📸 Screenshots
|
||||
## User Documentation (German)
|
||||
|
||||
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
|
||||
|
||||

|
||||
*This is how Mila might look in action.*
|
||||
|
||||
## 🔑 Features
|
||||
|
||||
|
|
@ -48,9 +48,10 @@ Our philosophy: **software should help people spend less time on administration
|
|||
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
|
||||
- ✅ Sidebar navigation (standard-compliant, accessible)
|
||||
- ✅ Global settings management
|
||||
- 🚧 Self-service & online application
|
||||
- ✅ Self-service & online application
|
||||
- ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation)
|
||||
- 🚧 Email sending
|
||||
- ✅ Email sending
|
||||
- ✅ Integration of Accounting-Software ([Vereinfacht](https://github.com/vereinfacht/vereinfacht))
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
|
|
@ -142,7 +143,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
|
|||
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
|
||||
4. add client from the admin panel
|
||||
- Client ID: mv
|
||||
- redirect uris: http://localhost:4000/auth/user/rauthy/callback
|
||||
- redirect uris: http://localhost:4000/auth/user/oidc/callback
|
||||
- Authorization Flows: authorization_code
|
||||
- allowed origins: http://localhost:4000
|
||||
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
|
||||
|
|
@ -153,13 +154,13 @@ Now you can log in to Mila via OIDC!
|
|||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:oidc` — it works with any OIDC-compliant provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
|
||||
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
|
|
@ -168,17 +169,11 @@ Example for Authentik:
|
|||
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
|
||||
```
|
||||
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/callback` if not explicitly set.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
- **Env vars:** see `.env.example`
|
||||
- `OIDC_CLIENT_SECRET` — secret for your OIDC client
|
||||
- Database defaults (Docker Compose):
|
||||
- Host: `localhost`
|
||||
- Port: `5000`
|
||||
- User/pass: `postgres` / `postgres`
|
||||
- DB: `mila_dev`
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
|
@ -193,6 +188,8 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/
|
|||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `lib/mv/` — Shared helpers and business logic
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
- `test/` — All tests
|
||||
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
|
|
@ -228,42 +225,19 @@ For testing the production Docker build locally:
|
|||
# Copy template and edit
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Required variables:
|
||||
SECRET_KEY_BASE=<your-generated-secret>
|
||||
TOKEN_SIGNING_SECRET=<your-generated-secret>
|
||||
DOMAIN=localhost # or PHX_HOST=localhost
|
||||
|
||||
# Optional OIDC configuration:
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
|
||||
|
||||
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
|
||||
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
|
||||
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
|
||||
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# DATABASE_URL_FILE=/run/secrets/database_url
|
||||
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
|
||||
```
|
||||
|
||||
3. **Start development environment** (for Rauthy):
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Start production environment:**
|
||||
3. **Start production environment:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up
|
||||
```
|
||||
|
||||
5. **Database migrations run automatically** on app start. For manual migration:
|
||||
4. **Database migrations run automatically** on app start. For manual migration:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
||||
```
|
||||
|
||||
6. **Access the production app:**
|
||||
5. **Access the production app:**
|
||||
- Production App: http://localhost:4001
|
||||
- Uses same Rauthy instance as dev (localhost:8080)
|
||||
|
||||
|
|
@ -286,9 +260,9 @@ For actual production deployment:
|
|||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions!
|
||||
- Open issues and PRs in this repo.
|
||||
- Please follow existing code style and conventions.
|
||||
- Expect breaking changes while the project is in early development.
|
||||
- Open issues and PRs in this repo
|
||||
- Please follow existing code style and conventions
|
||||
- Expect breaking changes while the project is in early development
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
|
@ -298,4 +272,4 @@ See the [LICENSE](LICENSE) file for details.
|
|||
## 📬 Contact
|
||||
|
||||
- Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues)
|
||||
- Community links: coming soon.
|
||||
- E-Mail: info@local-it.org
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||
|
|
@ -99,6 +99,193 @@
|
|||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session] { display: contents }
|
||||
|
||||
/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */
|
||||
.join-form-helper {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.join-form-helper .join-form-helper-input {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
|
||||
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
|
||||
spacing; use inherited values so custom stylesheets can override. */
|
||||
[popover] {
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
word-spacing: inherit;
|
||||
}
|
||||
|
||||
/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
|
||||
.text-success-aa {
|
||||
color: oklch(0.35 0.12 165);
|
||||
}
|
||||
|
||||
.text-error-aa {
|
||||
color: oklch(0.45 0.2 25);
|
||||
}
|
||||
|
||||
.text-warning-aa {
|
||||
color: oklch(0.45 0.14 75);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-success-aa {
|
||||
color: oklch(0.72 0.12 165);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-error-aa {
|
||||
color: oklch(0.75 0.18 25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-warning-aa {
|
||||
color: oklch(0.78 0.14 75);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
||||
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
||||
outline badges always have a visible background in both themes. */
|
||||
[data-theme="light"] .badge.badge-outline,
|
||||
[data-theme="dark"] .badge.badge-outline {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
|
||||
which fails contrast. Override to 85% of base-content so labels stay slightly
|
||||
de‑emphasised vs body text but meet the minimum ratio. Match .label directly
|
||||
so the override applies even when data-theme is not yet set (e.g. initial load). */
|
||||
.label {
|
||||
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
|
||||
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
||||
badge-soft uses variant as text on a light tint (low contrast). We override
|
||||
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
|
||||
|
||||
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
|
||||
[data-theme="light"] .badge.badge-primary {
|
||||
--badge-fg: oklch(0.25 0.08 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-primary.badge-soft {
|
||||
color: oklch(0.38 0.14 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success {
|
||||
--badge-fg: oklch(0.26 0.06 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success.badge-soft {
|
||||
color: oklch(0.35 0.10 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error {
|
||||
--badge-fg: oklch(0.22 0.08 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error.badge-soft {
|
||||
color: oklch(0.38 0.14 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning {
|
||||
--badge-fg: oklch(0.28 0.06 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning.badge-soft {
|
||||
color: oklch(0.42 0.12 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info {
|
||||
--badge-fg: oklch(0.26 0.08 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info.badge-soft {
|
||||
color: oklch(0.38 0.12 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral {
|
||||
--badge-fg: oklch(0.22 0.01 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral.badge-soft {
|
||||
color: oklch(0.32 0.02 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="light"] .badge.badge-outline.badge-success,
|
||||
[data-theme="light"] .badge.badge-outline.badge-error,
|
||||
[data-theme="light"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="light"] .badge.badge-outline.badge-info,
|
||||
[data-theme="light"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.25 0.02 285);
|
||||
}
|
||||
|
||||
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
|
||||
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
|
||||
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.20 277);
|
||||
--badge-fg: oklch(0.97 0.02 277);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.10 185);
|
||||
--badge-fg: oklch(0.97 0.01 185);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.18 18);
|
||||
--badge-fg: oklch(0.97 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.48 0.14 58);
|
||||
--badge-fg: oklch(0.22 0.02 58);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.45 0.14 242);
|
||||
--badge-fg: oklch(0.97 0.02 242);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.32 0.02 257);
|
||||
--badge-fg: oklch(0.96 0.01 257);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
|
||||
[data-theme="dark"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-success,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-error,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-info,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.92 0.02 257);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
|
||||
Inactive state uses base-content on a light/dark surface; active state ensures
|
||||
*-content on * background meets 4.5:1. */
|
||||
.member-filter-dropdown .join .btn {
|
||||
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.25 0.02 285);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.12 165);
|
||||
color: oklch(0.98 0.01 165);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.98 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.92 0.02 257);
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.10 165);
|
||||
color: oklch(0.97 0.01 165);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.97 0.02 18);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sidebar Base Styles
|
||||
============================================ */
|
||||
|
|
@ -338,4 +525,186 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Collapsed Sidebar: User Menu Dropdown Richtung
|
||||
============================================ */
|
||||
|
||||
/* Bei eingeklappter Sidebar liegt der Avatar-Button am linken Rand.
|
||||
dropdown-end würde das Menü nach links öffnen (off-screen).
|
||||
Stattdessen nach rechts öffnen (in den Content-Bereich). */
|
||||
#app-layout[data-sidebar-expanded="false"] .dropdown.dropdown-top > ul.dropdown-content {
|
||||
right: auto !important;
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
/* Sign-in: hide SSO button and "or" divider when OIDC is not configured.
|
||||
Scoped to #sign-in-page to avoid hiding unrelated elements. */
|
||||
#sign-in-page[data-oidc-configured="false"] [id*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 1.4.3: Primary button contrast (AA)
|
||||
============================================ */
|
||||
|
||||
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
|
||||
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
|
||||
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
|
||||
|
||||
/* Light theme: primary is orange (brand); primary-content must be dark. */
|
||||
[data-theme="light"] {
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||
[data-theme="dark"] {
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
|
||||
--color-primary: oklch(72% 0.17 45);
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
|
||||
--color-secondary: oklch(48% 0.233 277.117);
|
||||
--color-secondary-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
|
||||
============================================ */
|
||||
#member-tablist .tab:not(.tab-active) {
|
||||
color: oklch(0.35 0.02 285);
|
||||
}
|
||||
[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
|
||||
color: oklch(0.72 0.02 257);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Link contrast - primary and accent
|
||||
============================================ */
|
||||
[data-theme="light"] .link.link-primary {
|
||||
color: oklch(0.45 0.15 35);
|
||||
}
|
||||
[data-theme="light"] .link.link-primary:hover {
|
||||
color: oklch(0.38 0.14 35);
|
||||
}
|
||||
[data-theme="dark"] .link.link-primary {
|
||||
color: oklch(0.82 0.14 45);
|
||||
}
|
||||
[data-theme="dark"] .link.link-primary:hover {
|
||||
color: oklch(0.88 0.12 45);
|
||||
}
|
||||
[data-theme="dark"] .link.link-accent {
|
||||
color: oklch(0.82 0.18 292);
|
||||
}
|
||||
[data-theme="dark"] .link.link-accent:hover {
|
||||
color: oklch(0.88 0.16 292);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Danger zone heading contrast (dark theme)
|
||||
============================================ */
|
||||
[data-theme="dark"] #danger-zone-heading.text-error {
|
||||
color: oklch(0.78 0.18 25);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Blue link contrast in dark theme
|
||||
============================================ */
|
||||
[data-theme="dark"] a.text-blue-700,
|
||||
[data-theme="dark"] a.text-blue-600,
|
||||
[data-theme="dark"] a.hover\:text-blue-800 {
|
||||
color: oklch(0.72 0.16 255);
|
||||
}
|
||||
[data-theme="dark"] a.text-blue-700:hover,
|
||||
[data-theme="dark"] a.text-blue-600:hover {
|
||||
color: oklch(0.82 0.14 255);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 2.2 AA: Password / form label on light box in dark theme
|
||||
============================================ */
|
||||
[data-theme="dark"] .bg-gray-50 {
|
||||
background-color: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-gray-50 .label,
|
||||
[data-theme="dark"] .bg-gray-50 .mb-1.label,
|
||||
[data-theme="dark"] .bg-gray-50 .text-gray-600,
|
||||
[data-theme="dark"] .bg-gray-50 .text-gray-700,
|
||||
[data-theme="dark"] .bg-gray-50 strong,
|
||||
[data-theme="dark"] .bg-gray-50 p,
|
||||
[data-theme="dark"] .bg-gray-50 li {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* Dark mode: orange/red info boxes (admin note, OIDC warning) – dark bg, light text */
|
||||
[data-theme="dark"] .bg-orange-50 {
|
||||
background-color: oklch(0.32 0.06 55);
|
||||
border-color: oklch(0.42 0.08 55);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-orange-50 .text-orange-800,
|
||||
[data-theme="dark"] .bg-orange-50 p,
|
||||
[data-theme="dark"] .bg-orange-50 strong {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-red-50 {
|
||||
background-color: oklch(0.32 0.08 25);
|
||||
border-color: oklch(0.42 0.12 25);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
[data-theme="dark"] .bg-red-50 .text-red-800,
|
||||
[data-theme="dark"] .bg-red-50 .text-red-700,
|
||||
[data-theme="dark"] .bg-red-50 p,
|
||||
[data-theme="dark"] .bg-red-50 strong {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
||||
/* ============================================
|
||||
SortableList: drag-and-drop table rows
|
||||
============================================ */
|
||||
|
||||
/* Ghost row: placeholder showing where the dragged item will be dropped.
|
||||
Background fills the gap; text invisible so layout matches original row. */
|
||||
.sortable-ghost {
|
||||
background-color: var(--color-base-300) !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sortable-ghost td {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Chosen row: the row being actively dragged (follows the cursor). */
|
||||
.sortable-chosen {
|
||||
background-color: var(--color-base-200);
|
||||
box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18);
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/* Drag handle button: only grab cursor, no hover effect for mouse users.
|
||||
Keyboard outline is handled via JS outline style. */
|
||||
[data-sortable-handle] button {
|
||||
cursor: grab;
|
||||
}
|
||||
[data-sortable-handle] button:hover {
|
||||
background-color: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
|
|
|||
239
assets/js/app.js
239
assets/js/app.js
|
|
@ -21,9 +21,18 @@ import "phoenix_html"
|
|||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import Sortable from "../vendor/sortable"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
function getBrowserTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
|
|
@ -73,6 +82,214 @@ Hooks.ComboBox = {
|
|||
}
|
||||
}
|
||||
|
||||
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||
Hooks.TableRowKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (
|
||||
e.target.getAttribute("data-row-clickable") === "true" &&
|
||||
(e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.target.click()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener("keydown", this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||
Hooks.FocusRestore = {
|
||||
mounted() {
|
||||
this.handleEvent("focus_restore", ({id}) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts)
|
||||
Hooks.FlashAutoDismiss = {
|
||||
mounted() {
|
||||
const ms = this.el.dataset.autoClearMs
|
||||
if (!ms) return
|
||||
const delay = parseInt(ms, 10)
|
||||
if (delay > 0) {
|
||||
this.timer = setTimeout(() => {
|
||||
const key = this.el.dataset.clearFlashKey || "success"
|
||||
this.pushEvent("lv:clear-flash", {key})
|
||||
}, delay)
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||
Hooks.TabListKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener('keydown', this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener('keydown', this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// SortableList hook: Accessible reorderable table/list.
|
||||
// Mouse drag: SortableJS (smooth animation, ghost row, items push apart).
|
||||
// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern.
|
||||
// Container must have data-reorder-event and data-list-id.
|
||||
// Each row (tr) must have data-row-index; locked rows have data-locked="true".
|
||||
// Pushes event with { from_index, to_index } (both integers) on reorder.
|
||||
Hooks.SortableList = {
|
||||
mounted() {
|
||||
this.reorderEvent = this.el.dataset.reorderEvent
|
||||
this.listId = this.el.dataset.listId
|
||||
// Keyboard state: store grabbed row id so it survives LiveView re-renders
|
||||
this.grabbedRowId = null
|
||||
|
||||
this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null
|
||||
const announce = (msg) => {
|
||||
if (!this.announcementEl) return
|
||||
// Clear then re-set to force screen reader re-read
|
||||
this.announcementEl.textContent = ""
|
||||
setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50)
|
||||
}
|
||||
|
||||
const tbody = this.el.querySelector("tbody")
|
||||
if (!tbody) return
|
||||
|
||||
this.getRows = () => Array.from(tbody.querySelectorAll("tr"))
|
||||
this.getRowIndex = (tr) => {
|
||||
const idx = tr.getAttribute("data-row-index")
|
||||
return idx != null ? parseInt(idx, 10) : -1
|
||||
}
|
||||
this.isLocked = (tr) => tr.getAttribute("data-locked") === "true"
|
||||
|
||||
// SortableJS for mouse drag-and-drop with animation
|
||||
this.sortable = new Sortable(tbody, {
|
||||
animation: 150,
|
||||
handle: "[data-sortable-handle]",
|
||||
// Disable sorting for locked rows (first row = email)
|
||||
filter: "[data-locked='true']",
|
||||
preventOnFilter: true,
|
||||
// Ghost (placeholder showing where the item will land)
|
||||
ghostClass: "sortable-ghost",
|
||||
// The item being dragged
|
||||
chosenClass: "sortable-chosen",
|
||||
// Cursor while dragging
|
||||
dragClass: "sortable-drag",
|
||||
// Don't trigger on handle area clicks (only actual drag)
|
||||
delay: 0,
|
||||
onEnd: (e) => {
|
||||
if (e.oldIndex === e.newIndex) return
|
||||
this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex })
|
||||
announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`)
|
||||
// LiveView will reconcile the DOM order after re-render
|
||||
}
|
||||
})
|
||||
|
||||
// Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel)
|
||||
this.handleKeyDown = (e) => {
|
||||
// Don't intercept Space on interactive elements (checkboxes, buttons, inputs)
|
||||
const tag = e.target.tagName
|
||||
if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return
|
||||
|
||||
const tr = e.target.closest("tr")
|
||||
if (!tr || this.isLocked(tr)) return
|
||||
const rows = this.getRows()
|
||||
const idx = this.getRowIndex(tr)
|
||||
if (idx < 0) return
|
||||
const total = rows.length
|
||||
|
||||
if (e.key === " ") {
|
||||
e.preventDefault()
|
||||
const rowId = tr.id
|
||||
if (this.grabbedRowId === rowId) {
|
||||
// Drop
|
||||
this.grabbedRowId = null
|
||||
tr.style.outline = ""
|
||||
announce(`Dropped. Position ${idx + 1} of ${total}.`)
|
||||
} else {
|
||||
// Grab
|
||||
this.grabbedRowId = rowId
|
||||
tr.style.outline = "2px solid var(--color-primary)"
|
||||
tr.style.outlineOffset = "-2px"
|
||||
announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (this.grabbedRowId != null) {
|
||||
e.preventDefault()
|
||||
const grabbedTr = document.getElementById(this.grabbedRowId)
|
||||
if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" }
|
||||
this.grabbedRowId = null
|
||||
announce("Reorder cancelled.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.grabbedRowId == null) return
|
||||
|
||||
// Do not move into a locked row (e.g. email always first)
|
||||
if (e.key === "ArrowUp" && idx > 0) {
|
||||
const targetRow = rows[idx - 1]
|
||||
if (!this.isLocked(targetRow)) {
|
||||
e.preventDefault()
|
||||
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 })
|
||||
announce(`Position ${idx} of ${total}.`)
|
||||
}
|
||||
} else if (e.key === "ArrowDown" && idx < total - 1) {
|
||||
const targetRow = rows[idx + 1]
|
||||
if (!this.isLocked(targetRow)) {
|
||||
e.preventDefault()
|
||||
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 })
|
||||
announce(`Position ${idx + 2} of ${total}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.el.addEventListener("keydown", this.handleKeyDown, true)
|
||||
},
|
||||
|
||||
updated() {
|
||||
// Re-apply keyboard outline and restore focus after LiveView re-render.
|
||||
// LiveView DOM patching loses focus; without explicit re-focus the next keypress
|
||||
// goes to document.body (Space scrolls the page instead of triggering our handler).
|
||||
if (this.grabbedRowId) {
|
||||
const tr = document.getElementById(this.grabbedRowId)
|
||||
if (tr) {
|
||||
tr.style.outline = "2px solid var(--color-primary)"
|
||||
tr.style.outlineOffset = "-2px"
|
||||
tr.focus()
|
||||
} else {
|
||||
// Row no longer exists (removed while grabbed), clear state
|
||||
this.grabbedRowId = null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.sortable) this.sortable.destroy()
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown, true)
|
||||
}
|
||||
}
|
||||
|
||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||
Hooks.SidebarState = {
|
||||
mounted() {
|
||||
|
|
@ -87,6 +304,16 @@ Hooks.SidebarState = {
|
|||
}
|
||||
},
|
||||
|
||||
updated() {
|
||||
// LiveView patches data-sidebar-expanded back to the template default ("true")
|
||||
// on every DOM update. Re-apply the stored state from localStorage after each patch.
|
||||
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
|
||||
const current = this.el.dataset.sidebarExpanded === 'true'
|
||||
if (current !== expanded) {
|
||||
this.setSidebarState(expanded)
|
||||
}
|
||||
},
|
||||
|
||||
setSidebarState(expanded) {
|
||||
// Convert boolean to string for consistency
|
||||
const expandedStr = expanded ? 'true' : 'false'
|
||||
|
|
@ -112,7 +339,10 @@ Hooks.SidebarState = {
|
|||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: getBrowserTimezone()
|
||||
},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
|
|
@ -228,6 +458,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Listen for changes to the drawer checkbox
|
||||
drawerToggle.addEventListener("change", () => {
|
||||
// On desktop (lg:drawer-open), the mobile drawer must never open.
|
||||
// The hamburger label is lg:hidden, but guard here as a safety net
|
||||
// against any accidental toggles (e.g. from overlapping elements or JS).
|
||||
if (drawerToggle.checked && window.innerWidth >= 1024) {
|
||||
drawerToggle.checked = false
|
||||
return
|
||||
}
|
||||
const isOpen = drawerToggle.checked
|
||||
updateAriaExpanded()
|
||||
updateSidebarTabIndex(isOpen)
|
||||
|
|
|
|||
2
assets/vendor/sortable.js
vendored
Normal file
2
assets/vendor/sortable.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -46,11 +46,18 @@ config :spark,
|
|||
]
|
||||
]
|
||||
|
||||
# IANA timezone database for DateTime.shift_zone (browser timezone display)
|
||||
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
|
||||
|
||||
config :mv,
|
||||
ecto_repos: [Mv.Repo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||
|
||||
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
|
||||
# not available in releases. Set once at compile time via config_env().
|
||||
config :mv, :environment, config_env()
|
||||
|
||||
# CSV Import configuration
|
||||
config :mv,
|
||||
csv_import: [
|
||||
|
|
@ -89,6 +96,20 @@ config :mv, MvWeb.Endpoint,
|
|||
# at the `config/runtime.exs`.
|
||||
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP).
|
||||
# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod.
|
||||
config :mv, :smtp_verify_peer, false
|
||||
|
||||
# Default mail "from" address for transactional emails (join confirmation,
|
||||
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
||||
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
||||
|
||||
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
|
||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
|
||||
|
||||
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
|
||||
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
|
|
|
|||
|
|
@ -93,11 +93,13 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
|
|||
# Signing Secret for Authentication
|
||||
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
|
||||
|
||||
config :mv, :rauthy,
|
||||
client_id: "mv",
|
||||
base_url: "http://localhost:8080/auth/v1",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
|
||||
# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out,
|
||||
# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs.
|
||||
# config :mv, :oidc,
|
||||
# client_id: "mv",
|
||||
# base_url: "http://localhost:8080/auth/v1",
|
||||
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
# redirect_uri: "http://localhost:4000/auth/user/oidc/callback"
|
||||
|
||||
# AshAuthentication development configuration
|
||||
config :mv, :session_identifier, :jti
|
||||
|
|
|
|||
|
|
@ -129,8 +129,7 @@ if config_env() == :prod do
|
|||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
|
||||
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
|
||||
# The redirect_uri callback path is /auth/user/oidc/callback.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
|
|
@ -150,9 +149,9 @@ if config_env() == :prod do
|
|||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
|
||||
default_redirect_uri = "https://#{host}/auth/user/oidc/callback"
|
||||
|
||||
config :mv, :rauthy,
|
||||
config :mv, :oidc,
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
|
|
@ -218,21 +217,58 @@ if config_env() == :prod do
|
|||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# ## Configuring the mailer
|
||||
#
|
||||
# In production you need to configure the mailer to use a different adapter.
|
||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
||||
# are not using SMTP. Here is an example of the configuration:
|
||||
#
|
||||
# config :mv, Mv.Mailer,
|
||||
# adapter: Swoosh.Adapters.Mailgun,
|
||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||
#
|
||||
# For this example you need include a HTTP client required by Swoosh API client.
|
||||
# Swoosh supports Hackney, Req and Finch out of the box:
|
||||
#
|
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
||||
#
|
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||
# Transactional emails use the sender from config :mv, :mail_from (overridable via ENV).
|
||||
config :mv,
|
||||
:mail_from,
|
||||
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
||||
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
||||
|
||||
# SMTP configuration from environment variables (overrides base adapter in prod).
|
||||
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
||||
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
||||
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
|
||||
smtp_host_env = System.get_env("SMTP_HOST")
|
||||
|
||||
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
||||
smtp_port_env =
|
||||
case System.get_env("SMTP_PORT") do
|
||||
nil -> 587
|
||||
v -> String.to_integer(String.trim(v))
|
||||
end
|
||||
|
||||
smtp_password_env =
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
nil ->
|
||||
case System.get_env("SMTP_PASSWORD_FILE") do
|
||||
nil -> nil
|
||||
path -> path |> File.read!() |> String.trim()
|
||||
end
|
||||
|
||||
v ->
|
||||
v
|
||||
end
|
||||
|
||||
smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
|
||||
|
||||
# SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended
|
||||
# for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs.
|
||||
smtp_verify_peer =
|
||||
(System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes)
|
||||
|
||||
config :mv, :smtp_verify_peer, smtp_verify_peer
|
||||
|
||||
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
|
||||
|
||||
smtp_opts =
|
||||
Mv.Smtp.ConfigBuilder.build_opts(
|
||||
host: String.trim(smtp_host_env),
|
||||
port: smtp_port_env,
|
||||
username: System.get_env("SMTP_USERNAME"),
|
||||
password: smtp_password_env,
|
||||
ssl_mode: smtp_ssl_mode,
|
||||
verify_mode: verify_mode
|
||||
)
|
||||
|
||||
config :mv, Mv.Mailer, smtp_opts
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ config :mv, :session_identifier, :unsafe
|
|||
|
||||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# Use English as default locale in tests so UI tests can assert on English strings.
|
||||
config :mv, :default_locale, "en"
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||
config :mv, :sql_sandbox, true
|
||||
|
||||
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
|
||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
|
||||
|
||||
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
|
||||
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||
config :ash, warn_on_transaction_hooks?: false
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ services:
|
|||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# Rauthy OIDC config - use host.docker.internal to reach host services
|
||||
# OIDC config - use host.docker.internal to reach host services
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/oidc/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
|
|
@ -33,7 +33,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:18.1-alpine
|
||||
image: postgres:18.3-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ networks:
|
|||
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.1-alpine
|
||||
image: postgres:18.3-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
|
|||
|
|
@ -2,24 +2,27 @@
|
|||
|
||||
## Overview
|
||||
|
||||
- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before server). Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
|
||||
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
|
||||
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
|
||||
|
||||
## Admin Bootstrap (Part A)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run.
|
||||
- `FORCE_SEEDS` – If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied.
|
||||
- `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
|
||||
- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
|
||||
- `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret).
|
||||
|
||||
### Release Task
|
||||
### Release Tasks
|
||||
|
||||
- `Mv.Release.run_seeds/0` – If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start.
|
||||
- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
|
||||
|
||||
### Entrypoint
|
||||
|
||||
- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs seed_admin(), then starts the server.
|
||||
- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs run_seeds(), then seed_admin(), then starts the server.
|
||||
|
||||
### Seeds (Dev/Test)
|
||||
|
||||
|
|
@ -33,14 +36,19 @@
|
|||
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
||||
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||
|
||||
### Sign-in page (OIDC-only mode)
|
||||
|
||||
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
|
||||
- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect).
|
||||
|
||||
### Sync Logic
|
||||
|
||||
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
|
||||
|
||||
### Where It Runs
|
||||
|
||||
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
|
||||
1. Registration: register_with_oidc after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
|
||||
|
||||
### Internal Action
|
||||
|
||||
|
|
|
|||
88
docs/badge-wcag-phase1-analysis.md
Normal file
88
docs/badge-wcag-phase1-analysis.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Phase 1 — Badge WCAG Analysis & Migration
|
||||
|
||||
## 1) Repo-Analyse (Stand vor Änderungen)
|
||||
|
||||
### Badge-Verwendungen (alle Fundstellen)
|
||||
|
||||
| Datei | Kontext | Markup |
|
||||
|-------|---------|--------|
|
||||
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
|
||||
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
|
||||
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
|
||||
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
|
||||
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
|
||||
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
|
||||
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
|
||||
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
|
||||
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
|
||||
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
|
||||
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
|
||||
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
|
||||
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
|
||||
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
|
||||
|
||||
### DaisyUI/Tailwind Config
|
||||
|
||||
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
|
||||
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
|
||||
- **Themes:** Zwei Custom-Themes in `app.css`:
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
|
||||
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
|
||||
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
|
||||
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
|
||||
|
||||
### DaisyUI Badge (Vendor)
|
||||
|
||||
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
|
||||
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
|
||||
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
|
||||
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
|
||||
|
||||
---
|
||||
|
||||
## 2) Core Component <.badge> API (geplant)
|
||||
|
||||
- **attr :variant** — `:neutral | :primary | :info | :success | :warning | :error`
|
||||
- **attr :style** — `:soft | :solid | :outline` (Default: `:soft`)
|
||||
- **attr :size** — `:sm | :md` (Default: `:md`)
|
||||
- **slot :inner_block** — Badge-Text
|
||||
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
|
||||
- **slot :icon** — optional
|
||||
|
||||
Regeln:
|
||||
|
||||
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
|
||||
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
|
||||
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
|
||||
|
||||
---
|
||||
|
||||
## 3) Theme-Overrides (WCAG)
|
||||
|
||||
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
|
||||
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
|
||||
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
|
||||
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
|
||||
|
||||
---
|
||||
|
||||
## 4) Migration (erledigt)
|
||||
|
||||
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
|
||||
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
|
||||
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
|
||||
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
|
||||
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
|
||||
|
||||
## 5) Weitere Anpassungen (nach Phase 1)
|
||||
|
||||
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
|
||||
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)` → `:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
|
||||
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).
|
||||
|
|
@ -48,7 +48,7 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
- Upload CSV file via LiveView file upload
|
||||
- Parse CSV with bilingual header support for core member fields (English/German)
|
||||
- Auto-detect delimiter (`;` or `,`) using header recognition
|
||||
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
|
||||
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`)
|
||||
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
|
||||
- Validate each row (required field: `email`)
|
||||
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
|
||||
|
|
@ -149,13 +149,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
|
||||
**v1 Supported Fields:**
|
||||
|
||||
**Core Member Fields:**
|
||||
**Core Member Fields (all importable):**
|
||||
- `email` / `E-Mail` (required)
|
||||
- `first_name` / `Vorname` (optional)
|
||||
- `last_name` / `Nachname` (optional)
|
||||
- `email` / `E-Mail` (required)
|
||||
- `street` / `Straße` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date)
|
||||
- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date)
|
||||
- `notes` / `Notizen` (optional)
|
||||
- `country` / `Land` / `Staat` (optional)
|
||||
- `city` / `Stadt` (optional)
|
||||
- `street` / `Straße` (optional)
|
||||
- `house_number` / `Hausnummer` / `Nr.` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date)
|
||||
|
||||
Address column order in import/export matches the members overview: country, city, street, house number, postal code.
|
||||
|
||||
**Not supported for import (by design):**
|
||||
- **membership_fee_status** – Computed field (from fee cycles). Not stored; export-only.
|
||||
- **groups** – Many-to-many relationship. Would require resolving group names to IDs; not in current scope.
|
||||
- **membership_fee_type_id** – Foreign key; could be added later (e.g. resolve type name to ID).
|
||||
|
||||
**Custom Fields:**
|
||||
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
|
||||
|
|
@ -176,9 +189,15 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
|
||||
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
|
||||
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
|
||||
| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` |
|
||||
| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` |
|
||||
| `notes` | `notes` | `Notizen`, `bemerkungen` |
|
||||
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
|
||||
| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` |
|
||||
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
|
||||
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
|
||||
| `country` | `country` | `Land`, `land`, `Staat`, `staat` |
|
||||
| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` |
|
||||
|
||||
**Header Normalization (used consistently for both input headers AND mapping variants):**
|
||||
- Trim whitespace
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
- Postal code: optional (no format validation)
|
||||
- Country: optional
|
||||
|
||||
### CustomFieldValue System
|
||||
- Maximum one custom field value per custom field per member
|
||||
|
|
@ -240,7 +241,7 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes, group names (from member_groups → groups)
|
||||
- **Weight C:** city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight C:** city, street, house_number, postal_code, country, custom_field_values
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Group Names in Search
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ Table members {
|
|||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
country text [null, note: 'Country of residence']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
|
||||
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
|
||||
|
|
@ -188,7 +189,8 @@ Table members {
|
|||
- email: 5-254 characters, valid email format (required)
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: exactly 5 digits (if present)
|
||||
- postal_code: optional (no format validation)
|
||||
- country: optional
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -792,6 +792,31 @@ defmodule MvWeb.Components.SearchBarTest do
|
|||
end
|
||||
```
|
||||
|
||||
### Onboarding / Join (Issue #308, TDD)
|
||||
|
||||
**Subtask 1 – JoinRequest resource and public policies (done):**
|
||||
- Resource: `Mv.Membership.JoinRequest` with attributes (status, email, first_name, last_name, form_data, schema_version, confirmation_token_hash, confirmation_token_expires_at, submitted_at, etc.), actions `submit` (create), `get_by_confirmation_token_hash` (read), `confirm` (update). Migration: `20260309141437_add_join_requests.exs`.
|
||||
- Policies: Public actions allowed with `actor: nil` via `Mv.Authorization.Checks.ActorIsNil` (submit, get_by_confirmation_token_hash, confirm); default read remains Forbidden for unauthenticated.
|
||||
- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing via `JoinRequest.hash_confirmation_token/1`, lookup, expiry check, idempotency for :submitted/:approved/:rejected).
|
||||
- Test file: `test/membership/join_request_test.exs` – all tests pass; policy test and expired-token test implemented.
|
||||
|
||||
**Subtask 2 – Submit and confirm flow (done):**
|
||||
- **Unified email layout:** phoenix_swoosh with `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). All transactional emails (join confirmation, user confirmation, password reset) use the same layout. Config: `config :mv, :mail_from, {name, email}` (default `{"Mila", "noreply@example.com"}`); override in runtime.exs.
|
||||
- **Join confirmation:** Domain wrapper `submit_join_request/2` generates token (or uses optional `:confirmation_token` in attrs for tests), creates JoinRequest via action `:submit`, then sends one email via `MvWeb.Emails.JoinConfirmationEmail`. Route `GET /confirm_join/:token` (JoinConfirmController) updates to `submitted`; idempotent; expired/invalid handled.
|
||||
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`.
|
||||
- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban.
|
||||
- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings.
|
||||
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
|
||||
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass.
|
||||
|
||||
**Subtask 3 – Admin: Join form settings (done):**
|
||||
- **Setting resource** (`lib/membership/setting.ex`): 3 new attributes `join_form_enabled` (boolean, default false), `join_form_field_ids` ({:array, :string} – ordered list of member field names or custom field UUIDs), `join_form_field_required` (:map – field ID → boolean). Added to `:create` and `:update` accept lists. Validation rejects field IDs that are neither valid member field names nor UUID format. Migration: `20260310114701_add_join_form_settings_to_settings.exs`.
|
||||
- **NormalizeJoinFormSettings** (`lib/membership/setting/changes/normalize_join_form_settings.ex`): Change applied on create/update whenever join form attrs are changing. Ensures email is always in `join_form_field_ids`, forces `join_form_field_required["email"] = true`, drops required flags for fields not in `join_form_field_ids`.
|
||||
- **Domain** (`lib/membership/membership.ex`): `Mv.Membership.get_join_form_allowlist/0` – returns `[]` when join form is disabled, otherwise a list of `%{id, required, type}` maps (type = `:member_field` or `:custom_field` based on ID format).
|
||||
- **GlobalSettingsLive** (`lib/mv_web/live/global_settings_live.ex`): New "Join Form" / "Beitrittsformular" section between Club Settings and Vereinfacht. Checkbox to enable/disable, table of selected fields (with Required checkbox per field – email always checked/disabled, other fields can be toggled), "Add field" dropdown with all unselected member fields and custom fields, "Save Join Form Settings" button. State is managed locally (not via AshPhoenix.Form); saved on explicit save click.
|
||||
- **Translations**: 14 new German strings in `priv/gettext/de/LC_MESSAGES/default.po` (Beitrittsformular, Felder im Beitrittsformular, Feld hinzufügen, etc.).
|
||||
- Tests: All 13 tests in `test/membership/setting_join_form_test.exs` pass; full test suite 1900 tests, 0 failures.
|
||||
|
||||
### Test Data Management
|
||||
|
||||
**Seed Data:**
|
||||
|
|
@ -886,7 +911,7 @@ just regen-migrations <name>
|
|||
**Checklist:**
|
||||
1. ✅ Rauthy running: `docker compose ps`
|
||||
2. ✅ Client created in Rauthy admin panel
|
||||
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/rauthy/callback`
|
||||
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/oidc/callback`
|
||||
4. ✅ OIDC_CLIENT_SECRET in .env
|
||||
5. ✅ App restarted after .env update
|
||||
|
||||
|
|
|
|||
26
docs/email-layout-mockup.md
Normal file
26
docs/email-layout-mockup.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Unified Email Layout – ASCII Mockup
|
||||
|
||||
All transactional emails (join confirmation, user confirmation, password reset) use the same layout.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| [Logo or app name – e.g. "Mila" or club name] |
|
||||
+------------------------------------------------------------------+
|
||||
| |
|
||||
| [Subject / heading line – e.g. "Confirm your email address"] |
|
||||
| |
|
||||
| [Body content – paragraph and CTA link] |
|
||||
| e.g. "Please click the link below to confirm your request." |
|
||||
| "Confirm my request" (button or link) |
|
||||
| |
|
||||
| [Optional: short note – e.g. "If you didn't request this, |
|
||||
| you can ignore this email."] |
|
||||
| |
|
||||
+------------------------------------------------------------------+
|
||||
| [Footer – one line, e.g. "© 2025 Mila · Mitgliederverwaltung"] |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
- **Header:** Single line (app/club name), subtle.
|
||||
- **Main:** Heading + body text + primary CTA (link/button).
|
||||
- **Footer:** Single line, small text (copyright / product name).
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2026-01-27
|
||||
**Last Updated:** 2026-03-03
|
||||
**Status:** Active Development
|
||||
|
||||
---
|
||||
|
|
@ -36,10 +36,10 @@
|
|||
|
||||
**Closed Issues:**
|
||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
|
||||
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-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)
|
||||
**Open Issues:** (none remaining for Authentication UI)
|
||||
|
||||
**Current State:**
|
||||
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
||||
|
|
@ -49,6 +49,11 @@
|
|||
- ✅ **Page-level authorization** - LiveView page access control
|
||||
- ✅ **System role protection** - Critical roles cannot be deleted
|
||||
|
||||
**Planned: OIDC-only mode (TDD, tests first):**
|
||||
- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`).
|
||||
- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`).
|
||||
- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests.
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Password reset flow
|
||||
- ❌ Email verification
|
||||
|
|
@ -191,6 +196,11 @@
|
|||
- ❌ Mobile navigation
|
||||
- ❌ Context-sensitive help
|
||||
- ❌ Onboarding tooltips
|
||||
- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
|
||||
- Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility.
|
||||
- Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element.
|
||||
- LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`.
|
||||
- All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_GUIDELINES.md` §9.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -242,7 +252,7 @@
|
|||
- ❌ Payment records/transactions (external payment tracking)
|
||||
- ❌ Payment reminders
|
||||
- ❌ Invoice generation
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ✅ Member–finance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
|
||||
- ❌ SEPA direct debit support
|
||||
- ❌ Payment reports
|
||||
|
||||
|
|
@ -265,6 +275,9 @@
|
|||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Implemented Features:**
|
||||
- ✅ **SMTP configuration** – Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Email templates configuration
|
||||
- ❌ System health dashboard
|
||||
|
|
@ -282,6 +295,7 @@
|
|||
- ✅ Swoosh mailer integration
|
||||
- ✅ Email confirmation (via AshAuthentication)
|
||||
- ✅ Password reset emails (via AshAuthentication)
|
||||
- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
|
||||
- ⚠️ No member communication features
|
||||
|
||||
**Missing Features:**
|
||||
|
|
@ -366,6 +380,7 @@
|
|||
- ✅ Production Dockerfile
|
||||
- ✅ Drone CI/CD pipeline
|
||||
- ✅ Renovate for dependency updates
|
||||
- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03)
|
||||
- ⚠️ No staging environment
|
||||
|
||||
**Open Issues:**
|
||||
|
|
@ -501,8 +516,8 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
|--------|-------|---------|------|---------|----------|
|
||||
| `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 |
|
||||
| `GET` | `/auth/user/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
|
||||
| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
|
||||
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
|
||||
|
|
@ -515,9 +530,9 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
| 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` | `:sign_in_with_oidc` | 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` | `:register_with_oidc` | 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}` |
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ This feature implements secure account linking between password-based accounts a
|
|||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
**Change**: The `sign_in_with_oidc` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_rauthy do
|
||||
read :sign_in_with_oidc do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
|
|
|||
280
docs/onboarding-join-concept.md
Normal file
280
docs/onboarding-join-concept.md
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Onboarding & Join – High-Level Concept
|
||||
|
||||
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) implemented.**
|
||||
**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths.
|
||||
**Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage.
|
||||
|
||||
---
|
||||
|
||||
## 1. Focus and Goals
|
||||
|
||||
- **Focus:** Onboarding and **initial data capture**, not self-service editing of existing members.
|
||||
- **Entry paths (vision):**
|
||||
- **Public Join form** (Prio 1) – unauthenticated submission.
|
||||
- **Invite link** (tokenized) – later.
|
||||
- **OIDC first-login** (Just-in-Time Provisioning) – later.
|
||||
- **Admin control:** All entry paths and their behaviour (e.g. which fields, approval required) shall be configurable by admins; MVP can start with sensible defaults.
|
||||
- **Approval:** A Vorstand (board) approval step is a direct follow-up (Step 2) after the public Join; the data model and flow must support it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prio 1: Public Join Page
|
||||
|
||||
### 2.1 Intent
|
||||
|
||||
- **Public** page (e.g. `/join`): no login; anyone can open and submit.
|
||||
- Result is **not** a User or Member. Result is an **onboarding / join request**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link.
|
||||
- This keeps:
|
||||
- **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC).
|
||||
- Existing policies (e.g. User–Member linking, admin-only link) untouched until a defined "promotion" flow (e.g. after approval) creates User/Member.
|
||||
- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record.
|
||||
|
||||
### 2.2 User Flow (Prio 1)
|
||||
|
||||
1. Unauthenticated user opens `/join`.
|
||||
2. Short explanation + form (what happens next: "We will review … you will hear from us").
|
||||
3. **Submit** → A **JoinRequest is created** in the database with status `pending_confirmation`; confirmation email is sent; user sees: "We have saved your details. To complete your request, please click the link we sent to your email."
|
||||
4. **User clicks confirmation link** → The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, confirmation token invalidated); user sees: "Thank you, we have received your request."
|
||||
|
||||
**Rationale (double opt-in with DB-first):** Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (`pending_confirmation` → `submitted`) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state).
|
||||
|
||||
**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links.
|
||||
|
||||
### 2.3 Data Flow
|
||||
|
||||
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys.
|
||||
- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email.
|
||||
- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent).
|
||||
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
|
||||
|
||||
#### 2.3.1 Pre-Confirmation Store (Decided)
|
||||
|
||||
**Decision:** Store in the **database** only. Use the **same** JoinRequest resource and table from the start.
|
||||
|
||||
- On submit: **create** one JoinRequest row with status `pending_confirmation`, confirmation token **hash**, and expiry.
|
||||
- On confirm: **update** that row to status `submitted` (no second table, no ETS, no stateless token).
|
||||
- **Retention and cleanup:** JoinRequests that remain in `pending_confirmation` past the token expiry (e.g. 24 hours) are **hard-deleted** by a scheduled job (e.g. Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed.
|
||||
- **Rationale:** Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task.
|
||||
|
||||
#### 2.3.2 JoinRequest: Data Model and Schema
|
||||
|
||||
- **Status:** `pending_confirmation` (initial, after form submit) → `submitted` (after link click) → later `approved` / `rejected`. Include **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit.
|
||||
- **Confirmation:** Store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
|
||||
- **Payload vs typed columns (recommendation):**
|
||||
- **Typed columns** for **email** (required, dedicated field for index, search, dedup, audit) and for **first_name** and **last_name** (optional). These align with `Mv.Constants.member_fields()` and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON.
|
||||
- **Remaining form data** (other member fields + custom field values) in a **jsonb** attribute (e.g. `form_data`) plus a **schema_version** (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records.
|
||||
- **What it depends on:** (1) Whether the join form field set is fixed or often extended – if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) – if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names).
|
||||
- **Logger hygiene:** Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization.
|
||||
- **Idempotency:** Confirm action finds the JoinRequest by token hash; if status is already `submitted`, return success without updating. Optionally enforce **unique_index on confirmation_token_hash** so the same token cannot apply to more than one record.
|
||||
- **Abuse metadata:** If stored (e.g. IP hash), classify as **security telemetry** or **personally identifiable** (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added.
|
||||
|
||||
### 2.4 Security
|
||||
|
||||
- **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200).
|
||||
- **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plug’s **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone.
|
||||
- **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm.
|
||||
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation.
|
||||
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention.
|
||||
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
|
||||
- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.
|
||||
- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0.
|
||||
|
||||
### 2.5 Usability and UX
|
||||
|
||||
- **After submit:** Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (Exact copy in implementation spec.)
|
||||
- Clear heading and short copy (e.g. "Become a member / Submit request" and "What happens next").
|
||||
- Form only as simple as needed (conversion vs. data hunger).
|
||||
- Success message after confirm: neutral, no promise of an account ("We will get in touch").
|
||||
- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. "This link has expired") and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec.
|
||||
- **Re-send confirmation link:** Out of scope for Prio 1. If not implemented in Prio 1, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the "Please confirm your email" or expired-link page.
|
||||
- Accessibility and i18n: same standards as rest of the app (labels, errors, Gettext).
|
||||
|
||||
### 2.6 Admin Configurability: Join Form Settings
|
||||
|
||||
- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data).
|
||||
- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
|
||||
- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants.
|
||||
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**.
|
||||
- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field.
|
||||
- **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 2: Vorstand Approval
|
||||
|
||||
- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject.
|
||||
- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification.
|
||||
- **Outcome of approval (admin-configurable):**
|
||||
- **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed.
|
||||
- **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented.
|
||||
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_user’s allowed pages.
|
||||
|
||||
### 3.1 Step 2 – Approval (detail)
|
||||
|
||||
Implementation spec for Subtask 5.
|
||||
|
||||
#### Route and pages
|
||||
|
||||
- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
|
||||
- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`.
|
||||
|
||||
#### Backend (JoinRequest)
|
||||
|
||||
- **New actions (authenticated only):**
|
||||
- **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below).
|
||||
- **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
|
||||
- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`.
|
||||
- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data.
|
||||
|
||||
#### Promotion: JoinRequest → Member
|
||||
|
||||
- **When:** On successful `approve` only (status was `submitted`).
|
||||
- **Mapping:**
|
||||
- JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes.
|
||||
- **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member.
|
||||
- **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
|
||||
- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP.
|
||||
- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent.
|
||||
- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
|
||||
|
||||
#### Permission sets and routing
|
||||
|
||||
- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list.
|
||||
- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied.
|
||||
|
||||
#### UI/UX (approval)
|
||||
|
||||
- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table.
|
||||
- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields.
|
||||
- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed).
|
||||
|
||||
#### Tests
|
||||
|
||||
- JoinRequest: policy tests – approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only.
|
||||
- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error.
|
||||
- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot.
|
||||
- Optional: LiveView smoke test – list loads, approve/reject from detail updates state.
|
||||
|
||||
---
|
||||
|
||||
## 4. Future Entry Paths (Out of Scope Here)
|
||||
|
||||
- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token.
|
||||
- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data.
|
||||
- Both must be design-ready so they can attach to the same approval or creation pipeline later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Evaluation of the Proposed Concept Draft
|
||||
|
||||
**Adopted and reflected above:**
|
||||
|
||||
- **Naming:** Resource name **JoinRequest** (one resource, status + audit timestamps).
|
||||
- **No User/Member from `/join`:** Only a JoinRequest; record is **created on form submit** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low.
|
||||
- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged.
|
||||
- **Public paths:** `/join` is **explicitly** added to the page-permission plug’s public path list; confirmation route `/confirm_join/:token` is covered by existing `/confirm*` rule.
|
||||
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field.
|
||||
- **Security:** Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending.
|
||||
- **Tests:** Unauthenticated GET `/join` → 200; submit creates one JoinRequest (`pending_confirmation`); confirm updates it to `submitted`; idempotent confirm; honeypot and rate limiting covered; public-path tests updated.
|
||||
|
||||
**Refinements in this document:**
|
||||
|
||||
- Approval as Step 2; User creation after approval left open for later.
|
||||
- Admin configurability: join form settings as own section; detailed UX in a subtask.
|
||||
- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit.
|
||||
- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete.
|
||||
- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions and Open Points
|
||||
|
||||
**Decided:**
|
||||
|
||||
- **Email confirmation (double opt-in):** JoinRequest is **created on form submit** with status `pending_confirmation` and **updated to** `submitted` when the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route.
|
||||
- **Naming:** **JoinRequest**.
|
||||
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
|
||||
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
|
||||
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
|
||||
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
|
||||
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**.
|
||||
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
|
||||
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
|
||||
- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set.
|
||||
- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail).
|
||||
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
|
||||
|
||||
**Open for later:**
|
||||
|
||||
- Abuse metadata (IP hash etc.): classification and whether to store in Prio 1.
|
||||
- "Create User on approval" option: to be specified when implemented.
|
||||
- Invite link and OIDC JIT entry paths.
|
||||
|
||||
---
|
||||
|
||||
## 7. Definition of Done (Prio 1)
|
||||
|
||||
- Public `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests).
|
||||
- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; no User or Member created by this flow.
|
||||
- Anti-abuse: honeypot and rate limiting implemented and tested.
|
||||
- Cleanup: scheduled job hard-deletes JoinRequests in `pending_confirmation` older than 24h (or configured retention).
|
||||
- Page-permission and routing tests updated (including public-path coverage for `/join` and `/confirm_join/:token`).
|
||||
- Concept and decisions (§6) documented for use in implementation specs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Plan (Subtasks)
|
||||
|
||||
**Resend confirmation** remains a separate ticket (see §2.5, §6).
|
||||
|
||||
### Prio 1 – Public Join (4 subtasks)
|
||||
|
||||
#### 1. JoinRequest resource and public policies **(done)**
|
||||
|
||||
- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency).
|
||||
- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
|
||||
- **Boundary:** No UI, no emails – only resource, persistence, and actions callable with nil actor.
|
||||
- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update).
|
||||
|
||||
#### 2. Submit and confirm flow **(done)**
|
||||
|
||||
- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h.
|
||||
- **Boundary:** No join-form UI, no admin settings – only backend create/update and email/route.
|
||||
- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases.
|
||||
|
||||
#### 3. Admin: Join form settings **(done)**
|
||||
|
||||
- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed).
|
||||
- **Boundary:** No public form – only save/load of config and **server-side allowlist** for use in subtask 4.
|
||||
- **Done:** Settings save/load; allowlist available in backend for join form; tests.
|
||||
|
||||
#### 4. Public join page and anti-abuse **(done)**
|
||||
|
||||
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
|
||||
- **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
|
||||
- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated.
|
||||
|
||||
### Order and dependencies
|
||||
|
||||
- **1 → 2:** Submit/confirm flow uses JoinRequest resource.
|
||||
- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after.
|
||||
- **Recommended order:** **1** → **2** → **3** → **4** (or 3 in parallel with 2 if two people work on it).
|
||||
|
||||
### Step 2 – Approval (1 subtask, later)
|
||||
|
||||
#### 5. Approval UI (Vorstand)
|
||||
|
||||
- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1.
|
||||
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency).
|
||||
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions.
|
||||
- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user).
|
||||
- `lib/mv_web/plugs/check_page_permission.ex` – Public path list; **add `/join`** in `public_path?/1`.
|
||||
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` – Existing confirmation-email pattern (token, link, Mailer).
|
||||
- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) – Rate limiting for Phoenix/Plug.
|
||||
- Issue #308 – Original feature/planning context.
|
||||
|
|
@ -31,12 +31,18 @@ This document lists all protected routes, which permission set may access them,
|
|||
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||
| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ |
|
||||
| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ |
|
||||
|
||||
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
|
||||
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks).
|
||||
|
||||
## Public Paths (no permission check)
|
||||
|
||||
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
|
||||
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`**
|
||||
|
||||
The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`.
|
||||
|
||||
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).
|
||||
|
||||
## Test Coverage
|
||||
|
||||
|
|
@ -51,6 +57,7 @@ This document lists all protected routes, which permission set may access them,
|
|||
- Unauthenticated: nil user denied, redirect `/sign-in`.
|
||||
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
|
||||
- Error: no role, invalid permission_set_name → denied.
|
||||
- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added.
|
||||
|
||||
### Integration tests (full router, Mitglied = own_data)
|
||||
|
||||
|
|
|
|||
44
docs/settings-authentication-mockup.txt
Normal file
44
docs/settings-authentication-mockup.txt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Settings page – Authentication section (ASCII mockup)
|
||||
|
||||
Structure after renaming "OIDC" to "Authentication" and adding the registration toggle.
|
||||
Subsections use their own headings (h3) inside the main "Authentication" form_section.
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| Settings |
|
||||
| Manage global settings for the association. |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- Club Settings -------------------------------------------------+
|
||||
| Association Name: [________________] [Save Name] |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- Join Form -----------------------------------------------------+
|
||||
| ... (unchanged) |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- SMTP / E-Mail -------------------------------------------------+
|
||||
| ... |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- Accounting-Software (Vereinfacht) Integration -----------------+
|
||||
| ... |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
+-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)")
|
||||
| |
|
||||
| Direct registration | <-- subsection heading (h3)
|
||||
| [x] Allow direct registration (/register) |
|
||||
| If disabled, users cannot sign up via /register; sign-in |
|
||||
| and the join form remain available. |
|
||||
| |
|
||||
| OIDC (Single Sign-On) | <-- subsection heading (h3)
|
||||
| (Some values are set via environment variables...) |
|
||||
| Client ID: [________________] |
|
||||
| Base URL: [________________] |
|
||||
| Redirect URI: [________________] |
|
||||
| Client Secret: [________________] (set) |
|
||||
| Admin group name: [________________] |
|
||||
| Groups claim: [________________] |
|
||||
| [ ] Only OIDC sign-in (hide password login) |
|
||||
| [Save OIDC Settings] |
|
||||
+------------------------------------------------------------------+
|
||||
133
docs/smtp-configuration-concept.md
Normal file
133
docs/smtp-configuration-concept.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# SMTP Configuration – Concept
|
||||
|
||||
**Status:** Implemented
|
||||
**Last updated:** 2026-03-12
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
|
||||
- **Out of scope:** Separate adapters per email type; retry queues.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration Sources
|
||||
|
||||
| Source | Priority | Use case |
|
||||
|----------|----------|-----------------------------------|
|
||||
| ENV | 1 | Production, Docker, 12-factor |
|
||||
| Settings | 2 | Admin UI, dev without ENV |
|
||||
|
||||
When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment").
|
||||
|
||||
---
|
||||
|
||||
## 4. SMTP Parameters
|
||||
|
||||
| Parameter | ENV | Settings attribute | Notes |
|
||||
|----------------|------------------------|---------------------|---------------------------------------------|
|
||||
| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
|
||||
| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
|
||||
| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
|
||||
| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
|
||||
| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
|
||||
| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
|
||||
| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
|
||||
| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
|
||||
|
||||
**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
|
||||
|
||||
**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4).
|
||||
|
||||
---
|
||||
|
||||
## 5. Password from File
|
||||
|
||||
Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
|
||||
|
||||
---
|
||||
|
||||
## 6. Behaviour When SMTP Is Not Configured
|
||||
|
||||
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
|
||||
- **Production:** If neither ENV nor Settings provide SMTP (no host):
|
||||
- Show a warning in the Settings UI.
|
||||
- Delivery attempts silently fall back to the Local adapter (no crash).
|
||||
|
||||
---
|
||||
|
||||
## 7. Test Email (Settings UI)
|
||||
|
||||
- **Location:** SMTP / E-Mail section in Global Settings.
|
||||
- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
|
||||
- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
|
||||
- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
|
||||
- **Permission:** Reuses existing Settings page authorization (admin).
|
||||
|
||||
---
|
||||
|
||||
## 8. Sender Identity (`mail_from`)
|
||||
|
||||
`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
|
||||
1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
|
||||
2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
|
||||
3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
|
||||
|
||||
Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Join Confirmation Email
|
||||
|
||||
`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
|
||||
|
||||
---
|
||||
|
||||
## 10. AshAuthentication Senders
|
||||
|
||||
Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
|
||||
|
||||
---
|
||||
|
||||
## 11. TLS / SSL in OTP 27
|
||||
|
||||
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
|
||||
|
||||
By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification:
|
||||
|
||||
- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config.
|
||||
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
|
||||
|
||||
Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only).
|
||||
|
||||
---
|
||||
|
||||
## 12. Summary Checklist
|
||||
|
||||
- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
|
||||
- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
|
||||
- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
|
||||
- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
|
||||
- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
|
||||
- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
|
||||
- [x] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465).
|
||||
- [x] Prod warning: clear message in Settings when SMTP is not configured.
|
||||
- [x] Test email: form with recipient field, translatable content, classified success/error messages.
|
||||
- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin.
|
||||
- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
|
||||
- [x] Gettext for all new UI strings, translated to German.
|
||||
- [x] Docs and code guidelines updated.
|
||||
|
||||
---
|
||||
|
||||
## 13. Follow-up / Future Work
|
||||
|
||||
- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue.
|
||||
- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used.
|
||||
47
docs/vereinfacht-api.md
Normal file
47
docs/vereinfacht-api.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Vereinfacht API Integration
|
||||
|
||||
This document describes the current integration with the Vereinfacht (verein.visuel.dev) accounting API for syncing members as finance contacts.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose:** Create and update external finance contacts in Vereinfacht when members are created or updated; support bulk sync for members without a contact ID.
|
||||
- **Configuration:** ENV or Settings: `VEREINFACHT_API_URL`, `VEREINFACHT_API_KEY`, `VEREINFACHT_CLUB_ID`, optional `VEREINFACHT_APP_URL` for contact view links.
|
||||
- **Modules:** `Mv.Vereinfacht` (business logic), `Mv.Vereinfacht.Client` (HTTP client), `Mv.Vereinfacht.Changes.SyncContact` (Ash after_transaction change).
|
||||
|
||||
## API Usage
|
||||
|
||||
### Finding an existing contact by email
|
||||
|
||||
The API supports filtered list requests. Use a single GET instead of paginating:
|
||||
|
||||
- **Endpoint:** `GET /api/v1/finance-contacts?filter[isExternal]=true&filter[email]=<email>`
|
||||
- **Client:** `Mv.Vereinfacht.Client.find_contact_by_email/1` builds this URL (with encoded email) and returns `{:ok, contact_id}` if the first match exists, `{:error, :not_found}` otherwise.
|
||||
- No member fields are required in the app solely for this lookup.
|
||||
|
||||
### Creating a contact
|
||||
|
||||
When creating an external finance contact, the API only requires:
|
||||
|
||||
- **Attributes:** `contactType` (e.g. `"person"`), `isExternal: true`
|
||||
- **Relationship:** `club` (club ID from config)
|
||||
|
||||
Additional attributes (firstName, lastName, email, address, zipCode, city, country) are optional and are sent when present on the member so the contact is filled in. The app does **not** enforce extra required member fields for Vereinfacht; only Settings-based required fields and email apply.
|
||||
|
||||
- **Client:** `Mv.Vereinfacht.Client.create_contact/1` builds the JSON:API body from the member; `Mv.Constants.vereinfacht_required_member_fields/0` is an empty list.
|
||||
|
||||
### Updating a contact
|
||||
|
||||
- **Endpoint:** `PATCH /api/v1/finance-contacts/:id`
|
||||
- **Client:** `Mv.Vereinfacht.Client.update_contact/2` sends current member attributes. The API may still validate presence/format of fields on update.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Member create/update:** `SyncContact` runs after the transaction. If the member has no `vereinfacht_contact_id`, the client tries `find_contact_by_email(email)`; if found, it updates that contact and stores the ID on the member; otherwise it creates a contact and stores the new ID. If the member already has a contact ID, the client updates the contact.
|
||||
2. **Bulk sync:** “Sync all members without Vereinfacht contact” calls `Vereinfacht.sync_members_without_contact/0`, which loads members with nil/blank `vereinfacht_contact_id` and runs the same create/update flow per member.
|
||||
|
||||
## References
|
||||
|
||||
- **Config:** `Mv.Config` (`vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`, `vereinfacht_configured?/0`).
|
||||
- **Constants:** `Mv.Constants.vereinfacht_required_member_fields/0` (empty), `vereinfacht_required_field?/1` (legacy; currently unused in UI or validation).
|
||||
- **Tests:** `test/mv/vereinfacht/`, `test/mv/config_vereinfacht_test.exs`; see `test/mv/vereinfacht/vereinfacht_test_README.md` for scope.
|
||||
- **Roadmap:** Payment/transaction import and deeper integration are tracked in `docs/feature-roadmap.md` and `docs/membership-fee-architecture.md`.
|
||||
|
|
@ -9,7 +9,7 @@ defmodule Mv.Accounts do
|
|||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
|
||||
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
|
||||
- Authentication: `create_register_with_oidc/1`, `read_sign_in_with_oidc/1`
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
|
@ -24,8 +24,8 @@ defmodule Mv.Accounts do
|
|||
define :list_users, action: :read
|
||||
define :update_user, action: :update_user
|
||||
define :destroy_user, action: :destroy
|
||||
define :create_register_with_rauthy, action: :register_with_rauthy
|
||||
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
|
||||
define :create_register_with_oidc, action: :register_with_oidc
|
||||
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
|
||||
end
|
||||
|
||||
resource Mv.Accounts.Token
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ defmodule Mv.Accounts.User do
|
|||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Resource.Preparation.Builtins
|
||||
alias Mv.Authorization.Role, as: RoleResource
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.OidcRoleSync
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
repo Mv.Repo
|
||||
|
|
@ -28,7 +33,7 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
@doc """
|
||||
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
||||
Currently password and SSO with Rauthy as OIDC provider
|
||||
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
"""
|
||||
authentication do
|
||||
session_identifier Application.compile_env!(:mv, :session_identifier)
|
||||
|
|
@ -52,7 +57,7 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
strategies do
|
||||
oidc :rauthy do
|
||||
oidc :oidc do
|
||||
client_id Mv.Secrets
|
||||
base_url Mv.Secrets
|
||||
redirect_uri Mv.Secrets
|
||||
|
|
@ -88,7 +93,7 @@ defmodule Mv.Accounts.User do
|
|||
# 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)
|
||||
# - :register_with_oidc (for OIDC-based registration)
|
||||
defaults [:read]
|
||||
|
||||
destroy :destroy do
|
||||
|
|
@ -118,6 +123,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
create :create_user do
|
||||
|
|
@ -145,6 +152,8 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
|
|
@ -178,6 +187,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where any([changing(:email), changing(:member)])
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
|
||||
|
|
@ -211,6 +222,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
|
|
@ -248,6 +261,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
|
|
@ -257,7 +272,7 @@ defmodule Mv.Accounts.User do
|
|||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_rauthy do
|
||||
read :sign_in_with_oidc do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
|
|
@ -272,27 +287,27 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
|
||||
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
|
||||
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
|
||||
prepare Builtins.after_action(fn query, result, _context ->
|
||||
user_info = Ash.Query.get_argument(query, :user_info) || %{}
|
||||
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
|
||||
|
||||
users =
|
||||
case result do
|
||||
nil -> []
|
||||
u when is_struct(u, User) -> [u]
|
||||
u when is_struct(u, __MODULE__) -> [u]
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
end
|
||||
|
||||
create :register_with_rauthy do
|
||||
create :register_with_oidc do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
|
|
@ -328,6 +343,8 @@ defmodule Mv.Accounts.User do
|
|||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
|
||||
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
|
@ -345,6 +362,12 @@ defmodule Mv.Accounts.User do
|
|||
# Authorization Policies
|
||||
# Order matters: Most specific policies first, then general permission check
|
||||
policies do
|
||||
# When OIDC-only is active, password sign-in is forbidden (SSO only).
|
||||
policy action(:sign_in_with_password) do
|
||||
forbid_if Mv.Authorization.Checks.OidcOnlyActive
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
# AshAuthentication bypass (registration/login without actor)
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
description "Allow AshAuthentication internal operations (registration, login)"
|
||||
|
|
@ -388,6 +411,14 @@ defmodule Mv.Accounts.User do
|
|||
where: [action_is([:register_with_password, :admin_set_password])],
|
||||
message: "must have length of at least 8"
|
||||
|
||||
# Block direct registration when disabled in global settings
|
||||
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
||||
where: [action_is(:register_with_password)]
|
||||
|
||||
# Block password registration when OIDC-only mode is active
|
||||
validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
|
||||
where: [action_is(:register_with_password)]
|
||||
|
||||
# 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
|
||||
|
|
@ -471,10 +502,10 @@ defmodule Mv.Accounts.User do
|
|||
|> Enum.map(& &1.id)
|
||||
|
||||
# Count only non-system users with admin role (system user is for internal ops)
|
||||
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||
system_email = SystemActor.system_user_email()
|
||||
|
||||
count =
|
||||
Mv.Accounts.User
|
||||
__MODULE__
|
||||
|> Ash.Query.for_read(:read)
|
||||
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||
|> Ash.Query.filter(expr(email != ^system_email))
|
||||
|
|
@ -500,7 +531,7 @@ defmodule Mv.Accounts.User do
|
|||
# Prevent modification of the system actor user (required for internal operations).
|
||||
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||
validate fn changeset, _context ->
|
||||
if Mv.Helpers.SystemActor.system_user?(changeset.data) do
|
||||
if SystemActor.system_user?(changeset.data) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
|
|
@ -629,8 +660,8 @@ defmodule Mv.Accounts.User do
|
|||
case Process.get({__MODULE__, :default_role_id}) do
|
||||
nil ->
|
||||
role_id =
|
||||
case Mv.Authorization.Role.get_mitglied_role() do
|
||||
{:ok, %Mv.Authorization.Role{id: id}} -> id
|
||||
case RoleResource.get_mitglied_role() do
|
||||
{:ok, %RoleResource{id: id}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
|
@ -43,10 +45,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
existing_oidc_user =
|
||||
case Mv.Accounts.User
|
||||
case User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one(actor: system_actor) do
|
||||
{:ok, user} -> user
|
||||
|
|
@ -62,7 +64,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
|
||||
# Find existing user with this email
|
||||
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
||||
case Mv.Accounts.User
|
||||
case User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one(actor: system_actor) do
|
||||
{:ok, nil} ->
|
||||
|
|
@ -164,7 +166,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
def atomic?, do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
|
||||
@moduledoc """
|
||||
Validation that blocks direct registration (register_with_password) when
|
||||
OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
|
||||
only allowed via OIDC (SSO).
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(_changeset, _opts, _context) do
|
||||
if Mv.Config.oidc_only?() do
|
||||
{:error,
|
||||
field: :base,
|
||||
message:
|
||||
Gettext.dgettext(
|
||||
MvWeb.Gettext,
|
||||
"default",
|
||||
"Registration with password is disabled when only OIDC sign-in is active."
|
||||
)}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
31
lib/accounts/user/validations/registration_enabled.ex
Normal file
31
lib/accounts/user/validations/registration_enabled.ex
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
|
||||
@moduledoc """
|
||||
Validation that blocks direct registration (register_with_password) when
|
||||
registration is disabled in global settings. Used so that even direct API/form
|
||||
submissions cannot register when the setting is off.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(_changeset, _opts, _context) do
|
||||
case Membership.get_settings() do
|
||||
{:ok, %{registration_enabled: true}} ->
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
field: :base,
|
||||
message:
|
||||
Gettext.dgettext(
|
||||
MvWeb.Gettext,
|
||||
"default",
|
||||
"Registration is disabled. Please use the join form or contact an administrator."
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do
|
|||
## 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`)
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
||||
- `description` - Optional human-readable description
|
||||
- `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
|
||||
|
|
@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do
|
|||
## Constraints
|
||||
- Name must be unique across all custom fields
|
||||
- Name maximum length: 100 characters
|
||||
- `value_type` cannot be changed after creation (immutable)
|
||||
- Deleting a custom field will cascade delete all associated custom field values
|
||||
|
||||
## Calculations
|
||||
|
|
@ -51,7 +52,8 @@ defmodule Mv.Membership.CustomField do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
primary_read_warning?: false
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
|
|
@ -59,15 +61,32 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :update]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
prepare build(sort: [name: :asc])
|
||||
end
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:name, :description, :required, :show_in_overview]
|
||||
require_atomic? false
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if Ash.Changeset.changing_attribute?(changeset, :value_type) do
|
||||
{:error, field: :value_type, message: "cannot be changed after creation"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
destroy :destroy_with_values do
|
||||
primary? true
|
||||
end
|
||||
|
|
|
|||
13
lib/membership/join_notifier.ex
Normal file
13
lib/membership/join_notifier.ex
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Mv.Membership.JoinNotifier do
|
||||
@moduledoc """
|
||||
Behaviour for sending join-related emails (confirmation, already member, already pending).
|
||||
|
||||
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
|
||||
does not depend on the web layer. The default implementation is set in config
|
||||
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
|
||||
"""
|
||||
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
|
||||
{:ok, term()} | {:error, term()}
|
||||
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
|
||||
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
|
||||
end
|
||||
219
lib/membership/join_request.ex
Normal file
219
lib/membership/join_request.ex
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
defmodule Mv.Membership.JoinRequest do
|
||||
@moduledoc """
|
||||
Ash resource for public join requests (onboarding, double opt-in).
|
||||
|
||||
A JoinRequest is created on form submit with status `pending_confirmation`, then
|
||||
updated to `submitted` when the user clicks the confirmation link. No User or
|
||||
Member is created in this flow; promotion happens in a later approval step.
|
||||
|
||||
## Public actions (actor: nil)
|
||||
- `submit` (create) – create with token hash and expiry
|
||||
- `get_by_confirmation_token_hash` (read) – lookup by token hash for confirm flow
|
||||
- `confirm` (update) – set status to submitted and invalidate token
|
||||
|
||||
## Schema
|
||||
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
|
||||
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "join_requests"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :submit do
|
||||
description "Create a join request (public form submit); stores token hash and expiry"
|
||||
primary? true
|
||||
|
||||
argument :confirmation_token, :string, allow_nil?: false
|
||||
|
||||
accept [:email, :first_name, :last_name, :form_data, :schema_version]
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
|
||||
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
|
||||
end
|
||||
|
||||
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
|
||||
create :create_submitted do
|
||||
description "Create a join request with status submitted (seeds, internal use only)"
|
||||
accept [:email, :first_name, :last_name, :form_data, :schema_version]
|
||||
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
|
||||
end
|
||||
|
||||
read :get_by_confirmation_token_hash do
|
||||
description "Find a join request by confirmation token hash (for confirm flow only)"
|
||||
argument :confirmation_token_hash, :string, allow_nil?: false
|
||||
|
||||
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
|
||||
|
||||
prepare build(sort: [inserted_at: :desc], limit: 1)
|
||||
end
|
||||
|
||||
update :confirm do
|
||||
description "Mark join request as submitted and invalidate token (after link click)"
|
||||
primary? true
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
|
||||
end
|
||||
|
||||
update :approve do
|
||||
description "Approve a submitted join request and promote to Member"
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.ApproveRequest
|
||||
end
|
||||
|
||||
update :reject do
|
||||
description "Reject a submitted join request"
|
||||
require_atomic? false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.RejectRequest
|
||||
end
|
||||
|
||||
# Internal: resend confirmation (new token) when user submits form again with same email.
|
||||
# Called from domain with authorize?: false; not exposed to public.
|
||||
update :regenerate_confirmation_token do
|
||||
description "Set new confirmation token and expiry (resend flow)"
|
||||
require_atomic? false
|
||||
|
||||
argument :confirmation_token, :string, allow_nil?: false
|
||||
|
||||
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
# Use :strict so unauthorized access returns Forbidden (not empty list).
|
||||
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
|
||||
default_access_type :strict
|
||||
|
||||
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
|
||||
# Using bypass (not policy) avoids AND-combination with the read policy below.
|
||||
bypass action(:submit) do
|
||||
description "Allow unauthenticated submit (public join form)"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
bypass action(:get_by_confirmation_token_hash) do
|
||||
description "Allow unauthenticated lookup by token hash for confirm"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
bypass action(:confirm) do
|
||||
description "Allow unauthenticated confirm (confirmation link click)"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsNil
|
||||
end
|
||||
|
||||
# READ: bypass for authorized roles (normal_user, admin).
|
||||
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
|
||||
# expr(false), which would silently produce an empty list instead of Forbidden for
|
||||
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
|
||||
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
|
||||
bypass action_type(:read) do
|
||||
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
|
||||
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
|
||||
end
|
||||
|
||||
# Approve/Reject: only actors with JoinRequest update permission
|
||||
policy action(:approve) do
|
||||
description "Allow authenticated users with JoinRequest update permission to approve"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action(:reject) do
|
||||
description "Allow authenticated users with JoinRequest update permission to reject"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
# Format/formatting of email is not validated here; invalid addresses may fail at send time
|
||||
# or can be enforced via an Ash change if needed.
|
||||
validate present(:email), on: [:create]
|
||||
end
|
||||
|
||||
# Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :status, :atom do
|
||||
description "pending_confirmation | submitted | approved | rejected"
|
||||
default :pending_confirmation
|
||||
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :email, :string do
|
||||
description "Email address (required for join form)"
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :first_name, :string
|
||||
attribute :last_name, :string
|
||||
|
||||
attribute :form_data, :map do
|
||||
description "Additional form fields (jsonb)"
|
||||
end
|
||||
|
||||
attribute :schema_version, :integer do
|
||||
description "Version of join form / member_fields for form_data"
|
||||
end
|
||||
|
||||
attribute :confirmation_token_hash, :string do
|
||||
description "SHA256 hash of confirmation token; raw token only in email link"
|
||||
end
|
||||
|
||||
attribute :confirmation_token_expires_at, :utc_datetime_usec do
|
||||
description "When the confirmation link expires (e.g. 24h)"
|
||||
end
|
||||
|
||||
attribute :confirmation_sent_at, :utc_datetime_usec do
|
||||
description "When the confirmation email was sent"
|
||||
end
|
||||
|
||||
attribute :submitted_at, :utc_datetime_usec do
|
||||
description "When the user confirmed (clicked the link)"
|
||||
end
|
||||
|
||||
attribute :approved_at, :utc_datetime_usec
|
||||
attribute :rejected_at, :utc_datetime_usec
|
||||
attribute :reviewed_by_user_id, :uuid
|
||||
|
||||
attribute :reviewed_by_display, :string do
|
||||
description "Denormalized reviewer display (e.g. email) for UI without loading User"
|
||||
end
|
||||
|
||||
attribute :source, :string
|
||||
|
||||
create_timestamp :inserted_at
|
||||
update_timestamp :updated_at
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :reviewed_by_user, Mv.Accounts.User do
|
||||
define_attribute? false
|
||||
source_attribute :reviewed_by_user_id
|
||||
end
|
||||
end
|
||||
|
||||
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
|
||||
|
||||
@doc """
|
||||
Returns the SHA256 hash of the confirmation token (lowercase hex).
|
||||
|
||||
Used when creating a join request (submit) and when confirming by token.
|
||||
Only one implementation ensures algorithm changes stay in sync.
|
||||
"""
|
||||
@spec hash_confirmation_token(String.t()) :: String.t()
|
||||
def hash_confirmation_token(token) when is_binary(token) do
|
||||
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
33
lib/membership/join_request/changes/approve_request.ex
Normal file
33
lib/membership/join_request/changes/approve_request.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
|
||||
@moduledoc """
|
||||
Sets the join request to approved and records the reviewer.
|
||||
|
||||
Only transitions from :submitted status. If already approved, returns error
|
||||
(idempotency guard via status validation). Promotion to Member is handled
|
||||
by the domain function approve_join_request/2 after calling this action.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest.Changes.Helpers
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, context) do
|
||||
current_status = Ash.Changeset.get_data(changeset, :status)
|
||||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
||||
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
message: "can only approve a submitted join request (current status: #{current_status})"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
25
lib/membership/join_request/changes/confirm_request.ex
Normal file
25
lib/membership/join_request/changes/confirm_request.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
|
||||
@moduledoc """
|
||||
Sets the join request to submitted (confirmation link clicked).
|
||||
|
||||
Used by the confirm action after the user clicks the confirmation link.
|
||||
Only applies when the current status is `:pending_confirmation`, so that
|
||||
direct calls to the confirm action are idempotent and never overwrite
|
||||
:submitted, :approved, or :rejected. Token hash is kept so a second click
|
||||
can still find the record and return success without changing it.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
current_status = Ash.Changeset.get_data(changeset, :status)
|
||||
|
||||
if current_status == :pending_confirmation do
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|
||||
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
|
||||
@moduledoc """
|
||||
Filters form_data to only keys that are in the join form allowlist (server-side).
|
||||
|
||||
Ensures that even when submit_join_request/2 is called directly (e.g. from tests or API),
|
||||
only allowlisted custom fields are persisted. Typed fields (email, first_name, last_name)
|
||||
are not part of form_data; allowlist is join_form_field_ids minus those.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
@typed_fields ["email", "first_name", "last_name"]
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
||||
|
||||
allowlist_ids =
|
||||
case Membership.get_join_form_allowlist() do
|
||||
list when is_list(list) ->
|
||||
list
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
_ ->
|
||||
MapSet.new()
|
||||
end
|
||||
|
||||
filtered =
|
||||
form_data
|
||||
|> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end)
|
||||
|> Map.new()
|
||||
|
||||
Ash.Changeset.force_change_attribute(changeset, :form_data, filtered)
|
||||
end
|
||||
end
|
||||
39
lib/membership/join_request/changes/helpers.ex
Normal file
39
lib/membership/join_request/changes/helpers.ex
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.Helpers do
|
||||
@moduledoc """
|
||||
Shared helpers for JoinRequest change modules (e.g. ApproveRequest, RejectRequest).
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Extracts the actor's user id from the Ash change context.
|
||||
|
||||
Supports both atom and string keys for compatibility with different actor representations.
|
||||
"""
|
||||
@spec actor_id(term()) :: String.t() | nil
|
||||
def actor_id(nil), do: nil
|
||||
|
||||
def actor_id(actor) when is_map(actor) do
|
||||
Map.get(actor, :id) || Map.get(actor, "id")
|
||||
end
|
||||
|
||||
def actor_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Extracts the actor's email for display (e.g. reviewed_by_display).
|
||||
|
||||
Supports both atom and string keys for compatibility with different actor representations.
|
||||
"""
|
||||
@spec actor_email(term()) :: String.t() | nil
|
||||
def actor_email(nil), do: nil
|
||||
|
||||
def actor_email(actor) when is_map(actor) do
|
||||
raw = Map.get(actor, :email) || Map.get(actor, "email")
|
||||
if is_nil(raw), do: nil, else: actor_email_string(raw)
|
||||
end
|
||||
|
||||
def actor_email(_), do: nil
|
||||
|
||||
defp actor_email_string(raw) do
|
||||
s = raw |> to_string() |> String.trim()
|
||||
if s == "", do: nil, else: s
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
|
||||
@moduledoc """
|
||||
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
|
||||
|
||||
Used when the user submits the join form again with the same email while a request
|
||||
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@confirmation_validity_hours 24
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
|
||||
|
||||
if is_binary(token) and token != "" do
|
||||
now = DateTime.utc_now()
|
||||
expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(
|
||||
:confirmation_token_hash,
|
||||
JoinRequest.hash_confirmation_token(token)
|
||||
)
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
32
lib/membership/join_request/changes/reject_request.ex
Normal file
32
lib/membership/join_request/changes/reject_request.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
|
||||
@moduledoc """
|
||||
Sets the join request to rejected and records the reviewer.
|
||||
|
||||
Only transitions from :submitted status. Returns an error for any other status.
|
||||
No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest.Changes.Helpers
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, context) do
|
||||
current_status = Ash.Changeset.get_data(changeset, :status)
|
||||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|
||||
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
message: "can only reject a submitted join request (current status: #{current_status})"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do
|
||||
@moduledoc """
|
||||
Hashes the confirmation token and sets expiry for the join request (submit flow).
|
||||
|
||||
Uses `JoinRequest.hash_confirmation_token/1` so hashing logic lives in one place.
|
||||
|
||||
Reads the :confirmation_token argument, stores only its SHA256 hash and sets
|
||||
confirmation_token_expires_at (e.g. 24h). Raw token is never persisted.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@confirmation_validity_hours 24
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
|
||||
|
||||
if is_binary(token) and token != "" do
|
||||
hash = JoinRequest.hash_confirmation_token(token)
|
||||
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|
||||
|> Ash.Changeset.force_change_attribute(:status, :pending_confirmation)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do
|
||||
@moduledoc """
|
||||
Sets status to :submitted and submitted_at for seed/internal creation.
|
||||
|
||||
Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|
||||
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|
||||
end
|
||||
end
|
||||
|
|
@ -22,7 +22,6 @@ defmodule Mv.Membership.Member do
|
|||
## Validations
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- 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
|
||||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||||
|
|
@ -38,12 +37,19 @@ defmodule Mv.Membership.Member do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
import Bitwise
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
||||
alias Mv.Helpers
|
||||
require Logger
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
|
@ -117,6 +123,9 @@ defmodule Mv.Membership.Member do
|
|||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle generation after member creation
|
||||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
|
|
@ -190,6 +199,9 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
||||
|
|
@ -243,6 +255,13 @@ defmodule Mv.Membership.Member do
|
|||
end)
|
||||
end
|
||||
|
||||
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
|
||||
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
|
||||
update :set_vereinfacht_contact_id do
|
||||
require_atomic? false
|
||||
accept [:vereinfacht_contact_id]
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
read :search do
|
||||
argument :query, :string, allow_nil?: true
|
||||
|
|
@ -320,6 +339,12 @@ defmodule Mv.Membership.Member do
|
|||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
|
||||
policy action(:set_vereinfacht_contact_id) do
|
||||
description "Only system actor may set Vereinfacht contact ID"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
|
||||
end
|
||||
|
||||
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
||||
|
|
@ -458,11 +483,6 @@ defmodule Mv.Membership.Member do
|
|||
where: [present([:join_date, :exit_date])],
|
||||
message: "cannot be before join date"
|
||||
|
||||
# Postal code format (only if set)
|
||||
validate match(:postal_code, ~r/^\d{5}$/),
|
||||
where: [present(:postal_code)],
|
||||
message: "must consist of 5 digits"
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator
|
||||
validate fn changeset, _ ->
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
|
@ -481,48 +501,92 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Validate required custom fields (actor from validation context only; no fallback)
|
||||
# Validate required custom fields (actor from validation context only; no fallback).
|
||||
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
|
||||
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
|
||||
validate fn changeset, context ->
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields =
|
||||
missing_required_fields(required_custom_fields, provided_values)
|
||||
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
end
|
||||
end,
|
||||
where: [action_is([:create_member, :update_member])]
|
||||
|
||||
# Validate member fields that are marked as required in settings.
|
||||
# When settings cannot be loaded, enforce only email.
|
||||
validate fn changeset, _context ->
|
||||
required_fields =
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email || Map.get(normalized, field, false)
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
|
||||
"Enforcing only email."
|
||||
)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email
|
||||
end)
|
||||
end
|
||||
|
||||
missing =
|
||||
Enum.filter(required_fields, fn field ->
|
||||
value = Ash.Changeset.get_attribute(changeset, field)
|
||||
not member_field_value_present?(field, value)
|
||||
end)
|
||||
|
||||
if Enum.empty?(missing) do
|
||||
:ok
|
||||
else
|
||||
field = hd(missing)
|
||||
|
||||
{:error,
|
||||
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -580,6 +644,10 @@ defmodule Mv.Membership.Member do
|
|||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :country, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :search_vector, AshPostgres.Tsvector,
|
||||
writable?: false,
|
||||
public?: false,
|
||||
|
|
@ -593,6 +661,14 @@ defmodule Mv.Membership.Member do
|
|||
public? true
|
||||
description "Date from which membership fees should be calculated"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
|
||||
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
|
||||
attribute :vereinfacht_contact_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "ID of the finance contact in Vereinfacht (set by sync)"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -739,7 +815,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
|
@ -773,7 +849,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) == :gt
|
||||
|
||||
|
|
@ -789,7 +865,7 @@ defmodule Mv.Membership.Member do
|
|||
cycles,
|
||||
fn cycle ->
|
||||
interval = Map.get(cycle, :membership_fee_type).interval
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
end,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
|
@ -816,7 +892,7 @@ defmodule Mv.Membership.Member do
|
|||
case Map.get(cycle, :membership_fee_type) do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
||||
|
||||
|
|
@ -826,6 +902,25 @@ defmodule Mv.Membership.Member do
|
|||
end)
|
||||
end
|
||||
|
||||
# Returns a deterministic 64-bit key for pg_advisory_xact_lock from a member id (UUID string).
|
||||
# Reduces collision risk vs phash2 when multiple members are locked.
|
||||
@doc false
|
||||
def advisory_lock_key_for_member_id(member_id) when is_binary(member_id) do
|
||||
hex = String.replace(member_id, "-", "")
|
||||
|
||||
if String.length(hex) >= 16 do
|
||||
first_8_hex = String.slice(hex, 0, 16)
|
||||
bin = Base.decode16!(first_8_hex, case: :lower)
|
||||
decoded = :binary.decode_unsigned(bin, :big)
|
||||
# Postgres bigint is signed 64-bit; keep in non-negative range
|
||||
rem(decoded, 1 <<< 63)
|
||||
else
|
||||
:erlang.phash2(member_id)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> :erlang.phash2(member_id)
|
||||
end
|
||||
|
||||
# Regenerates cycles when membership fee type changes
|
||||
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Uses advisory lock to prevent concurrent modifications
|
||||
|
|
@ -834,15 +929,12 @@ defmodule Mv.Membership.Member do
|
|||
@doc false
|
||||
# Uses system actor for cycle regeneration (mandatory side effect)
|
||||
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
today = Date.utc_today()
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
lock_key = advisory_lock_key_for_member_id(member.id)
|
||||
|
||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||
# This ensures atomicity when multiple updates happen simultaneously
|
||||
if Mv.Repo.in_transaction?() do
|
||||
if Repo.in_transaction?() do
|
||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||
else
|
||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||
|
|
@ -852,15 +944,15 @@ defmodule Mv.Membership.Member do
|
|||
# Already in transaction: use advisory lock directly
|
||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||
end
|
||||
|
||||
# Not in transaction: start new transaction with advisory lock
|
||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||
Mv.Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
Repo.transaction(fn ->
|
||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||
{:ok, notifications} ->
|
||||
|
|
@ -868,7 +960,7 @@ defmodule Mv.Membership.Member do
|
|||
notifications
|
||||
|
||||
{:error, reason} ->
|
||||
Mv.Repo.rollback(reason)
|
||||
Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
|
|
@ -882,9 +974,6 @@ defmodule Mv.Membership.Member do
|
|||
# notifications are collected to be sent after transaction commits
|
||||
# Uses system actor for all operations
|
||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
|
@ -894,7 +983,7 @@ defmodule Mv.Membership.Member do
|
|||
# Find all unpaid cycles for this member
|
||||
# We need to check cycle_end for each cycle using its own interval
|
||||
all_unpaid_cycles_query =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.Query.filter(status == :unpaid)
|
||||
|> Ash.Query.load([:membership_fee_type])
|
||||
|
|
@ -923,7 +1012,7 @@ defmodule Mv.Membership.Member do
|
|||
case cycle.membership_fee_type do
|
||||
%{interval: interval} ->
|
||||
cycle_end =
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
|
||||
|
|
@ -951,18 +1040,17 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||
# Uses system actor for authorization to ensure deletion always works
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
|
||||
# Returns the first error for debugging; uses system actor for authorization.
|
||||
defp delete_cycles(cycles_to_delete, actor_opts) do
|
||||
delete_results =
|
||||
Enum.map(cycles_to_delete, fn cycle ->
|
||||
Ash.destroy(cycle, actor_opts)
|
||||
end)
|
||||
|
||||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||
{:error, :deletion_failed}
|
||||
else
|
||||
:ok
|
||||
case Enum.find(delete_results, &match?({:error, _}, &1)) do
|
||||
{:error, reason} -> {:error, reason}
|
||||
nil -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -973,7 +1061,7 @@ defmodule Mv.Membership.Member do
|
|||
defp regenerate_cycles(member_id, today, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
case CycleGenerator.generate_cycles_for_member(
|
||||
member_id,
|
||||
today: today,
|
||||
skip_lock?: skip_lock?
|
||||
|
|
@ -1004,7 +1092,7 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member, initiator) do
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
case CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today(),
|
||||
initiator: initiator
|
||||
|
|
@ -1025,7 +1113,7 @@ defmodule Mv.Membership.Member do
|
|||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member, initiator) do
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
|
||||
case CycleGenerator.generate_cycles_for_member(member.id,
|
||||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
|
|
@ -1173,7 +1261,8 @@ defmodule Mv.Membership.Member do
|
|||
contains(postal_code, ^query) or
|
||||
contains(house_number, ^query) or
|
||||
contains(email, ^query) or
|
||||
contains(city, ^query)
|
||||
contains(city, ^query) or
|
||||
contains(country, ^query)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -1273,17 +1362,24 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from existing member data (update scenario)
|
||||
# Extracts custom field values from existing member data (update scenario).
|
||||
# Actor must come from context; no system-actor fallback (per guidelines).
|
||||
# When no actor is present we skip the load and return empty map.
|
||||
defp extract_existing_values(member_data, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
case Map.get(changeset.context, :actor) do
|
||||
nil ->
|
||||
%{}
|
||||
|
||||
actor ->
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1386,4 +1482,14 @@ defmodule Mv.Membership.Member do
|
|||
defp value_present?(_value, :email), do: false
|
||||
|
||||
defp value_present?(_value, _type), do: false
|
||||
|
||||
# Used by member-field-required validation (settings-driven required fields)
|
||||
defp member_field_value_present?(_field, nil), do: false
|
||||
|
||||
defp member_field_value_present?(_, value) when is_binary(value),
|
||||
do: String.trim(value) != ""
|
||||
|
||||
defp member_field_value_present?(_, %Date{}), do: true
|
||||
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
|
||||
defp member_field_value_present?(_, _), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Mv.Membership do
|
|||
- `Setting` - Global application settings (singleton)
|
||||
- `Group` - Groups that members can belong to
|
||||
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
||||
- `JoinRequest` - Public join form submissions (pending_confirmation → submitted after email confirm)
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
|
|
@ -27,6 +28,12 @@ defmodule Mv.Membership do
|
|||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.SettingsCache
|
||||
require Logger
|
||||
|
||||
admin do
|
||||
show? true
|
||||
|
|
@ -64,6 +71,8 @@ defmodule Mv.Membership do
|
|||
|
||||
define :update_single_member_field_visibility,
|
||||
action: :update_single_member_field_visibility
|
||||
|
||||
define :update_single_member_field, action: :update_single_member_field
|
||||
end
|
||||
|
||||
resource Mv.Membership.Group do
|
||||
|
|
@ -78,6 +87,11 @@ defmodule Mv.Membership do
|
|||
define :list_member_groups, action: :read
|
||||
define :destroy_member_group, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.JoinRequest do
|
||||
# Public submit/confirm and approval domain functions are implemented as custom
|
||||
# functions below to handle cross-resource operations (Member promotion on approve).
|
||||
end
|
||||
end
|
||||
|
||||
# Singleton pattern: Get the single settings record
|
||||
|
|
@ -102,10 +116,16 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def get_settings do
|
||||
# Try to get the first (and only) settings record
|
||||
case Process.whereis(SettingsCache) do
|
||||
nil -> get_settings_uncached()
|
||||
_pid -> SettingsCache.get()
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def get_settings_uncached do
|
||||
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
|
||||
|
|
@ -146,9 +166,16 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def update_settings(settings, attrs) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _updated} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -212,11 +239,18 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
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__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -249,12 +283,66 @@ defmodule Mv.Membership do
|
|||
field: field,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Atomically updates visibility and required for a single member field.
|
||||
|
||||
Updates both `member_field_visibility` and `member_field_required` in one
|
||||
operation. Use this when saving from the member field settings form.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `field` - The member field name as a string (e.g., "first_name", "street")
|
||||
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||
- `required` - Boolean value indicating whether the field is required in member forms
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||
iex> updated.member_field_required["first_name"]
|
||||
true
|
||||
|
||||
"""
|
||||
def update_single_member_field(settings,
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -300,4 +388,511 @@ defmodule Mv.Membership do
|
|||
|> Keyword.put_new(:domain, __MODULE__)
|
||||
|> then(&Ash.read_one(query, &1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a join request (submit flow) and sends the confirmation email.
|
||||
|
||||
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
|
||||
`:confirmation_token` to get a known token). On success, sends one email with
|
||||
the confirm link to the request email.
|
||||
|
||||
## Options
|
||||
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
|
||||
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
|
||||
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
|
||||
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
|
||||
- `{:error, error}` - Validation or authorization error
|
||||
"""
|
||||
def submit_join_request(attrs, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
email = normalize_submit_email(attrs)
|
||||
|
||||
pending =
|
||||
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
|
||||
|
||||
cond do
|
||||
email != nil and email != "" and member_exists_with_email?(email) ->
|
||||
send_already_member_and_return(email)
|
||||
|
||||
pending != nil ->
|
||||
handle_already_pending(email, pending)
|
||||
|
||||
true ->
|
||||
do_create_join_request(attrs, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_submit_email(attrs) do
|
||||
raw = attrs["email"] || attrs[:email]
|
||||
if is_binary(raw), do: String.trim(raw), else: nil
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = [actor: system_actor, domain: __MODULE__]
|
||||
|
||||
case Ash.get(Member, %{email: email}, opts) do
|
||||
{:ok, _member} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp member_exists_with_email?(_), do: false
|
||||
|
||||
defp pending_join_request_with_email(email) when is_binary(email) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
|
||||
{:ok, request} -> request
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_join_request_with_email(_), do: nil
|
||||
|
||||
defp join_notifier do
|
||||
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
|
||||
end
|
||||
|
||||
defp send_already_member_and_return(email) do
|
||||
case join_notifier().send_already_member(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_member}
|
||||
end
|
||||
|
||||
defp handle_already_pending(email, existing) do
|
||||
if existing.status == :pending_confirmation do
|
||||
resend_confirmation_to_pending(email, existing)
|
||||
else
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp resend_confirmation_to_pending(email, request) do
|
||||
new_token = generate_confirmation_token()
|
||||
|
||||
case request
|
||||
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
|
||||
confirmation_token: new_token
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__, authorize?: false) do
|
||||
{:ok, _updated} ->
|
||||
case join_notifier().send_confirmation(email, new_token, resend: true) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
|
||||
{:error, _} ->
|
||||
# Fallback: do not create duplicate; send generic pending email
|
||||
send_already_pending_and_return(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_already_pending_and_return(email) do
|
||||
case join_notifier().send_already_pending(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
end
|
||||
|
||||
defp do_create_join_request(attrs, actor) do
|
||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
||||
|
||||
case Ash.create(JoinRequest, attrs_with_token,
|
||||
action: :submit,
|
||||
actor: actor,
|
||||
domain: __MODULE__
|
||||
) do
|
||||
{:ok, request} ->
|
||||
case join_notifier().send_confirmation(request.email, token, []) do
|
||||
{:ok, _email} ->
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, request}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:error, :email_delivery_failed}
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_confirmation_token do
|
||||
32
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a join request by token (public confirmation link).
|
||||
|
||||
Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that
|
||||
the token has not expired, then updates to status :submitted. Idempotent: if
|
||||
already submitted, approved, or rejected, returns the existing record without changing it.
|
||||
|
||||
## Options
|
||||
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - Updated or already-processed JoinRequest
|
||||
- `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past
|
||||
- `{:error, error}` - Token unknown/invalid or authorization error
|
||||
"""
|
||||
def confirm_join_request(token, opts \\ []) when is_binary(token) do
|
||||
hash = JoinRequest.hash_confirmation_token(token)
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
|
||||
confirmation_token_hash: hash
|
||||
})
|
||||
|
||||
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
|
||||
{:ok, nil} ->
|
||||
{:error, NotFoundError.exception(resource: JoinRequest)}
|
||||
|
||||
{:ok, request} ->
|
||||
do_confirm_request(request, actor)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_confirm_request(request, _actor)
|
||||
when request.status in [:submitted, :approved, :rejected] do
|
||||
{:ok, request}
|
||||
end
|
||||
|
||||
defp do_confirm_request(request, actor) do
|
||||
if expired?(request.confirmation_token_expires_at) do
|
||||
{:error, :token_expired}
|
||||
else
|
||||
request
|
||||
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|
||||
|> Ash.update(domain: __MODULE__, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the public join form is enabled in global settings.
|
||||
|
||||
Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether
|
||||
to show join-related UI and to gate access to join request pages.
|
||||
"""
|
||||
@spec join_form_enabled?() :: boolean()
|
||||
def join_form_enabled? do
|
||||
case get_settings() do
|
||||
{:ok, %{join_form_enabled: true}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the allowlist of fields configured for the public join form.
|
||||
|
||||
Reads the current settings. When the join form is disabled (or no settings exist),
|
||||
returns an empty list. When enabled, returns each configured field as a map with:
|
||||
- `:id` - field identifier string (member field name or custom field UUID)
|
||||
- `:required` - boolean; email is always true
|
||||
- `:type` - `:member_field` or `:custom_field`
|
||||
|
||||
This is the server-side allowlist used by the join form submit action (Subtask 4)
|
||||
to enforce which fields are accepted from user input.
|
||||
|
||||
## Returns
|
||||
|
||||
- `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
|
||||
- `[]` when join form is disabled or settings are missing
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Membership.get_join_form_allowlist()
|
||||
[%{id: "email", required: true, type: :member_field},
|
||||
%{id: "first_name", required: false, type: :member_field}]
|
||||
|
||||
"""
|
||||
def get_join_form_allowlist do
|
||||
case get_settings() do
|
||||
{:ok, settings} ->
|
||||
if settings.join_form_enabled do
|
||||
build_join_form_allowlist(settings)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp build_join_form_allowlist(settings) do
|
||||
field_ids = settings.join_form_field_ids || []
|
||||
required_config = settings.join_form_field_required || %{}
|
||||
member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
Enum.map(field_ids, fn id ->
|
||||
type = if id in member_field_names, do: :member_field, else: :custom_field
|
||||
required = Map.get(required_config, id, false)
|
||||
%{id: id, required: required, type: type}
|
||||
end)
|
||||
end
|
||||
|
||||
defp expired?(nil), do: true
|
||||
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Approval domain functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Lists join requests, optionally filtered by status.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
- `:status` - Optional atom to filter by status (default: `:submitted`).
|
||||
Pass `:all` to return requests of all statuses.
|
||||
|
||||
## Returns
|
||||
- `{:ok, list}` - List of JoinRequests
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
|
||||
def list_join_requests(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
status = Keyword.get(opts, :status, :submitted)
|
||||
|
||||
query =
|
||||
if status == :all do
|
||||
JoinRequest
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
else
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(status == ^status))
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
end
|
||||
|
||||
Ash.read(query, actor: actor, domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first.
|
||||
|
||||
Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, list}` - List of JoinRequests (approved/rejected only)
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
|
||||
def list_join_requests_history(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(expr(status in [:approved, :rejected]))
|
||||
|> Ash.Query.sort(updated_at: :desc)
|
||||
|> Ash.Query.load(:reviewed_by_user)
|
||||
|
||||
Ash.read(query, actor: actor, domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of join requests with status `:submitted` (unprocessed).
|
||||
|
||||
Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- Non-negative integer (0 on error or when unauthorized).
|
||||
"""
|
||||
@spec count_submitted_join_requests(keyword()) :: non_neg_integer()
|
||||
def count_submitted_join_requests(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
query = JoinRequest |> Ash.Query.filter(expr(status == :submitted))
|
||||
|
||||
case Ash.count(query, actor: actor, domain: __MODULE__) do
|
||||
{:ok, count} when is_integer(count) and count >= 0 ->
|
||||
count
|
||||
|
||||
{:error, error} ->
|
||||
Logger.debug("count_submitted_join_requests failed: #{inspect(error)}")
|
||||
0
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single JoinRequest by id.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The actor for authorization.
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - The JoinRequest
|
||||
- `{:ok, nil}` - Not found
|
||||
- `{:error, error}` - Authorization or query error
|
||||
"""
|
||||
@spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()}
|
||||
def get_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
Ash.get(JoinRequest, id,
|
||||
actor: actor,
|
||||
load: [:reviewed_by_user],
|
||||
not_found_error?: false,
|
||||
domain: __MODULE__
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Approves a join request and promotes it to a Member.
|
||||
|
||||
Finds the JoinRequest by id, calls the :approve action (which sets status to
|
||||
:approved and records the reviewer), then creates a Member from the typed fields
|
||||
and form_data. Idempotency: if the request is already approved, returns an error.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The reviewer (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, approved_request}` - Approved JoinRequest
|
||||
- `{:error, error}` - Status error, authorization error, or Member creation error
|
||||
"""
|
||||
@spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
|
||||
def approve_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
result =
|
||||
Ash.transact(JoinRequest, fn ->
|
||||
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__),
|
||||
{:ok, approved} <-
|
||||
request
|
||||
|> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__)
|
||||
|> Ash.update(actor: actor, domain: __MODULE__),
|
||||
{:ok, _member} <- promote_to_member(approved, actor) do
|
||||
{:ok, approved}
|
||||
end
|
||||
end)
|
||||
|
||||
# Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()}
|
||||
case result do
|
||||
{:ok, inner} -> inner
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rejects a join request.
|
||||
|
||||
Finds the JoinRequest by id and calls the :reject action (status → :rejected,
|
||||
records reviewer). No Member is created. Returns error if not in :submitted status.
|
||||
|
||||
## Options
|
||||
- `:actor` - Required. The reviewer (normal_user or admin).
|
||||
|
||||
## Returns
|
||||
- `{:ok, rejected_request}` - Rejected JoinRequest
|
||||
- `{:error, error}` - Status error or authorization error
|
||||
"""
|
||||
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
|
||||
def reject_join_request(id, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
|
||||
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do
|
||||
request
|
||||
|> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__)
|
||||
|> Ash.update(actor: actor, domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds Member attrs + custom_field_values from a JoinRequest and creates the Member.
|
||||
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
# Evaluated at compile time so we do not resolve member_fields() on every reduce step.
|
||||
@member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
defp promote_to_member(%JoinRequest{} = request, actor) do
|
||||
{member_attrs, custom_field_values} = build_member_attrs(request)
|
||||
|
||||
attrs =
|
||||
if Enum.empty?(custom_field_values) do
|
||||
member_attrs
|
||||
else
|
||||
Map.put(member_attrs, :custom_field_values, custom_field_values)
|
||||
end
|
||||
|
||||
Ash.create(Mv.Membership.Member, attrs,
|
||||
action: :create_member,
|
||||
actor: actor,
|
||||
domain: __MODULE__
|
||||
)
|
||||
end
|
||||
|
||||
defp build_member_attrs(%JoinRequest{} = request) do
|
||||
# join_date defaults to today so membership fee cycles can be generated.
|
||||
base_attrs = %{
|
||||
email: request.email,
|
||||
first_name: request.first_name,
|
||||
last_name: request.last_name,
|
||||
join_date: Date.utc_today()
|
||||
}
|
||||
|
||||
form_data = request.form_data || %{}
|
||||
|
||||
Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} ->
|
||||
cond do
|
||||
key in @member_field_strings ->
|
||||
atom_key = String.to_existing_atom(key)
|
||||
{Map.put(attrs, atom_key, value), cfvs}
|
||||
|
||||
Regex.match?(@uuid_pattern, key) ->
|
||||
cfv = %{custom_field_id: key, value: to_string(value)}
|
||||
{attrs, [cfv | cfvs]}
|
||||
|
||||
true ->
|
||||
{attrs, cfvs}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,8 +11,17 @@ defmodule Mv.Membership.Setting do
|
|||
- `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`.
|
||||
- `member_field_required` - JSONB map storing which member fields are required in forms
|
||||
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||
- `registration_enabled` - Whether direct registration via /register is allowed (default: true)
|
||||
- `join_form_enabled` - Whether the public /join page is active (default: false)
|
||||
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
|
||||
either a member field name string (e.g. "email") or a custom field UUID. Email is always
|
||||
included and always required; normalization enforces this automatically.
|
||||
- `join_form_field_required` - Map of field ID => required boolean for the join form.
|
||||
Email is always forced to true.
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -42,12 +51,25 @@ defmodule Mv.Membership.Setting do
|
|||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# Update visibility and required for a single member field (e.g. from settings UI)
|
||||
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||
|
||||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
# primary_read_warning?: false — We use a custom read prepare that selects only public
|
||||
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
|
||||
# not load all attributes; we intentionally omit the password for security.
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
primary_read_warning?: false
|
||||
|
||||
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
|
||||
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
alias Ash.Resource.Info, as: ResourceInfo
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
|
|
@ -58,8 +80,27 @@ defmodule Mv.Membership.Setting do
|
|||
description "Global application settings (singleton resource)"
|
||||
end
|
||||
|
||||
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
|
||||
# read only via explicit select when needed; never loaded into default get_settings().
|
||||
@excluded_from_read [:smtp_password, :oidc_client_secret]
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
read :read do
|
||||
primary? true
|
||||
|
||||
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
|
||||
# them via explicit select when needed. Uses all attribute names minus excluded so
|
||||
# the list stays correct when new attributes are added to the resource.
|
||||
prepare fn query, _context ->
|
||||
select_attrs =
|
||||
__MODULE__
|
||||
|> ResourceInfo.attribute_names()
|
||||
|> MapSet.to_list()
|
||||
|> Kernel.--(@excluded_from_read)
|
||||
|
||||
Ash.Query.select(query, select_attrs)
|
||||
end
|
||||
end
|
||||
|
||||
# Internal create action - not exposed via code interface
|
||||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
|
|
@ -68,9 +109,34 @@ defmodule Mv.Membership.Setting do
|
|||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only,
|
||||
:smtp_host,
|
||||
:smtp_port,
|
||||
:smtp_username,
|
||||
:smtp_password,
|
||||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
:join_form_field_required
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
end
|
||||
|
||||
update :update do
|
||||
|
|
@ -80,9 +146,34 @@ defmodule Mv.Membership.Setting do
|
|||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only,
|
||||
:smtp_host,
|
||||
:smtp_port,
|
||||
:smtp_username,
|
||||
:smtp_password,
|
||||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
:join_form_field_required
|
||||
]
|
||||
|
||||
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -101,6 +192,17 @@ defmodule Mv.Membership.Setting do
|
|||
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
||||
end
|
||||
|
||||
update :update_single_member_field do
|
||||
description "Atomically updates visibility and required for a single member field"
|
||||
require_atomic? false
|
||||
|
||||
argument :field, :string, allow_nil?: false
|
||||
argument :show_in_overview, :boolean, allow_nil?: false
|
||||
argument :required, :boolean, allow_nil?: false
|
||||
|
||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
|
|
@ -154,6 +256,71 @@ defmodule Mv.Membership.Setting do
|
|||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate member_field_required map structure and content
|
||||
validate fn changeset, _context ->
|
||||
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
|
||||
|
||||
if required_config && is_map(required_config) do
|
||||
invalid_values =
|
||||
Enum.filter(required_config, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(required_config, 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_required,
|
||||
message: "All values in member_field_required must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_required,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate join_form_field_ids: each entry must be a known member field name
|
||||
# or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
|
||||
# change) runs before validations, so email is already present when this runs.
|
||||
validate fn changeset, _context ->
|
||||
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
|
||||
|
||||
if is_list(field_ids) and field_ids != [] do
|
||||
invalid_ids =
|
||||
Enum.reject(field_ids, fn id ->
|
||||
is_binary(id) and
|
||||
(id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id))
|
||||
end)
|
||||
|
||||
if Enum.empty?(invalid_ids) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
field: :join_form_field_ids,
|
||||
message:
|
||||
"Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate default_membership_fee_type_id exists if set
|
||||
validate fn changeset, context ->
|
||||
fee_type_id =
|
||||
|
|
@ -211,6 +378,12 @@ defmodule Mv.Membership.Setting do
|
|||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
attribute :member_field_required, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
|
||||
|
||||
# Membership fee settings
|
||||
attribute :include_joining_cycle, :boolean do
|
||||
allow_nil? false
|
||||
|
|
@ -225,6 +398,158 @@ defmodule Mv.Membership.Setting do
|
|||
description "Default membership fee type ID for new members"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration (can be overridden by ENV)
|
||||
attribute :vereinfacht_api_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_api_key, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "Vereinfacht API key (Bearer token)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :vereinfacht_club_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht club ID for multi-tenancy"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_app_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||
end
|
||||
|
||||
# OIDC authentication (can be overridden by ENV)
|
||||
attribute :oidc_client_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
|
||||
end
|
||||
|
||||
attribute :oidc_base_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
|
||||
end
|
||||
|
||||
attribute :oidc_redirect_uri, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
|
||||
end
|
||||
|
||||
attribute :oidc_client_secret, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :oidc_admin_group_name, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
|
||||
end
|
||||
|
||||
attribute :oidc_groups_claim, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
|
||||
end
|
||||
|
||||
attribute :oidc_only, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||
end
|
||||
|
||||
# SMTP configuration (can be overridden by ENV)
|
||||
attribute :smtp_host, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP server hostname (e.g. smtp.example.com)"
|
||||
end
|
||||
|
||||
attribute :smtp_port, :integer do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
|
||||
end
|
||||
|
||||
attribute :smtp_username, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP authentication username"
|
||||
end
|
||||
|
||||
attribute :smtp_password, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "SMTP authentication password (sensitive)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :smtp_ssl, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
|
||||
end
|
||||
|
||||
attribute :smtp_from_name, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
|
||||
end
|
||||
|
||||
attribute :smtp_from_email, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
|
||||
end
|
||||
|
||||
# Authentication: direct registration toggle
|
||||
attribute :registration_enabled, :boolean do
|
||||
allow_nil? false
|
||||
default true
|
||||
public? true
|
||||
|
||||
description "When true, users can register via /register; when false, only sign-in and join form remain available."
|
||||
end
|
||||
|
||||
# Join form (Beitrittsformular) settings
|
||||
attribute :join_form_enabled, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true, the public /join page is active and new members can submit a request."
|
||||
end
|
||||
|
||||
attribute :join_form_field_ids, {:array, :string} do
|
||||
allow_nil? true
|
||||
default []
|
||||
public? true
|
||||
|
||||
description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
|
||||
end
|
||||
|
||||
attribute :join_form_field_required, :map do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Map of field ID => required boolean for the join form. Email is always true after normalization."
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do
|
||||
@moduledoc """
|
||||
Ash change that normalizes join form field settings before persist.
|
||||
|
||||
Applied on create and update actions whenever join form attributes are present.
|
||||
|
||||
Rules enforced:
|
||||
- Email is always added to join_form_field_ids if not already present.
|
||||
- Email is always marked as required (true) in join_form_field_required.
|
||||
- Keys in join_form_field_required that are not in join_form_field_ids are dropped.
|
||||
|
||||
Only runs when join_form_field_ids is being changed; if only
|
||||
join_form_field_required changes, normalization still uses the current
|
||||
(possibly changed) field_ids to strip orphaned required flags.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids)
|
||||
changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required)
|
||||
|
||||
if changing_ids? or changing_required? do
|
||||
normalize(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize(changeset) do
|
||||
field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
|
||||
required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required)
|
||||
|
||||
field_ids = normalize_field_ids(field_ids)
|
||||
required_config = normalize_required(field_ids, required_config)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids)
|
||||
|> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config)
|
||||
end
|
||||
|
||||
defp normalize_field_ids(nil), do: ["email"]
|
||||
|
||||
defp normalize_field_ids(ids) when is_list(ids) do
|
||||
if "email" in ids do
|
||||
ids
|
||||
else
|
||||
["email" | ids]
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_field_ids(_), do: ["email"]
|
||||
|
||||
defp normalize_required(field_ids, required_config) do
|
||||
base = if is_map(required_config), do: required_config, else: %{}
|
||||
|
||||
base
|
||||
|> Map.take(field_ids)
|
||||
|> Map.put("email", true)
|
||||
end
|
||||
end
|
||||
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||
@moduledoc """
|
||||
Ash change that atomically updates visibility and required for a single member field.
|
||||
|
||||
Updates both `member_field_visibility` and `member_field_required` JSONB maps
|
||||
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
|
||||
|
||||
## Arguments
|
||||
- `field` - The member field name as a string (e.g., "street", "first_name")
|
||||
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||
- `required` - Boolean value indicating whether the field is required in member forms
|
||||
|
||||
## Example
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{},
|
||||
arguments: %{field: "first_name", show_in_overview: true, required: true}
|
||||
)
|
||||
|> Ash.update(domain: Mv.Membership)
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
|
||||
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
|
||||
add_after_action(changeset, field, show_in_overview, required)
|
||||
else
|
||||
{:error, updated_changeset} -> updated_changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_field(changeset) do
|
||||
case Ash.Changeset.get_argument(changeset, :field) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(changeset,
|
||||
field: :field,
|
||||
message: "field argument is required"
|
||||
)}
|
||||
|
||||
field ->
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field in valid_fields do
|
||||
{:ok, field}
|
||||
else
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :field,
|
||||
message: "Invalid member field: #{field}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
|
||||
do_validate_boolean(changeset, arg_name, :show_in_overview)
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, :required = arg_name) do
|
||||
do_validate_boolean(changeset, arg_name, :member_field_required)
|
||||
end
|
||||
|
||||
defp do_validate_boolean(changeset, arg_name, error_field) do
|
||||
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: error_field,
|
||||
message: "#{arg_name} argument is required"
|
||||
)}
|
||||
|
||||
value when is_boolean(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: error_field,
|
||||
message: "#{arg_name} must be a boolean"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(changeset, opts) do
|
||||
Ash.Changeset.add_error(changeset, opts)
|
||||
end
|
||||
|
||||
defp add_after_action(changeset, field, show_in_overview, required) do
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||
# Update both JSONB columns in one statement
|
||||
sql = """
|
||||
UPDATE settings
|
||||
SET
|
||||
member_field_visibility = jsonb_set(
|
||||
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($2::boolean),
|
||||
true
|
||||
),
|
||||
member_field_required = jsonb_set(
|
||||
COALESCE(member_field_required, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($3::boolean),
|
||||
true
|
||||
),
|
||||
updated_at = (now() AT TIME ZONE 'utc')
|
||||
WHERE id = $4
|
||||
RETURNING member_field_visibility, member_field_required
|
||||
"""
|
||||
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
||||
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
||||
vis = normalize_jsonb_result(updated_visibility)
|
||||
req = normalize_jsonb_result(updated_required)
|
||||
|
||||
updated_settings = %{
|
||||
settings
|
||||
| member_field_visibility: vis,
|
||||
member_field_required: req
|
||||
}
|
||||
|
||||
{:ok, updated_settings}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_required,
|
||||
message: "Settings not found"
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_required,
|
||||
message: "Failed to update member field settings"
|
||||
)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_jsonb_result(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
85
lib/membership/settings_cache.ex
Normal file
85
lib/membership/settings_cache.ex
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Mv.Membership.SettingsCache do
|
||||
@moduledoc """
|
||||
Process-based cache for global settings to avoid repeated DB reads on hot paths
|
||||
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
|
||||
|
||||
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
|
||||
update so that changes take effect quickly. If no settings process exists
|
||||
(e.g. in tests), get/1 falls back to direct read.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@default_ttl_seconds 60
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
|
||||
"""
|
||||
def get do
|
||||
case Process.whereis(__MODULE__) do
|
||||
nil ->
|
||||
# No cache process (e.g. test) – read directly
|
||||
do_fetch()
|
||||
|
||||
_pid ->
|
||||
GenServer.call(__MODULE__, :get, 10_000)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Invalidates the cache so the next get/0 will refetch from the database.
|
||||
Call after update_settings and any other path that mutates settings.
|
||||
"""
|
||||
def invalidate do
|
||||
case Process.whereis(__MODULE__) do
|
||||
nil -> :ok
|
||||
_pid -> GenServer.cast(__MODULE__, :invalidate)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
|
||||
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get, _from, state) do
|
||||
now = System.monotonic_time(:second)
|
||||
expired? = state.expires_at == nil or state.expires_at <= now
|
||||
|
||||
{result, new_state} =
|
||||
if expired? do
|
||||
fetch_and_cache(now, state)
|
||||
else
|
||||
{{:ok, state.cached}, state}
|
||||
end
|
||||
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
defp fetch_and_cache(now, state) do
|
||||
case do_fetch() do
|
||||
{:ok, settings} = ok ->
|
||||
expires = now + state.ttl_seconds
|
||||
{ok, %{state | cached: settings, expires_at: expires}}
|
||||
|
||||
err ->
|
||||
result = if state.cached, do: {:ok, state.cached}, else: err
|
||||
{result, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:invalidate, state) do
|
||||
{:noreply, %{state | cached: nil, expires_at: nil}}
|
||||
end
|
||||
|
||||
defp do_fetch do
|
||||
Mv.Membership.get_settings_uncached()
|
||||
end
|
||||
end
|
||||
75
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal file
75
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||
@moduledoc """
|
||||
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
|
||||
|
||||
Retention: records with `confirmation_token_expires_at` older than now are deleted.
|
||||
Intended for cron or Oban (e.g. every hour). See docs/onboarding-join-concept.md.
|
||||
|
||||
## Usage
|
||||
|
||||
mix join_requests.cleanup_expired
|
||||
|
||||
## Examples
|
||||
|
||||
$ mix join_requests.cleanup_expired
|
||||
Deleted 3 expired join request(s).
|
||||
"""
|
||||
use Mix.Task
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||
|
||||
@impl Mix.Task
|
||||
def run(_args) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
now = DateTime.utc_now()
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(status == :pending_confirmation)
|
||||
|> Ash.Query.filter(confirmation_token_expires_at < ^now)
|
||||
|
||||
# Bypass authorization: cleanup is a system maintenance task (cron/Oban).
|
||||
# Use bulk_destroy so the data layer can delete in one pass when supported.
|
||||
opts = [domain: Mv.Membership, authorize?: false]
|
||||
|
||||
count =
|
||||
case Ash.count(query, opts) do
|
||||
{:ok, n} -> n
|
||||
{:error, _} -> 0
|
||||
end
|
||||
|
||||
do_run(query, opts, count)
|
||||
end
|
||||
|
||||
defp do_run(_query, _opts, 0) do
|
||||
Mix.shell().info("No expired join requests to delete.")
|
||||
0
|
||||
end
|
||||
|
||||
defp do_run(query, opts, count) do
|
||||
case Ash.bulk_destroy(query, :destroy, %{}, opts) do
|
||||
%{status: status, errors: errors} when status in [:success, :partial_success] ->
|
||||
maybe_log_errors(errors)
|
||||
Mix.shell().info("Deleted #{count} expired join request(s).")
|
||||
count
|
||||
|
||||
%{status: :error, errors: errors} ->
|
||||
Mix.raise("Failed to delete expired join requests: #{inspect(errors)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_log_errors(nil), do: :ok
|
||||
defp maybe_log_errors([]), do: :ok
|
||||
|
||||
defp maybe_log_errors(errors) do
|
||||
Logger.warning(
|
||||
"Join requests cleanup: #{length(errors)} error(s) while deleting expired requests: #{inspect(errors)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,12 +1,22 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@moduledoc """
|
||||
Sends an email for a new user to confirm their email address.
|
||||
|
||||
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
|
||||
central mail from config (Mv.Mailer.mail_from/0).
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
|
|
@ -22,25 +32,39 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
`:ok` always. Delivery errors are logged and not re-raised so they do not
|
||||
crash the caller process (AshAuthentication ignores the return value).
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
confirm_url = url(~p"/confirm_new_user/#{token}")
|
||||
subject = gettext("Confirm your email address")
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
assigns = %{
|
||||
confirm_url: confirm_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("user_confirmation.html", assigns)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@moduledoc """
|
||||
Sends a password reset email
|
||||
Sends a password reset email.
|
||||
|
||||
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
|
||||
central mail from config (Mv.Mailer.mail_from/0).
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
|
|
@ -22,25 +32,36 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
`:ok` always. Delivery errors are logged and not re-raised so they do not
|
||||
crash the caller process (AshAuthentication ignores the return value).
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
reset_url = url(~p"/password-reset/#{token}")
|
||||
subject = gettext("Reset your password")
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
assigns = %{
|
||||
reset_url: reset_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("password_reset.html", assigns)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,21 +5,40 @@ defmodule Mv.Application do
|
|||
|
||||
use Application
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.SettingsCache
|
||||
alias Mv.Repo
|
||||
alias Mv.Vereinfacht.SyncFlash
|
||||
alias MvWeb.Endpoint
|
||||
alias MvWeb.JoinRateLimit
|
||||
alias MvWeb.Telemetry
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
Mv.Helpers.SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
MvWeb.Endpoint
|
||||
]
|
||||
SyncFlash.create_table!()
|
||||
|
||||
# SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
|
||||
cache_children =
|
||||
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
|
||||
|
||||
children =
|
||||
[
|
||||
Telemetry,
|
||||
Repo
|
||||
] ++
|
||||
cache_children ++
|
||||
[
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
|
|
|||
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal file
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsNil do
|
||||
@moduledoc """
|
||||
Policy check: true only when the actor is nil (unauthenticated).
|
||||
|
||||
Used for the public join flow so that submit and confirm actions are allowed
|
||||
only when called without an authenticated user (e.g. from the public /join form
|
||||
and confirmation link). See docs/onboarding-join-concept.md.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor is nil (unauthenticated)"
|
||||
|
||||
@impl true
|
||||
def match?(nil, _context, _opts), do: true
|
||||
def match?(_actor, _context, _opts), do: false
|
||||
end
|
||||
17
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
17
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
|
||||
@moduledoc """
|
||||
Policy check: true only when the actor is the system user (e.g. system@mila.local).
|
||||
|
||||
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
|
||||
only code paths using SystemActor can perform them, not regular admins.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor is the system user"
|
||||
|
||||
@impl true
|
||||
def match?(actor, _context, _opts), do: SystemActor.system_user?(actor)
|
||||
end
|
||||
|
|
@ -22,6 +22,7 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
|||
end
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
@impl true
|
||||
|
|
@ -67,5 +68,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
|
||||
defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
|
||||
end
|
||||
|
|
|
|||
32
lib/mv/authorization/checks/has_join_request_access.ex
Normal file
32
lib/mv/authorization/checks/has_join_request_access.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Mv.Authorization.Checks.HasJoinRequestAccess do
|
||||
@moduledoc """
|
||||
Simple policy check: true when the actor's role has JoinRequest read/update permission.
|
||||
|
||||
Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based
|
||||
check) so Ash does NOT call auto_filter, which would silently return an empty list for
|
||||
unauthorized actors instead of Forbidden.
|
||||
|
||||
Returns true for permission sets that grant JoinRequest read :all (normal_user, admin).
|
||||
Returns false for all others (own_data, read_only, nil actor).
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)"
|
||||
|
||||
@impl true
|
||||
def match?(actor, _context, _opts) do
|
||||
with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor),
|
||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||
Enum.any?(permissions.resources, fn p ->
|
||||
p.resource == "JoinRequest" and p.action == :read and p.granted
|
||||
end)
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -81,6 +81,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
use Ash.Policy.Check
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Authorization.PermissionSets
|
||||
require Logger
|
||||
|
||||
|
|
@ -397,6 +398,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
# Fallback: Load role if not loaded (in case on_mount didn't run)
|
||||
# Delegates to centralized Actor helper
|
||||
defp ensure_role_loaded(actor) do
|
||||
Mv.Authorization.Actor.ensure_loaded(actor)
|
||||
Actor.ensure_loaded(actor)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
defmodule Mv.Authorization.Checks.OidcOnlyActive do
|
||||
@moduledoc """
|
||||
Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
|
||||
|
||||
Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "OIDC-only mode is active"
|
||||
|
||||
@impl true
|
||||
def match?(_actor, _context, _opts), do: Config.oidc_only?()
|
||||
end
|
||||
|
|
@ -218,7 +218,11 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
perm("MembershipFeeCycle", :update, :all),
|
||||
perm("MembershipFeeCycle", :destroy, :all)
|
||||
] ++
|
||||
role_read_all(),
|
||||
role_read_all() ++
|
||||
[
|
||||
perm("JoinRequest", :read, :all),
|
||||
perm("JoinRequest", :update, :all)
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||
|
|
@ -247,7 +251,10 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
# Edit group
|
||||
"/groups/:slug/edit",
|
||||
# Statistics
|
||||
"/statistics"
|
||||
"/statistics",
|
||||
# Approval UI (Step 2)
|
||||
"/join_requests",
|
||||
"/join_requests/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
|
@ -270,7 +277,8 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
perm_all("Group") ++
|
||||
member_group_perms ++
|
||||
perm_all("MembershipFeeType") ++
|
||||
perm_all("MembershipFeeCycle"),
|
||||
perm_all("MembershipFeeCycle") ++
|
||||
perm_all("JoinRequest"),
|
||||
pages: [
|
||||
# Explicit admin-only pages (for clarity and future restrictions)
|
||||
"/settings",
|
||||
|
|
|
|||
|
|
@ -94,14 +94,16 @@ defmodule Mv.Authorization.Role do
|
|||
end
|
||||
end
|
||||
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
validations do
|
||||
validate one_of(
|
||||
:permission_set_name,
|
||||
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||
PermissionSets.all_permission_sets()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
),
|
||||
message:
|
||||
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||
"must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if changeset.data.is_system_role do
|
||||
|
|
|
|||
524
lib/mv/config.ex
524
lib/mv/config.ex
|
|
@ -142,4 +142,528 @@ defmodule Mv.Config do
|
|||
|> Keyword.get(key, default)
|
||||
|> parse_and_validate_integer(default)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vereinfacht accounting software integration
|
||||
# ENV variables take priority; fallback to Settings from database.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht API base URL.
|
||||
|
||||
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_api_url() :: String.t() | nil
|
||||
def vereinfacht_api_url do
|
||||
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht API key (Bearer token).
|
||||
|
||||
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_api_key() :: String.t() | nil
|
||||
def vereinfacht_api_key do
|
||||
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht club ID for multi-tenancy.
|
||||
|
||||
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_club_id() :: String.t() | nil
|
||||
def vereinfacht_club_id do
|
||||
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
|
||||
|
||||
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
|
||||
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
|
||||
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
|
||||
"""
|
||||
@spec vereinfacht_app_url() :: String.t() | nil
|
||||
def vereinfacht_app_url do
|
||||
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
|
||||
derive_app_url_from_api_url(vereinfacht_api_url())
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(nil), do: nil
|
||||
|
||||
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
|
||||
api_url = String.trim(api_url)
|
||||
uri = URI.parse(api_url)
|
||||
host = uri.host || ""
|
||||
|
||||
if String.starts_with?(host, "api.") do
|
||||
app_host = "app." <> String.slice(host, 4..-1//1)
|
||||
scheme = uri.scheme || "https"
|
||||
"#{scheme}://#{app_host}"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||
"""
|
||||
@spec vereinfacht_configured?() :: boolean()
|
||||
def vereinfacht_configured? do
|
||||
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
|
||||
present?(vereinfacht_club_id())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
|
||||
"""
|
||||
@spec vereinfacht_env_configured?() :: boolean()
|
||||
def vereinfacht_env_configured? do
|
||||
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
|
||||
vereinfacht_club_id_env_set?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
|
||||
|
||||
defp env_set?(key) do
|
||||
case System.get_env(key) do
|
||||
nil -> false
|
||||
v when is_binary(v) -> String.trim(v) != ""
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp env_or_setting(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil -> get_vereinfacht_from_settings(setting_key)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp env_or_setting_bool(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil ->
|
||||
get_from_settings_bool(setting_key)
|
||||
|
||||
value when is_binary(value) ->
|
||||
v = String.trim(value) |> String.downcase()
|
||||
v in ["true", "1", "yes"]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp get_vereinfacht_from_settings(key) do
|
||||
get_from_settings(key)
|
||||
end
|
||||
|
||||
defp get_from_settings(key) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_from_settings_bool(key) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
case Map.get(settings, key) do
|
||||
true -> true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp trim_nil(nil), do: nil
|
||||
|
||||
defp trim_nil(s) when is_binary(s) do
|
||||
t = String.trim(s)
|
||||
if t == "", do: nil, else: t
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
|
||||
|
||||
Uses the configured app base URL (or derived from API URL) and appends
|
||||
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
|
||||
"""
|
||||
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
|
||||
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
|
||||
base = vereinfacht_app_url()
|
||||
|
||||
if present?(base) do
|
||||
base
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp present?(nil), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC authentication
|
||||
# ENV variables take priority; fallback to Settings from database.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the OIDC client ID. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_client_id() :: String.t() | nil
|
||||
def oidc_client_id do
|
||||
env_or_setting("OIDC_CLIENT_ID", :oidc_client_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC provider base URL. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_base_url() :: String.t() | nil
|
||||
def oidc_base_url do
|
||||
env_or_setting("OIDC_BASE_URL", :oidc_base_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC redirect URI. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_redirect_uri() :: String.t() | nil
|
||||
def oidc_redirect_uri do
|
||||
env_or_setting("OIDC_REDIRECT_URI", :oidc_redirect_uri)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC client secret.
|
||||
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings).
|
||||
"""
|
||||
@spec oidc_client_secret() :: String.t() | nil
|
||||
def oidc_client_secret do
|
||||
case Application.get_env(:mv, :oidc) do
|
||||
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
||||
_ -> oidc_client_secret_from_env_or_settings()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value.
|
||||
"""
|
||||
@spec oidc_client_secret_set?() :: boolean()
|
||||
def oidc_client_secret_set? do
|
||||
present?(get_oidc_client_secret_from_settings())
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(nil),
|
||||
do: oidc_client_secret_from_env_or_settings()
|
||||
|
||||
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
||||
s = String.trim(secret)
|
||||
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(_),
|
||||
do: oidc_client_secret_from_env_or_settings()
|
||||
|
||||
defp oidc_client_secret_from_env_or_settings do
|
||||
case System.get_env("OIDC_CLIENT_SECRET") do
|
||||
nil -> get_oidc_client_secret_from_settings()
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_admin_group_name() :: String.t() | nil
|
||||
def oidc_admin_group_name do
|
||||
env_or_setting("OIDC_ADMIN_GROUP_NAME", :oidc_admin_group_name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_groups_claim() :: String.t() | nil
|
||||
def oidc_groups_claim do
|
||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||
nil -> "groups"
|
||||
v -> v
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if any OIDC ENV variable is set (used to show hint in Settings UI).
|
||||
"""
|
||||
@spec oidc_env_configured?() :: boolean()
|
||||
def oidc_env_configured? do
|
||||
oidc_client_id_env_set?() or oidc_base_url_env_set?() or
|
||||
oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or
|
||||
oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() or
|
||||
oidc_only_env_set?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when OIDC is configured and can be used for sign-in (client ID, base URL,
|
||||
redirect URI, and client secret must be set). Used to show or hide the Single Sign-On button on the
|
||||
sign-in page. Without client secret, the OIDC flow fails with MissingSecret; without redirect_uri,
|
||||
the OIDC Plug crashes with URI.new(nil).
|
||||
"""
|
||||
@spec oidc_configured?() :: boolean()
|
||||
def oidc_configured? do
|
||||
id = oidc_client_id()
|
||||
base = oidc_base_url()
|
||||
secret = oidc_client_secret()
|
||||
redirect = oidc_redirect_uri()
|
||||
present = &(is_binary(&1) and String.trim(&1) != "")
|
||||
present.(id) and present.(base) and present.(secret) and present.(redirect)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when only OIDC sign-in should be shown (password login hidden).
|
||||
ENV OIDC_ONLY first (true/1/yes vs false/0/no), then Settings.oidc_only.
|
||||
Only has effect when OIDC is configured; when false or OIDC not configured, both password and OIDC are shown as usual.
|
||||
"""
|
||||
@spec oidc_only?() :: boolean()
|
||||
def oidc_only? do
|
||||
env_or_setting_bool("OIDC_ONLY", :oidc_only)
|
||||
end
|
||||
|
||||
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
|
||||
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
|
||||
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
|
||||
|
||||
def oidc_client_secret_env_set?,
|
||||
do: env_set?("OIDC_CLIENT_SECRET") or env_set?("OIDC_CLIENT_SECRET_FILE")
|
||||
|
||||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
|
||||
"""
|
||||
@spec smtp_host() :: String.t() | nil
|
||||
def smtp_host do
|
||||
smtp_env_or_setting("SMTP_HOST", :smtp_host)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings.
|
||||
Returns nil when neither ENV nor Settings provide a valid port.
|
||||
"""
|
||||
@spec smtp_port() :: non_neg_integer() | nil
|
||||
def smtp_port do
|
||||
case System.get_env("SMTP_PORT") do
|
||||
nil ->
|
||||
get_from_settings_integer(:smtp_port)
|
||||
|
||||
value when is_binary(value) ->
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{port, _} when port > 0 -> port
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings.
|
||||
"""
|
||||
@spec smtp_username() :: String.t() | nil
|
||||
def smtp_username do
|
||||
smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns SMTP password.
|
||||
|
||||
Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings.
|
||||
Strips trailing whitespace/newlines from file contents.
|
||||
"""
|
||||
@spec smtp_password() :: String.t() | nil
|
||||
def smtp_password do
|
||||
case System.get_env("SMTP_PASSWORD") do
|
||||
nil -> smtp_password_from_file_or_settings()
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp smtp_password_from_file_or_settings do
|
||||
case System.get_env("SMTP_PASSWORD_FILE") do
|
||||
nil -> get_smtp_password_from_settings()
|
||||
path -> read_smtp_password_file(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp read_smtp_password_file(path) do
|
||||
case File.read(String.trim(path)) do
|
||||
{:ok, content} -> trim_nil(content)
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none').
|
||||
ENV `SMTP_SSL` overrides Settings.
|
||||
"""
|
||||
@spec smtp_ssl() :: String.t() | nil
|
||||
def smtp_ssl do
|
||||
smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when SMTP is configured (host present from ENV or Settings).
|
||||
"""
|
||||
@spec smtp_configured?() :: boolean()
|
||||
def smtp_configured? do
|
||||
present?(smtp_host())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when any SMTP ENV variable is set (used in Settings UI for hints).
|
||||
"""
|
||||
@spec smtp_env_configured?() :: boolean()
|
||||
def smtp_env_configured? do
|
||||
smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or
|
||||
smtp_password_env_set?() or smtp_ssl_env_set?()
|
||||
end
|
||||
|
||||
@doc "Returns true if SMTP_HOST ENV is set."
|
||||
@spec smtp_host_env_set?() :: boolean()
|
||||
def smtp_host_env_set?, do: env_set?("SMTP_HOST")
|
||||
|
||||
@doc "Returns true if SMTP_PORT ENV is set."
|
||||
@spec smtp_port_env_set?() :: boolean()
|
||||
def smtp_port_env_set?, do: env_set?("SMTP_PORT")
|
||||
|
||||
@doc "Returns true if SMTP_USERNAME ENV is set."
|
||||
@spec smtp_username_env_set?() :: boolean()
|
||||
def smtp_username_env_set?, do: env_set?("SMTP_USERNAME")
|
||||
|
||||
@doc "Returns true if SMTP_PASSWORD or SMTP_PASSWORD_FILE ENV is set."
|
||||
@spec smtp_password_env_set?() :: boolean()
|
||||
def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE")
|
||||
|
||||
@doc "Returns true if SMTP_SSL ENV is set."
|
||||
@spec smtp_ssl_env_set?() :: boolean()
|
||||
def smtp_ssl_env_set?, do: env_set?("SMTP_SSL")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transactional email sender identity (mail_from)
|
||||
# ENV variables MAIL_FROM_NAME / MAIL_FROM_EMAIL take priority; fallback to
|
||||
# Settings smtp_from_name / smtp_from_email; final fallback: hardcoded defaults.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the display name for the transactional email sender.
|
||||
|
||||
Priority: `MAIL_FROM_NAME` ENV > Settings `smtp_from_name` > `"Mila"`.
|
||||
"""
|
||||
@spec mail_from_name() :: String.t()
|
||||
def mail_from_name do
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||
value -> trim_nil(value) || "Mila"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the email address for the transactional email sender.
|
||||
|
||||
Priority: `MAIL_FROM_EMAIL` ENV > Settings `smtp_from_email` > `nil`.
|
||||
Returns `nil` when not configured (caller should fall back to a safe default).
|
||||
"""
|
||||
@spec mail_from_email() :: String.t() | nil
|
||||
def mail_from_email do
|
||||
case System.get_env("MAIL_FROM_EMAIL") do
|
||||
nil -> get_from_settings(:smtp_from_email)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Returns true if MAIL_FROM_NAME ENV is set."
|
||||
@spec mail_from_name_env_set?() :: boolean()
|
||||
def mail_from_name_env_set?, do: env_set?("MAIL_FROM_NAME")
|
||||
|
||||
@doc "Returns true if MAIL_FROM_EMAIL ENV is set."
|
||||
@spec mail_from_email_env_set?() :: boolean()
|
||||
def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL")
|
||||
|
||||
# Reads a plain string SMTP setting: ENV first, then Settings.
|
||||
defp smtp_env_or_setting(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil -> get_from_settings(setting_key)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
# Reads an integer setting attribute from Settings.
|
||||
defp get_from_settings_integer(key) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
case Map.get(settings, key) do
|
||||
v when is_integer(v) and v > 0 -> v
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Reads the SMTP password directly from the DB via an explicit select,
|
||||
# bypassing the standard read action which excludes smtp_password for security.
|
||||
defp get_smtp_password_from_settings do
|
||||
query = Ash.Query.select(Mv.Membership.Setting, [:id, :smtp_password])
|
||||
|
||||
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||
{:ok, settings} when not is_nil(settings) ->
|
||||
settings |> Map.get(:smtp_password) |> trim_nil()
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password).
|
||||
defp get_oidc_client_secret_from_settings do
|
||||
query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret])
|
||||
|
||||
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||
{:ok, settings} when not is_nil(settings) ->
|
||||
settings |> Map.get(:oidc_client_secret) |> trim_nil()
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Mv.Constants do
|
|||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:country,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
|
|
@ -21,14 +22,38 @@ defmodule Mv.Constants do
|
|||
|
||||
@boolean_filter_prefix "bf_"
|
||||
|
||||
@group_filter_prefix "group_"
|
||||
|
||||
@fee_type_filter_prefix "fee_type_"
|
||||
|
||||
@max_boolean_filters 50
|
||||
|
||||
@max_uuid_length 36
|
||||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
||||
# No member fields are required solely for Vereinfacht; API accepts minimal payload
|
||||
# (contactType + isExternal) when creating external contacts and supports filter by email for lookup.
|
||||
@vereinfacht_required_member_fields []
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
||||
@doc """
|
||||
Returns member fields that are always required when Vereinfacht integration is configured.
|
||||
|
||||
Currently empty: the Vereinfacht API only requires contactType (e.g. "person") when creating
|
||||
external contacts; lookup uses filter[email] so no extra required fields in the app.
|
||||
"""
|
||||
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
|
||||
|
||||
@doc """
|
||||
Returns whether the given member field is required by Vereinfacht when integration is active.
|
||||
"""
|
||||
def vereinfacht_required_field?(field) when is_atom(field),
|
||||
do: field in @vereinfacht_required_member_fields
|
||||
|
||||
def vereinfacht_required_field?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for custom field keys in field visibility maps.
|
||||
|
||||
|
|
@ -49,6 +74,16 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
def boolean_filter_prefix, do: @boolean_filter_prefix
|
||||
|
||||
@doc """
|
||||
Returns the prefix for group filter URL parameters (e.g. group_<uuid>=in|not_in).
|
||||
"""
|
||||
def group_filter_prefix, do: @group_filter_prefix
|
||||
|
||||
@doc """
|
||||
Returns the prefix for fee type filter URL parameters (e.g. fee_type_<uuid>=in|not_in).
|
||||
"""
|
||||
def fee_type_filter_prefix, do: @fee_type_filter_prefix
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of boolean custom field filters allowed per request.
|
||||
|
||||
|
|
|
|||
188
lib/mv/mailer.ex
188
lib/mv/mailer.ex
|
|
@ -1,3 +1,191 @@
|
|||
defmodule Mv.Mailer do
|
||||
@moduledoc """
|
||||
Swoosh mailer for transactional emails.
|
||||
|
||||
Use `mail_from/0` for the configured sender address (join confirmation,
|
||||
user confirmation, password reset).
|
||||
|
||||
## Sender identity
|
||||
|
||||
The "from" address is determined by priority:
|
||||
1. `MAIL_FROM_EMAIL` / `MAIL_FROM_NAME` environment variables
|
||||
2. Settings database (`smtp_from_email`, `smtp_from_name`)
|
||||
3. Hardcoded default (`"Mila"`, `"noreply@example.com"`)
|
||||
|
||||
**Important:** On most SMTP servers the sender email must be owned by the
|
||||
authenticated SMTP user. Set `smtp_from_email` to the same address as
|
||||
`smtp_username` (or an alias allowed by the server).
|
||||
|
||||
## SMTP adapter configuration
|
||||
|
||||
The SMTP adapter can be configured via:
|
||||
- **Environment variables** at boot (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
|
||||
`SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) — configured in `runtime.exs`.
|
||||
- **Admin Settings** (database) — read at send time via `Mv.Config.smtp_*()` helpers.
|
||||
Settings-based config is passed per-send via `smtp_config/0`.
|
||||
|
||||
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
|
||||
"""
|
||||
use Swoosh.Mailer, otp_app: :mv
|
||||
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Smtp.ConfigBuilder
|
||||
require Logger
|
||||
|
||||
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||
@email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
@doc """
|
||||
Returns the configured "from" address for transactional emails as `{name, email}`.
|
||||
|
||||
Priority: ENV `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` > Settings `smtp_from_name`/`smtp_from_email` > defaults.
|
||||
"""
|
||||
@spec mail_from() :: {String.t(), String.t()}
|
||||
def mail_from do
|
||||
{Mv.Config.mail_from_name(), Mv.Config.mail_from_email() || "noreply@example.com"}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a test email to the given address. Used from Global Settings SMTP section.
|
||||
|
||||
Returns `{:ok, email}` on success, `{:error, reason}` on failure.
|
||||
The `reason` is a classified atom for known error categories, or `{:smtp_error, message}`
|
||||
for SMTP-level errors with a human-readable message, or the raw term for unknown errors.
|
||||
"""
|
||||
@spec send_test_email(String.t()) ::
|
||||
{:ok, Swoosh.Email.t()} | {:error, atom() | {:smtp_error, String.t()} | term()}
|
||||
def send_test_email(to_email) when is_binary(to_email) do
|
||||
if valid_email?(to_email) do
|
||||
subject = gettext("Mila – Test email")
|
||||
|
||||
body =
|
||||
gettext(
|
||||
"This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||
)
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(mail_from())
|
||||
|> to(to_email)
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|> html_body("<p>#{body}</p>")
|
||||
|
||||
case deliver(email, smtp_config()) do
|
||||
{:ok, _} = ok ->
|
||||
ok
|
||||
|
||||
{:error, reason} ->
|
||||
classified = classify_smtp_error(reason)
|
||||
Logger.warning("SMTP test email failed: #{inspect(reason)}")
|
||||
{:error, classified}
|
||||
end
|
||||
else
|
||||
{:error, :invalid_email_address}
|
||||
end
|
||||
end
|
||||
|
||||
def send_test_email(_), do: {:error, :invalid_email_address}
|
||||
|
||||
@doc """
|
||||
Builds the per-send SMTP config from `Mv.Config` when SMTP is configured via
|
||||
Settings only (not boot-time ENV). Returns an empty list when the mailer is
|
||||
already configured at boot (ENV-based), so Swoosh uses the Application config.
|
||||
|
||||
The return value must be a flat keyword list (adapter, relay, port, ...).
|
||||
Swoosh merges it with Application config; top-level keys override the mailer's
|
||||
default adapter (e.g. Local in dev), so this delivery uses SMTP.
|
||||
"""
|
||||
@spec smtp_config() :: keyword()
|
||||
def smtp_config do
|
||||
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
|
||||
verify_mode =
|
||||
if Application.get_env(:mv, :smtp_verify_peer, false),
|
||||
do: :verify_peer,
|
||||
else: :verify_none
|
||||
|
||||
ConfigBuilder.build_opts(
|
||||
host: Mv.Config.smtp_host(),
|
||||
port: Mv.Config.smtp_port() || 587,
|
||||
username: Mv.Config.smtp_username(),
|
||||
password: Mv.Config.smtp_password(),
|
||||
ssl_mode: Mv.Config.smtp_ssl() || "tls",
|
||||
verify_mode: verify_mode
|
||||
)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP error classification
|
||||
# Maps raw gen_smtp error terms to human-readable atoms / structs.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc false
|
||||
@spec classify_smtp_error(term()) ::
|
||||
:sender_rejected
|
||||
| :auth_failed
|
||||
| :recipient_rejected
|
||||
| :tls_failed
|
||||
| :connection_failed
|
||||
| {:smtp_error, String.t()}
|
||||
| term()
|
||||
def classify_smtp_error({:retries_exceeded, {:temporary_failure, _host, :tls_failed}}),
|
||||
do: :tls_failed
|
||||
|
||||
def classify_smtp_error({:retries_exceeded, {:network_failure, _host, _}}),
|
||||
do: :connection_failed
|
||||
|
||||
def classify_smtp_error({:send, {:permanent_failure, _host, msg}}) do
|
||||
str = if is_list(msg), do: List.to_string(msg), else: to_string(msg)
|
||||
classify_permanent_failure_message(str)
|
||||
end
|
||||
|
||||
def classify_smtp_error(reason), do: reason
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp classify_permanent_failure_message(str) do
|
||||
cond do
|
||||
smtp_auth_failure?(str) -> :auth_failed
|
||||
smtp_sender_rejected?(str) -> :sender_rejected
|
||||
smtp_recipient_rejected?(str) -> :recipient_rejected
|
||||
true -> {:smtp_error, String.trim(str)}
|
||||
end
|
||||
end
|
||||
|
||||
defp smtp_auth_failure?(str),
|
||||
do:
|
||||
String.contains?(str, "535") or String.contains?(str, "authentication") or
|
||||
String.contains?(str, "Authentication")
|
||||
|
||||
defp smtp_sender_rejected?(str),
|
||||
do:
|
||||
String.contains?(str, "553") or String.contains?(str, "Sender address rejected") or
|
||||
String.contains?(str, "not owned")
|
||||
|
||||
defp smtp_recipient_rejected?(str),
|
||||
do:
|
||||
String.contains?(str, "550") or String.contains?(str, "No such user") or
|
||||
String.contains?(str, "no such user") or String.contains?(str, "User unknown")
|
||||
|
||||
# Returns true when the SMTP adapter has been configured at boot time via ENV
|
||||
# (i.e. the Application config is already set to the SMTP adapter).
|
||||
defp boot_smtp_configured? do
|
||||
case Application.get_env(:mv, __MODULE__) do
|
||||
config when is_list(config) -> Keyword.get(config, :adapter) == Swoosh.Adapters.SMTP
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_email?(email) when is_binary(email) do
|
||||
Regex.match?(@email_regex, String.trim(email))
|
||||
end
|
||||
|
||||
defp valid_email?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,15 +17,24 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
## Member Field Mapping
|
||||
|
||||
Maps CSV headers to canonical member fields:
|
||||
- `email` (required)
|
||||
- `first_name` (optional)
|
||||
- `last_name` (optional)
|
||||
- `street` (optional)
|
||||
- `postal_code` (optional)
|
||||
- `city` (optional)
|
||||
Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for
|
||||
importable attributes). All DB-backed member attributes can be imported.
|
||||
|
||||
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
|
||||
- `email` (required)
|
||||
- `first_name`, `last_name` (optional)
|
||||
- `join_date`, `exit_date` (optional, ISO-8601 date)
|
||||
- `notes` (optional)
|
||||
- `country`, `city`, `street`, `house_number`, `postal_code` (optional)
|
||||
- `membership_fee_start_date` (optional, ISO-8601 date)
|
||||
|
||||
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
|
||||
|
||||
## Fields not supported for import
|
||||
|
||||
- **membership_fee_status** – Computed (calculation from membership fee cycles). Not stored;
|
||||
cannot be set via CSV. Export can include it.
|
||||
- **groups** – Many-to-many relationship (through member_groups). Import would require
|
||||
resolving group names/slugs to IDs and creating associations; not in current import scope.
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
|
|
@ -75,11 +84,37 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"nachname",
|
||||
"familienname"
|
||||
],
|
||||
join_date: [
|
||||
"join date",
|
||||
"join_date",
|
||||
"beitrittsdatum",
|
||||
"beitritts-datum"
|
||||
],
|
||||
exit_date: [
|
||||
"exit date",
|
||||
"exit_date",
|
||||
"austrittsdatum",
|
||||
"austritts-datum"
|
||||
],
|
||||
notes: [
|
||||
"notes",
|
||||
"notizen",
|
||||
"bemerkungen"
|
||||
],
|
||||
street: [
|
||||
"street",
|
||||
"address",
|
||||
"strasse"
|
||||
],
|
||||
house_number: [
|
||||
"house number",
|
||||
"house_number",
|
||||
"house no",
|
||||
"hausnummer",
|
||||
"nr",
|
||||
"nr.",
|
||||
"nummer"
|
||||
],
|
||||
postal_code: [
|
||||
"postal code",
|
||||
"postal_code",
|
||||
|
|
@ -93,6 +128,18 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"town",
|
||||
"stadt",
|
||||
"ort"
|
||||
],
|
||||
country: [
|
||||
"country",
|
||||
"land",
|
||||
"staat"
|
||||
],
|
||||
membership_fee_start_date: [
|
||||
"membership fee start date",
|
||||
"membership_fee_start_date",
|
||||
"fee start",
|
||||
"beitragsbeginn",
|
||||
"beitrags-beginn"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -549,9 +549,12 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
line_number,
|
||||
actor
|
||||
) do
|
||||
# Convert empty strings to nil for date fields so Ash accepts them
|
||||
member_attrs = sanitize_date_fields(trimmed_member_attrs)
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
trimmed_member_attrs
|
||||
member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
|
|
@ -793,6 +796,23 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end)
|
||||
end
|
||||
|
||||
# Converts empty strings to nil for date fields so Ash can accept them
|
||||
@date_fields [:join_date, :exit_date, :membership_fee_start_date]
|
||||
|
||||
defp sanitize_date_fields(attrs) when is_map(attrs) do
|
||||
Enum.reduce(@date_fields, attrs, fn field, acc ->
|
||||
put_date_field(acc, field, Map.get(acc, field))
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_date_field(acc, field, ""), do: Map.put(acc, field, nil)
|
||||
|
||||
defp put_date_field(acc, field, val) when is_binary(val) do
|
||||
if String.trim(val) == "", do: Map.put(acc, field, nil), else: acc
|
||||
end
|
||||
|
||||
defp put_date_field(acc, _field, _), do: acc
|
||||
|
||||
# Formats Ash errors into MemberCSV.Error structs
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do
|
||||
# Try to find email-related errors first (for better error messages)
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ defmodule Mv.Membership.MemberExport do
|
|||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MemberExportSort
|
||||
alias MvWeb.MemberLive.Index
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_status"]
|
||||
["membership_fee_type", "membership_fee_status", "groups"]
|
||||
@computed_export_fields ["membership_fee_status"]
|
||||
@computed_insert_after "membership_fee_start_date"
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
|
@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport do
|
|||
if parsed.selected_ids == [] do
|
||||
members
|
||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|> Index.apply_boolean_custom_field_filters(
|
||||
parsed.boolean_filters || %{},
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
|
|
@ -323,10 +324,14 @@ defmodule Mv.Membership.MemberExport do
|
|||
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
||||
|> order_member_fields_like_table()
|
||||
|
||||
# final member_fields list (used for column specs order): table order + computed inserted
|
||||
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
||||
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
||||
|
||||
# final member_fields list (used for column specs order): table order + fee type + computed + groups
|
||||
ordered_member_fields =
|
||||
selectable_member_fields
|
||||
|> insert_computed_fields_like_table(computed_fields)
|
||||
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|
||||
|> then(fn fields -> fields ++ groups_field end)
|
||||
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
|
|
@ -373,6 +378,38 @@ defmodule Mv.Membership.MemberExport do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies export filters (cycle status and boolean custom field filters) when exporting "all" (no selected_ids).
|
||||
|
||||
Used by the CSV export controller so that "Export (all)" with active filters exports only the filtered members,
|
||||
matching PDF export behavior.
|
||||
|
||||
- `members` - Loaded members (must have cycle data loaded when cycle_status_filter is used).
|
||||
- `opts` - Map with `:selected_ids`, `:cycle_status_filter`, `:show_current_cycle`, `:boolean_filters`.
|
||||
- `custom_fields_by_id` - Map of custom field id => custom field struct (for boolean filter resolution).
|
||||
|
||||
When `opts.selected_ids` is not empty, returns `members` unchanged (selected_ids
|
||||
override filters). Otherwise applies cycle status filter and boolean custom field filters.
|
||||
|
||||
Uses `Map.get(opts, :selected_ids, [])` so that `nil` or a missing key is treated as
|
||||
"export all" and filters are applied.
|
||||
"""
|
||||
@spec apply_export_filters([struct()], map(), map()) :: [struct()]
|
||||
def apply_export_filters(members, opts, custom_fields_by_id) do
|
||||
selected_ids = Map.get(opts, :selected_ids, [])
|
||||
|
||||
if Enum.empty?(selected_ids) do
|
||||
members
|
||||
|> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle])
|
||||
|> Index.apply_boolean_custom_field_filters(
|
||||
Map.get(opts, :boolean_filters, %{}),
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_list(params, key) do
|
||||
case Map.get(params, key) do
|
||||
list when is_list(list) -> list
|
||||
|
|
@ -416,27 +453,52 @@ defmodule Mv.Membership.MemberExport do
|
|||
table_order |> Enum.filter(&(&1 in fields))
|
||||
end
|
||||
|
||||
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
|
||||
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
|
||||
# otherwise append at the end of DB fields.
|
||||
defp insert_fee_type_and_computed_fields_like_table(
|
||||
db_fields_ordered,
|
||||
computed_fields,
|
||||
member_fields
|
||||
) do
|
||||
computed_fields = computed_fields || []
|
||||
member_fields = member_fields || []
|
||||
|
||||
db_with_insert =
|
||||
Enum.flat_map(db_fields_ordered, fn f ->
|
||||
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
|
||||
[f, "membership_fee_status"]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
expand_field_with_computed(f, member_fields, computed_fields)
|
||||
end)
|
||||
|
||||
remaining =
|
||||
computed_fields
|
||||
|> Enum.reject(&(&1 in db_with_insert))
|
||||
# If fee type is visible but start_date was not in the list, it won't be in db_with_insert
|
||||
db_with_insert =
|
||||
if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
|
||||
db_with_insert ++ ["membership_fee_type"]
|
||||
else
|
||||
db_with_insert
|
||||
end
|
||||
|
||||
remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
|
||||
db_with_insert ++ remaining
|
||||
end
|
||||
|
||||
# Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
|
||||
defp expand_field_with_computed(f, member_fields, computed_fields) do
|
||||
if f == @computed_insert_after do
|
||||
extra = []
|
||||
|
||||
extra =
|
||||
if "membership_fee_type" in member_fields,
|
||||
do: extra ++ ["membership_fee_type"],
|
||||
else: extra
|
||||
|
||||
extra =
|
||||
if "membership_fee_status" in computed_fields,
|
||||
do: extra ++ ["membership_fee_status"],
|
||||
else: extra
|
||||
|
||||
[f] ++ extra
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(fields) when is_list(fields) do
|
||||
fields
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
|
||||
alias MvWeb.MemberLive.Index
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
|
@ -132,12 +133,20 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
parsed.computed_fields != [] or
|
||||
"membership_fee_status" in parsed.member_fields
|
||||
|
||||
need_groups = "groups" in parsed.member_fields
|
||||
|
||||
need_membership_fee_type =
|
||||
"membership_fee_type" in parsed.member_fields or
|
||||
parsed.sort_field == "membership_fee_type"
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(custom_field_ids_union)
|
||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||
|> maybe_load_groups(need_groups)
|
||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
|
|
@ -161,7 +170,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
if parsed.selected_ids == [] do
|
||||
members
|
||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|> Index.apply_boolean_custom_field_filters(
|
||||
parsed.boolean_filters || %{},
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
|
|
@ -193,8 +202,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
defp sort_members_in_memory(members, field, order) when is_binary(field) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in Mv.Constants.member_fields() do
|
||||
sort_by_field(members, field_atom, order)
|
||||
if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
|
||||
key_fn = sort_key_fn_for_field(field_atom)
|
||||
compare_fn = build_compare_fn(order)
|
||||
Enum.sort_by(members, key_fn, compare_fn)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
|
@ -204,13 +215,17 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
|
||||
defp sort_members_in_memory(members, _field, _order), do: members
|
||||
|
||||
defp sort_by_field(members, field_atom, order) do
|
||||
key_fn = fn member -> Map.get(member, field_atom) end
|
||||
compare_fn = build_compare_fn(order)
|
||||
|
||||
Enum.sort_by(members, key_fn, compare_fn)
|
||||
defp sort_key_fn_for_field(:membership_fee_type) do
|
||||
fn member ->
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
nil -> nil
|
||||
rel -> Map.get(rel, :name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end
|
||||
|
||||
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
|
||||
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
|
||||
defp build_compare_fn(_), do: fn _a, _b -> true end
|
||||
|
|
@ -241,30 +256,65 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
defp maybe_sort(query, _field, nil), do: {query, false}
|
||||
|
||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
||||
if custom_field_sort?(field) do
|
||||
{query, true}
|
||||
else
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
cond do
|
||||
field == "groups" -> {query, true}
|
||||
field == "membership_fee_type" -> apply_fee_type_sort(query, order)
|
||||
custom_field_sort?(field) -> {query, true}
|
||||
true -> apply_standard_member_sort(query, field, order)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp apply_fee_type_sort(query, order) do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||
end
|
||||
|
||||
defp apply_standard_member_sort(query, field, order) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
sortable =
|
||||
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||
field_atom == :membership_fee_type
|
||||
|
||||
if sortable do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
|
||||
sort_field =
|
||||
if field_atom == :membership_fee_type,
|
||||
do: {"membership_fee_type.name", order_atom},
|
||||
else: {field_atom, order_atom}
|
||||
|
||||
{Ash.Query.sort(query, [sort_field]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
||||
do: []
|
||||
|
||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
||||
if field == "groups" do
|
||||
sort_members_by_groups_export(members, order)
|
||||
else
|
||||
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_by_custom_field_value(members, field, order, custom_fields) do
|
||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||||
|
||||
if is_nil(custom_field), do: members
|
||||
if is_nil(custom_field) do
|
||||
members
|
||||
else
|
||||
sort_members_with_custom_field(members, custom_field, order)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_members_with_custom_field(members, custom_field, order) do
|
||||
key_fn = fn member ->
|
||||
cfv = find_cfv(member, custom_field)
|
||||
raw = if cfv, do: cfv.value, else: nil
|
||||
|
|
@ -277,6 +327,26 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
|> Enum.map(fn {m, _} -> m end)
|
||||
end
|
||||
|
||||
defp sort_members_by_groups_export(members, order) do
|
||||
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||
first_group_name = fn member ->
|
||||
(member.groups || [])
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.min(fn -> nil end)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.sort_by(fn member ->
|
||||
name = first_group_name.(member)
|
||||
# Nil (no groups) sorts last in asc, first in desc
|
||||
{name == nil, name || ""}
|
||||
end)
|
||||
|> then(fn list ->
|
||||
if order == "desc", do: Enum.reverse(list), else: list
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_cfv(member, custom_field) do
|
||||
(member.custom_field_values || [])
|
||||
|> Enum.find(fn cfv ->
|
||||
|
|
@ -294,6 +364,19 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp maybe_load_groups(query, false), do: query
|
||||
|
||||
defp maybe_load_groups(query, true) do
|
||||
# Load groups with id and name only (for export formatting)
|
||||
Ash.Query.load(query, groups: [:id, :name])
|
||||
end
|
||||
|
||||
defp maybe_load_membership_fee_type(query, false), do: query
|
||||
|
||||
defp maybe_load_membership_fee_type(query, true) do
|
||||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||
|
||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||
|
|
@ -343,6 +426,32 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
}
|
||||
end)
|
||||
|
||||
membership_fee_type_col =
|
||||
if "membership_fee_type" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
key: :membership_fee_type,
|
||||
kind: :membership_fee_type,
|
||||
label: label_fn.(:membership_fee_type)
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
groups_col =
|
||||
if "groups" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
key: :groups,
|
||||
kind: :groups,
|
||||
label: label_fn.(:groups)
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
custom_cols =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id ->
|
||||
|
|
@ -361,7 +470,8 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||
end
|
||||
|
||||
defp build_rows(members, columns, custom_fields_by_id) do
|
||||
|
|
@ -391,14 +501,28 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp cell_value(
|
||||
member,
|
||||
%{kind: :membership_fee_type, key: :membership_fee_type},
|
||||
_custom_fields_by_id
|
||||
) do
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
%{name: name} when is_binary(name) -> name
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
|
||||
groups = Map.get(member, :groups) || []
|
||||
format_groups(groups)
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
try do
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
|
||||
defp get_cfv_by_id(member, id) do
|
||||
|
|
@ -424,6 +548,15 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp format_groups([]), do: ""
|
||||
|
||||
defp format_groups(groups) when is_list(groups) do
|
||||
groups
|
||||
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
|
||||
defp build_meta(members) do
|
||||
%{
|
||||
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||
|
|
|
|||
|
|
@ -59,14 +59,24 @@ defmodule Mv.Membership.MembersCSV do
|
|||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
%{name: name} when is_binary(name) -> name
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :groups, key: :groups}) do
|
||||
groups = Map.get(member, :groups) || []
|
||||
format_groups(groups)
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
try do
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
|
||||
defp get_cfv_by_id(member, id) do
|
||||
|
|
@ -97,4 +107,13 @@ defmodule Mv.Membership.MembersCSV do
|
|||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp format_groups([]), do: ""
|
||||
|
||||
defp format_groups(groups) when is_list(groups) do
|
||||
groups
|
||||
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -299,11 +299,9 @@ defmodule Mv.Membership.MembersPDF do
|
|||
defp date_column?(_), do: false
|
||||
|
||||
defp key_to_atom_safe(key) when is_binary(key) do
|
||||
try do
|
||||
String.to_existing_atom(key)
|
||||
rescue
|
||||
ArgumentError -> key
|
||||
end
|
||||
String.to_existing_atom(key)
|
||||
rescue
|
||||
ArgumentError -> key
|
||||
end
|
||||
|
||||
defp key_to_atom_safe(key), do: key
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Repo
|
||||
|
||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
|
|
@ -110,10 +112,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
end
|
||||
|
||||
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
||||
lock_key = :erlang.phash2(member.id)
|
||||
lock_key = Member.advisory_lock_key_for_member_id(member.id)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case do_generate_cycles(member, today, opts) do
|
||||
{:ok, cycles, notifications} ->
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Mv.OidcRoleSync do
|
|||
@moduledoc """
|
||||
Syncs user role from OIDC user_info (e.g. groups claim → Admin role).
|
||||
|
||||
Used after OIDC registration (register_with_rauthy) and on sign-in so that
|
||||
Used after OIDC registration (register_with_oidc) and on sign-in so that
|
||||
users in the configured admin group get the Admin role; others get Mitglied.
|
||||
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
|
||||
|
||||
|
|
@ -82,11 +82,9 @@ defmodule Mv.OidcRoleSync do
|
|||
end
|
||||
|
||||
defp safe_get_atom(map, key) when is_binary(key) do
|
||||
try do
|
||||
Map.get(map, String.to_existing_atom(key))
|
||||
rescue
|
||||
ArgumentError -> nil
|
||||
end
|
||||
Map.get(map, String.to_existing_atom(key))
|
||||
rescue
|
||||
ArgumentError -> nil
|
||||
end
|
||||
|
||||
defp safe_get_atom(_map, _key), do: nil
|
||||
|
|
|
|||
|
|
@ -2,23 +2,19 @@ defmodule Mv.OidcRoleSyncConfig do
|
|||
@moduledoc """
|
||||
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
||||
|
||||
Reads from Application config `:mv, :oidc_role_sync`:
|
||||
- `:admin_group_name` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
||||
- `:groups_claim` – JWT/user_info claim name for groups (default: `"groups"`).
|
||||
Reads from Mv.Config (ENV first, then Settings):
|
||||
- `oidc_admin_group_name/0` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
||||
- `oidc_groups_claim/0` – JWT/user_info claim name for groups (default: `"groups"`).
|
||||
|
||||
Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
|
||||
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings → OIDC).
|
||||
"""
|
||||
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
|
||||
def oidc_admin_group_name do
|
||||
get(:admin_group_name)
|
||||
Mv.Config.oidc_admin_group_name()
|
||||
end
|
||||
|
||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
||||
def oidc_groups_claim do
|
||||
get(:groups_claim) || "groups"
|
||||
end
|
||||
|
||||
defp get(key) do
|
||||
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
|
||||
Mv.Config.oidc_groups_claim() || "groups"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ defmodule Mv.Release do
|
|||
## Tasks
|
||||
|
||||
- `migrate/0` - Runs all pending Ecto migrations.
|
||||
- `bootstrap_seeds_applied?/0` - Returns whether bootstrap was already applied (admin user exists). Used to skip re-running seeds.
|
||||
- `run_seeds/0` - If bootstrap already applied, skips; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data).
|
||||
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||
to update the admin password without redeploying.
|
||||
|
|
@ -17,6 +19,7 @@ defmodule Mv.Release do
|
|||
alias Mv.Authorization.Role
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
|
|
@ -26,6 +29,68 @@ defmodule Mv.Release do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether bootstrap seeds have already been applied (admin user exists).
|
||||
|
||||
We check for the admin user (from ADMIN_EMAIL or default), not the Admin role,
|
||||
because migrations may create the Admin role for the system actor. Only seeds
|
||||
create the admin (login) user. Used to skip re-running seeds on subsequent starts.
|
||||
Call only when the application is already started.
|
||||
"""
|
||||
def bootstrap_seeds_applied? do
|
||||
admin_email = get_env("ADMIN_EMAIL", "admin@localhost")
|
||||
|
||||
case User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Accounts) do
|
||||
{:ok, %User{}} -> true
|
||||
_ -> false
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.warning("Could not check seed status (#{inspect(e)}), assuming not applied.")
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
|
||||
|
||||
- Skips if bootstrap was already applied (admin user exists); set `FORCE_SEEDS=true` to override and re-run.
|
||||
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data)
|
||||
when bootstrap is run.
|
||||
|
||||
Uses paths from the application's priv dir so it works in releases (no Mix).
|
||||
"""
|
||||
def run_seeds do
|
||||
case Application.ensure_all_started(@app) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
||||
end
|
||||
|
||||
if bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
|
||||
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
||||
else
|
||||
priv = :code.priv_dir(@app)
|
||||
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
||||
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
||||
|
||||
prev = Code.compiler_options()
|
||||
Code.compiler_options(ignore_module_conflict: true)
|
||||
|
||||
try do
|
||||
Code.eval_file(bootstrap_path)
|
||||
IO.puts("✅ Bootstrap seeds completed.")
|
||||
|
||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||
Code.eval_file(dev_path)
|
||||
IO.puts("✅ Dev seeds completed.")
|
||||
end
|
||||
after
|
||||
Code.compiler_options(prev)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
|
|
|
|||
|
|
@ -7,59 +7,66 @@ defmodule Mv.Secrets do
|
|||
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`
|
||||
Secrets are read via `Mv.Config` which prefers environment variables and
|
||||
falls back to Settings from the database:
|
||||
- OIDC_CLIENT_ID / settings.oidc_client_id
|
||||
- OIDC_CLIENT_SECRET / settings.oidc_client_secret
|
||||
- OIDC_BASE_URL / settings.oidc_base_url
|
||||
- OIDC_REDIRECT_URI / settings.oidc_redirect_uri
|
||||
|
||||
## Usage
|
||||
This module is automatically called by AshAuthentication when resolving
|
||||
secrets for the User resource's OIDC strategy.
|
||||
When a value is nil, returns `{:error, MissingSecret}` so that AshAuthentication
|
||||
does not crash (e.g. URI.new(nil)) and can redirect to sign-in with an error.
|
||||
"""
|
||||
use AshAuthentication.Secret
|
||||
|
||||
alias AshAuthentication.Errors.MissingSecret
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :client_id],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :client_id],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:client_id)
|
||||
secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :redirect_uri],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :redirect_uri],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:redirect_uri)
|
||||
secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :client_secret],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :client_secret],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:client_secret)
|
||||
secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :base_url],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :base_url],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:base_url)
|
||||
secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url)
|
||||
end
|
||||
|
||||
defp get_config(key) do
|
||||
:mv
|
||||
|> Application.fetch_env!(:rauthy)
|
||||
|> Keyword.fetch!(key)
|
||||
|> then(&{:ok, &1})
|
||||
defp secret_or_error(nil, resource, key) do
|
||||
path = [:authentication, :strategies, :oidc, key]
|
||||
{:error, MissingSecret.exception(path: path, resource: resource)}
|
||||
end
|
||||
|
||||
defp secret_or_error(value, resource, key) when is_binary(value) do
|
||||
if String.trim(value) == "" do
|
||||
secret_or_error(nil, resource, key)
|
||||
else
|
||||
{:ok, value}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
58
lib/mv/smtp/config_builder.ex
Normal file
58
lib/mv/smtp/config_builder.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule Mv.Smtp.ConfigBuilder do
|
||||
@moduledoc """
|
||||
Builds Swoosh/gen_smtp SMTP adapter options from connection parameters.
|
||||
|
||||
Single source of truth for TLS/sockopts logic (port 587 vs 465):
|
||||
- Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`.
|
||||
- Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`.
|
||||
|
||||
Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only).
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Builds the keyword list of Swoosh SMTP adapter options.
|
||||
|
||||
Options (keyword list):
|
||||
- `:host` (required) — relay hostname
|
||||
- `:port` (required) — port number (e.g. 587 or 465)
|
||||
- `:ssl_mode` (required) — `"tls"` or `"ssl"`
|
||||
- `:verify_mode` (required) — `:verify_peer` or `:verify_none`
|
||||
- `:username` (optional)
|
||||
- `:password` (optional)
|
||||
|
||||
Nil values are stripped from the result.
|
||||
"""
|
||||
@spec build_opts(keyword()) :: keyword()
|
||||
def build_opts(opts) do
|
||||
host = Keyword.fetch!(opts, :host)
|
||||
port = Keyword.fetch!(opts, :port)
|
||||
username = Keyword.get(opts, :username)
|
||||
password = Keyword.get(opts, :password)
|
||||
ssl_mode = Keyword.fetch!(opts, :ssl_mode)
|
||||
verify_mode = Keyword.fetch!(opts, :verify_mode)
|
||||
|
||||
base_opts = [
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: host,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
ssl: ssl_mode == "ssl",
|
||||
tls: if(ssl_mode == "tls", do: :always, else: :never),
|
||||
auth: :always,
|
||||
# tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect.
|
||||
tls_options: [verify: verify_mode]
|
||||
]
|
||||
|
||||
# Port 465: initial connection is ssl:connect; pass verify in sockopts.
|
||||
# Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it).
|
||||
opts =
|
||||
if ssl_mode == "ssl" do
|
||||
Keyword.put(base_opts, :sockopts, verify: verify_mode)
|
||||
else
|
||||
base_opts
|
||||
end
|
||||
|
||||
Enum.reject(opts, fn {_k, v} -> is_nil(v) end)
|
||||
end
|
||||
end
|
||||
95
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
95
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||
@moduledoc """
|
||||
Syncs a member to Vereinfacht as a finance contact after create/update.
|
||||
|
||||
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
|
||||
- If the member already has an ID, updates the contact via API.
|
||||
Runs in `after_transaction` so the member is persisted first. API failures are logged
|
||||
but do not block the member operation. Requires Vereinfacht to be configured
|
||||
(Mv.Config.vereinfacht_configured?/0).
|
||||
|
||||
Only runs when relevant data changed: on create always; on update only when
|
||||
first_name, last_name, email, street, house_number, postal_code, city, or country changed,
|
||||
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.Vereinfacht
|
||||
alias Mv.Vereinfacht.SyncFlash
|
||||
|
||||
require Logger
|
||||
|
||||
@synced_attributes [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city,
|
||||
:country
|
||||
]
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
|
||||
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_relevant?(changeset) do
|
||||
case changeset.action_type do
|
||||
:create -> true
|
||||
:update -> relevant_update?(changeset)
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp relevant_update?(changeset) do
|
||||
any_synced_attr_changed? =
|
||||
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
|
||||
|
||||
record = changeset.data
|
||||
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
|
||||
|
||||
any_synced_attr_changed? or no_contact_id_yet?
|
||||
end
|
||||
|
||||
defp blank_contact_id?(nil), do: true
|
||||
defp blank_contact_id?(""), do: true
|
||||
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
|
||||
defp blank_contact_id?(_), do: false
|
||||
|
||||
# Ash calls after_transaction with (changeset, result) only - 2 args.
|
||||
defp sync_after_transaction(_changeset, {:ok, member}) do
|
||||
case Vereinfacht.sync_member(member) do
|
||||
:ok ->
|
||||
SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
|
||||
{:ok, member}
|
||||
|
||||
{:ok, member_updated} ->
|
||||
SyncFlash.store(
|
||||
to_string(member_updated.id),
|
||||
:ok,
|
||||
"Synced to Vereinfacht."
|
||||
)
|
||||
|
||||
{:ok, member_updated}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
|
||||
|
||||
SyncFlash.store(
|
||||
to_string(member.id),
|
||||
:warning,
|
||||
Vereinfacht.format_error(reason)
|
||||
)
|
||||
|
||||
{:ok, member}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_after_transaction(_changeset, error), do: error
|
||||
end
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||
@moduledoc """
|
||||
Syncs the linked Member to Vereinfacht after a User action that may have updated
|
||||
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
|
||||
|
||||
Attach to any User action that uses SyncUserEmailToMember. After the transaction
|
||||
commits, if the user has a linked member and Vereinfacht is configured, syncs
|
||||
that member to the API. Failures are logged but do not affect the User result.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
|
||||
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
# Only sync when something that affects the linked member's data actually changed
|
||||
# (email sync or member link), to avoid unnecessary API calls on every user update.
|
||||
defp relevant_change?(changeset) do
|
||||
Ash.Changeset.changing_attribute?(changeset, :email) or
|
||||
Ash.Changeset.changing_relationship?(changeset, :member)
|
||||
end
|
||||
|
||||
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
||||
case load_linked_member(user) do
|
||||
nil ->
|
||||
{:ok, user}
|
||||
|
||||
member ->
|
||||
case Mv.Vereinfacht.sync_member(member) do
|
||||
:ok ->
|
||||
{:ok, user}
|
||||
|
||||
{:ok, _} ->
|
||||
{:ok, user}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_linked_member_after_transaction(_changeset, result), do: result
|
||||
|
||||
defp load_linked_member(%{member_id: nil}), do: nil
|
||||
defp load_linked_member(%{member_id: ""}), do: nil
|
||||
|
||||
defp load_linked_member(user) do
|
||||
actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
|
||||
{:ok, %Member{} = member} -> member
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
392
lib/mv/vereinfacht/client.ex
Normal file
392
lib/mv/vereinfacht/client.ex
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
defmodule Mv.Vereinfacht.Client do
|
||||
@moduledoc """
|
||||
HTTP client for the Vereinfacht accounting software JSON:API.
|
||||
|
||||
Creates and updates finance contacts. Uses Bearer token authentication and
|
||||
requires club ID for multi-tenancy. Configuration via ENV or Settings
|
||||
(see Mv.Config).
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@content_type "application/vnd.api+json"
|
||||
|
||||
@doc """
|
||||
Tests the connection to the Vereinfacht API with the given credentials.
|
||||
|
||||
Makes a lightweight `GET /finance-contacts?page[size]=1` request to verify
|
||||
that the API URL, API key, and club ID are valid and reachable.
|
||||
|
||||
## Returns
|
||||
- `{:ok, :connected}` – credentials are valid (HTTP 200)
|
||||
- `{:error, :not_configured}` – any parameter is nil or blank
|
||||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{:error, {:request_failed, reason}}` – network/transport error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> test_connection("https://api.example.com/api/v1", "token", "2")
|
||||
{:ok, :connected}
|
||||
|
||||
iex> test_connection(nil, "token", "2")
|
||||
{:error, :not_configured}
|
||||
"""
|
||||
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
|
||||
{:ok, :connected} | {:error, term()}
|
||||
def test_connection(api_url, api_key, club_id) do
|
||||
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
url =
|
||||
api_url
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/finance-contacts?page[size]=1")
|
||||
|
||||
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 200}} ->
|
||||
{:ok, :connected}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
|
||||
defp blank?(_), do: true
|
||||
|
||||
@doc """
|
||||
Creates a finance contact in Vereinfacht for the given member.
|
||||
|
||||
Returns the contact ID on success. Does not update the member record;
|
||||
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
|
||||
|
||||
## Options
|
||||
- None; URL, API key, and club ID are read from Mv.Config.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_contact(member)
|
||||
{:ok, "242"}
|
||||
|
||||
iex> create_contact(member)
|
||||
{:error, {:http, 401, "Unauthenticated."}}
|
||||
"""
|
||||
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
|
||||
def create_contact(member) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
club_id = club_id()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
body = build_create_body(member, club_id)
|
||||
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
|
||||
post_and_parse_contact(url, body, api_key)
|
||||
end
|
||||
end
|
||||
|
||||
@sync_timeout_ms 5_000
|
||||
|
||||
# Resolved at compile time so Mix is never called at runtime (Mix is not available in releases).
|
||||
@env Mix.env()
|
||||
|
||||
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
|
||||
defp req_http_options do
|
||||
opts = [receive_timeout: @sync_timeout_ms]
|
||||
if @env == :test, do: [retry: false] ++ opts, else: opts
|
||||
end
|
||||
|
||||
defp post_and_parse_contact(url, body, api_key) do
|
||||
encoded_body = Jason.encode!(body)
|
||||
|
||||
case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 201, body: resp_body}} ->
|
||||
case get_contact_id_from_response(resp_body) do
|
||||
nil -> {:error, {:invalid_response, resp_body}}
|
||||
id -> {:ok, id}
|
||||
end
|
||||
|
||||
{:ok, %{status: status, body: resp_body}} ->
|
||||
{:error, {:http, status, extract_error_message(resp_body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an existing finance contact in Vereinfacht.
|
||||
|
||||
Only sends attributes that are typically synced from the member (name, email,
|
||||
address fields). Returns the same contact_id on success.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_contact("242", member)
|
||||
{:ok, "242"}
|
||||
|
||||
iex> update_contact("242", member)
|
||||
{:error, {:http, 404, "Not Found"}}
|
||||
"""
|
||||
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
|
||||
def update_contact(contact_id, member) when is_binary(contact_id) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
body = build_update_body(contact_id, member)
|
||||
encoded_body = Jason.encode!(body)
|
||||
|
||||
url =
|
||||
base_url
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||
|
||||
case Req.patch(
|
||||
url,
|
||||
[
|
||||
body: encoded_body,
|
||||
headers: headers(api_key)
|
||||
] ++ req_http_options()
|
||||
) do
|
||||
{:ok, %{status: 200, body: _resp_body}} ->
|
||||
{:ok, contact_id}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a finance contact by email using the API filter.
|
||||
|
||||
Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API
|
||||
returns only matching external contacts. Returns {:ok, contact_id} if a contact
|
||||
exists, {:error, :not_found} if none, or {:error, reason} on API/network failure.
|
||||
Used before create for idempotency.
|
||||
"""
|
||||
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
|
||||
def find_contact_by_email(email) when is_binary(email) do
|
||||
if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
do_find_contact_by_email(email)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_find_contact_by_email(email) do
|
||||
normalized_email = email |> String.trim() |> String.downcase()
|
||||
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
|
||||
encoded_email = URI.encode_www_form(normalized_email)
|
||||
url = "#{base}?filter[isExternal]=true&filter[email]=#{encoded_email}"
|
||||
|
||||
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
|
||||
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||
case get_first_contact_id_from_list(body) do
|
||||
nil -> {:error, :not_found}
|
||||
id -> {:ok, id}
|
||||
end
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_first_contact_id_from_list(%{"data" => data} = _body) when is_list(data) do
|
||||
if length(data) > 1 do
|
||||
Logger.warning(
|
||||
"Vereinfacht find_contact_by_email: API returned multiple contacts for same email (count: #{length(data)}), using first. Check for duplicate or inconsistent data."
|
||||
)
|
||||
end
|
||||
|
||||
case data do
|
||||
[%{"id" => id} | _] -> normalize_contact_id(id)
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_first_contact_id_from_list(_), do: nil
|
||||
|
||||
defp normalize_contact_id(id) when is_binary(id), do: id
|
||||
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
|
||||
defp normalize_contact_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).
|
||||
|
||||
Returns the full response body (decoded JSON) for debugging/display.
|
||||
"""
|
||||
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def get_contact(contact_id) when is_binary(contact_id) do
|
||||
fetch_contact(contact_id, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
|
||||
|
||||
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
|
||||
(and optional :type) for each receipt, or {:error, reason}.
|
||||
"""
|
||||
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
|
||||
case fetch_contact(contact_id, include: "receipts") do
|
||||
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_contact(contact_id, query_params) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
path =
|
||||
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||
|
||||
url = build_url_with_params(path, query_params)
|
||||
|
||||
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp build_url_with_params(base, []), do: base
|
||||
|
||||
defp build_url_with_params(base, include: value) do
|
||||
sep = if String.contains?(base, "?"), do: "&", else: "?"
|
||||
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
|
||||
end
|
||||
|
||||
# Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS).
|
||||
@receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a
|
||||
|
||||
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
|
||||
included
|
||||
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|
||||
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
|
||||
Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{}))
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_receipts_from_response(_), do: []
|
||||
|
||||
defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
|
||||
Map.new(@receipt_attr_allowlist, fn key ->
|
||||
str_key = to_string(key)
|
||||
{key, Map.get(attrs, str_key)}
|
||||
end)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp base_url, do: Mv.Config.vereinfacht_api_url()
|
||||
defp api_key, do: Mv.Config.vereinfacht_api_key()
|
||||
defp club_id, do: Mv.Config.vereinfacht_club_id()
|
||||
|
||||
defp headers(api_key) do
|
||||
[
|
||||
{"Accept", @content_type},
|
||||
{"Content-Type", @content_type},
|
||||
{"Authorization", "Bearer #{api_key}"}
|
||||
]
|
||||
end
|
||||
|
||||
defp build_create_body(member, club_id) do
|
||||
attributes = member_to_attributes(member)
|
||||
|
||||
%{
|
||||
"data" => %{
|
||||
"type" => "finance-contacts",
|
||||
"attributes" => attributes,
|
||||
"relationships" => %{
|
||||
"club" => %{
|
||||
"data" => %{"type" => "clubs", "id" => club_id}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp build_update_body(contact_id, member) do
|
||||
attributes = member_to_attributes(member)
|
||||
|
||||
%{
|
||||
"data" => %{
|
||||
"type" => "finance-contacts",
|
||||
"id" => contact_id,
|
||||
"attributes" => attributes
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp member_to_attributes(member) do
|
||||
address =
|
||||
[member |> Map.get(:street), member |> Map.get(:house_number)]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map_join(" ", &to_string/1)
|
||||
|> then(fn s -> if s == "", do: nil, else: s end)
|
||||
|
||||
%{}
|
||||
|> put_attr("lastName", member |> Map.get(:last_name))
|
||||
|> put_attr("firstName", member |> Map.get(:first_name))
|
||||
|> put_attr("email", member |> Map.get(:email))
|
||||
|> put_attr("address", address)
|
||||
|> put_attr("zipCode", member |> Map.get(:postal_code))
|
||||
|> put_attr("city", member |> Map.get(:city))
|
||||
|> put_attr("country", member |> Map.get(:country))
|
||||
|> Map.put("contactType", "person")
|
||||
|> Map.put("isExternal", true)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp put_attr(acc, _key, nil), do: acc
|
||||
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
|
||||
|
||||
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
|
||||
|
||||
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
|
||||
do: to_string(id)
|
||||
|
||||
defp get_contact_id_from_response(_), do: nil
|
||||
|
||||
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
|
||||
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
|
||||
defp extract_error_message(body) when is_map(body), do: inspect(body)
|
||||
|
||||
defp extract_error_message(body) when is_binary(body) do
|
||||
trimmed = String.trim(body)
|
||||
|
||||
if String.starts_with?(trimmed, "<") do
|
||||
:html_response
|
||||
else
|
||||
trimmed
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(other), do: inspect(other)
|
||||
end
|
||||
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Mv.Vereinfacht.SyncFlash do
|
||||
@moduledoc """
|
||||
Short-lived store for Vereinfacht sync results so the UI can show them after save.
|
||||
|
||||
The SyncContact change runs in after_transaction and cannot access the LiveView
|
||||
socket. This module stores a message keyed by member_id; the form LiveView
|
||||
calls `take/1` after a successful save and displays the message in flash.
|
||||
"""
|
||||
@table :vereinfacht_sync_flash
|
||||
|
||||
@doc """
|
||||
Stores a sync result for the given member. Overwrites any previous message.
|
||||
|
||||
- `:ok` - Sync succeeded (optional user message).
|
||||
- `:warning` - Sync failed; message should be shown as a warning.
|
||||
"""
|
||||
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
|
||||
def store(member_id, kind, message) when is_binary(member_id) do
|
||||
:ets.insert(@table, {member_id, {kind, message}})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Takes and removes the stored sync message for the given member.
|
||||
|
||||
Returns `{kind, message}` if present, otherwise `nil`.
|
||||
"""
|
||||
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
|
||||
def take(member_id) when is_binary(member_id) do
|
||||
case :ets.take(@table, member_id) do
|
||||
[{^member_id, value}] -> value
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def create_table! do
|
||||
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
||||
# not the process that created the table). :protected would restrict writes to the creating process.
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
186
lib/mv/vereinfacht/vereinfacht.ex
Normal file
186
lib/mv/vereinfacht/vereinfacht.ex
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
defmodule Mv.Vereinfacht do
|
||||
@moduledoc """
|
||||
Business logic for Vereinfacht accounting software integration.
|
||||
|
||||
- `sync_member/1` – Sync a single member to the API (create or update contact).
|
||||
Used by Member create/update (SyncContact) and by User actions that update
|
||||
the linked member's email via Ecto (e.g. user email change).
|
||||
- `sync_members_without_contact/0` – Bulk sync of members without a contact ID.
|
||||
"""
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Vereinfacht.Client
|
||||
|
||||
@doc """
|
||||
Tests the connection to the Vereinfacht API using the current configuration.
|
||||
|
||||
Delegates to `Mv.Vereinfacht.Client.test_connection/3` with the values from
|
||||
`Mv.Config` (ENV variables take priority over database settings).
|
||||
|
||||
## Returns
|
||||
- `{:ok, :connected}` – credentials are valid and API is reachable
|
||||
- `{:error, :not_configured}` – URL, API key or club ID is missing
|
||||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{:error, {:request_failed, reason}}` – network/transport error
|
||||
"""
|
||||
@spec test_connection() :: {:ok, :connected} | {:error, term()}
|
||||
def test_connection do
|
||||
Client.test_connection(
|
||||
Mv.Config.vereinfacht_api_url(),
|
||||
Mv.Config.vereinfacht_api_key(),
|
||||
Mv.Config.vereinfacht_club_id()
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Syncs a single member to Vereinfacht (create or update finance contact).
|
||||
|
||||
If the member has no `vereinfacht_contact_id`, creates a contact and updates
|
||||
the member with the new ID. If they already have an ID, updates the contact.
|
||||
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
|
||||
|
||||
Returns:
|
||||
- `:ok` – Contact was updated.
|
||||
- `{:ok, member}` – Contact was created and member was updated with the new ID.
|
||||
- `{:error, reason}` – API or update failed.
|
||||
"""
|
||||
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
|
||||
def sync_member(member) do
|
||||
if Mv.Config.vereinfacht_configured?() do
|
||||
do_sync_member(member)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_member(member) do
|
||||
if present_contact_id?(member.vereinfacht_contact_id) do
|
||||
sync_existing_contact(member)
|
||||
else
|
||||
ensure_contact_then_save(member)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_existing_contact(member) do
|
||||
case Client.update_contact(member.vereinfacht_contact_id, member) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_contact_then_save(member) do
|
||||
case get_or_create_contact_id(member) do
|
||||
{:ok, contact_id} -> save_contact_id(member, contact_id)
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
# Before create: find by email to avoid duplicate contacts (idempotency).
|
||||
# When an existing contact is found, update it with current member data.
|
||||
defp get_or_create_contact_id(member) do
|
||||
email = member |> Map.get(:email) |> to_string() |> String.trim()
|
||||
|
||||
if email == "" do
|
||||
Client.create_contact(member)
|
||||
else
|
||||
case Client.find_contact_by_email(email) do
|
||||
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
|
||||
{:error, :not_found} -> Client.create_contact(member)
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp update_existing_contact_and_return_id(contact_id, member) do
|
||||
case Client.update_contact(contact_id, member) do
|
||||
{:ok, _} -> {:ok, contact_id}
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
defp save_contact_id(member, contact_id) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
|
||||
{:action, :set_vereinfacht_contact_id} | opts
|
||||
]) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp present_contact_id?(nil), do: false
|
||||
defp present_contact_id?(""), do: false
|
||||
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present_contact_id?(_), do: false
|
||||
|
||||
@doc """
|
||||
Formats an API/request error reason into a short user-facing message.
|
||||
|
||||
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
|
||||
"""
|
||||
@spec format_error(term()) :: String.t()
|
||||
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
|
||||
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
|
||||
|
||||
def format_error({:request_failed, _}),
|
||||
do: "Vereinfacht: Request failed (e.g. connection error)."
|
||||
|
||||
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
|
||||
def format_error(other), do: "Vereinfacht: " <> inspect(other)
|
||||
|
||||
@doc """
|
||||
Creates Vereinfacht contacts for all members that do not yet have a
|
||||
`vereinfacht_contact_id`. Uses system actor for reads and updates.
|
||||
|
||||
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
|
||||
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
|
||||
"""
|
||||
@spec sync_members_without_contact() ::
|
||||
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
|
||||
| {:error, :not_configured}
|
||||
def sync_members_without_contact do
|
||||
if Mv.Config.vereinfacht_configured?() do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(
|
||||
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
|
||||
)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, members} ->
|
||||
do_sync_members(members, opts)
|
||||
|
||||
{:error, _} = err ->
|
||||
err
|
||||
end
|
||||
else
|
||||
{:error, :not_configured}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_members(members, opts) do
|
||||
{synced, errors} =
|
||||
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
|
||||
{inc, new_errors} = sync_one_member(member, opts)
|
||||
{acc_synced + inc, acc_errors ++ new_errors}
|
||||
end)
|
||||
|
||||
{:ok, %{synced: synced, errors: errors}}
|
||||
end
|
||||
|
||||
defp sync_one_member(member, _opts) do
|
||||
case sync_member(member) do
|
||||
:ok -> {1, []}
|
||||
{:ok, _} -> {1, []}
|
||||
{:error, reason} -> {0, [{member.id, reason}]}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -94,8 +94,8 @@ defmodule MvWeb do
|
|||
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
|
||||
|
||||
# Common modules used in templates
|
||||
alias Phoenix.LiveView.JS
|
||||
alias MvWeb.Layouts
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
|
|
|
|||
|
|
@ -3,46 +3,70 @@ defmodule MvWeb.AuthOverrides do
|
|||
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
|
||||
- `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
|
||||
- `Banner` - Replaces default logo with text for reset/confirm pages
|
||||
- `Flash` - Hides library flash (we use flash_group in root layout)
|
||||
|
||||
## 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
|
||||
|
||||
# configure your UI overrides here
|
||||
|
||||
# First argument to `override` is the component name you are overriding.
|
||||
# The body contains any number of configurations you wish to override
|
||||
# Below are some examples
|
||||
|
||||
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
|
||||
# override AshAuthentication.Phoenix.Components.Banner do
|
||||
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
|
||||
# set :text_class, "bg-red-500"
|
||||
# end
|
||||
|
||||
# Avoid full-width for the Sign In Form
|
||||
# Avoid full-width for the Sign In Form.
|
||||
# Banner is hidden because SignInLive renders its own locale-aware title.
|
||||
override AshAuthentication.Phoenix.Components.SignIn do
|
||||
set :root_class, "md:min-w-md"
|
||||
set :show_banner, false
|
||||
end
|
||||
|
||||
# Replace banner logo with text
|
||||
# Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
|
||||
override AshAuthentication.Phoenix.Components.Banner do
|
||||
set :text, "Mitgliederverwaltung"
|
||||
set :image_url, nil
|
||||
set :dark_image_url, nil
|
||||
end
|
||||
|
||||
# Translate the or in the horizontal rule to German
|
||||
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
||||
set :text,
|
||||
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
|
||||
Gettext.gettext(MvWeb.Gettext, "or")
|
||||
end)
|
||||
# Hide AshAuthentication's Flash component since we use flash_group in root layout.
|
||||
# This prevents duplicate flash messages.
|
||||
override AshAuthentication.Phoenix.Components.Flash do
|
||||
set :message_class_info, "hidden"
|
||||
set :message_class_error, "hidden"
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MvWeb.AuthOverridesRegistrationDisabled do
|
||||
@moduledoc """
|
||||
When direct registration is disabled in global settings, this override is
|
||||
prepended in SignInLive so the Password component hides the "Need an account?"
|
||||
toggle (register_toggle_text: nil disables the register link per library docs).
|
||||
"""
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
|
||||
override AshAuthentication.Phoenix.Components.Password do
|
||||
set :register_toggle_text, nil
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MvWeb.AuthOverridesDE do
|
||||
@moduledoc """
|
||||
German locale-specific overrides for AshAuthentication Phoenix components.
|
||||
|
||||
Prepended to the overrides list in SignInLive when the locale is "de".
|
||||
Provides runtime-static German text for components that do not use
|
||||
the `_gettext` mechanism (e.g. HorizontalRule renders its text directly),
|
||||
and for submit buttons whose disable_text bypasses the POT extraction pipeline.
|
||||
"""
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
|
||||
# HorizontalRule renders text without `_gettext`, so we need a static German string.
|
||||
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
||||
set :text, "oder"
|
||||
end
|
||||
|
||||
# Registering ... disable-text is passed through _gettext but "Registering ..."
|
||||
# has no dgettext source reference, so we supply the German string directly.
|
||||
override AshAuthentication.Phoenix.Components.Password.RegisterForm do
|
||||
set :disable_button_text, "Registrieren..."
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,8 +29,24 @@ defmodule MvWeb.CoreComponents do
|
|||
use Phoenix.Component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Phoenix.HTML.Form, as: HTMLForm
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
||||
@button_focus_classes [
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-offset-base-100",
|
||||
"focus-visible:ring-base-content/60"
|
||||
]
|
||||
|
||||
@doc """
|
||||
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
|
||||
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
|
||||
"""
|
||||
def button_focus_classes, do: @button_focus_classes
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
|
|
@ -47,6 +63,11 @@ defmodule MvWeb.CoreComponents do
|
|||
values: [:info, :error, :success, :warning],
|
||||
doc: "used for styling and flash lookup"
|
||||
|
||||
attr :auto_clear_ms, :integer,
|
||||
default: nil,
|
||||
doc:
|
||||
"when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)"
|
||||
|
||||
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"
|
||||
|
|
@ -58,17 +79,20 @@ defmodule MvWeb.CoreComponents do
|
|||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-hook={@auto_clear_ms && "FlashAutoDismiss"}
|
||||
data-auto-clear-ms={@auto_clear_ms}
|
||||
data-clear-flash-key={@auto_clear_ms && @kind}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="z-50 toast toast-top toast-end"
|
||||
class="pointer-events-auto"
|
||||
{@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 == :success && "alert-success",
|
||||
@kind == :warning && "alert-warning"
|
||||
]}>
|
||||
<.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" />
|
||||
|
|
@ -90,33 +114,74 @@ defmodule MvWeb.CoreComponents do
|
|||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Variants (Design Guidelines §5.2)
|
||||
- primary (main CTA)
|
||||
- secondary (supporting)
|
||||
- neutral (cancel/back)
|
||||
- ghost (low emphasis; table/toolbars)
|
||||
- outline (alternative CTA)
|
||||
- danger (destructive)
|
||||
- link (inline; rare)
|
||||
- icon (icon-only)
|
||||
|
||||
## Sizes
|
||||
- sm, md (default), lg
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
<.button navigate={~p"/"} variant="secondary">Home</.button>
|
||||
<.button variant="ghost" size="sm">Edit</.button>
|
||||
<.button disabled={true}>Disabled</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method data-testid)
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
attr :rest, :global, include: ~w(href navigate patch method data-testid form)
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(primary secondary neutral ghost outline danger link icon),
|
||||
default: "primary"
|
||||
|
||||
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
rest = assigns.rest
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||
variant = assigns[:variant] || "primary"
|
||||
size = assigns[:size] || "md"
|
||||
|
||||
variant_classes = %{
|
||||
"primary" => "btn-primary",
|
||||
"secondary" => "btn-secondary",
|
||||
"neutral" => "btn-neutral",
|
||||
"ghost" => "btn-ghost",
|
||||
"outline" => "btn-outline",
|
||||
"danger" => "btn-error",
|
||||
"link" => "btn-link",
|
||||
"icon" => "btn-ghost btn-square"
|
||||
}
|
||||
|
||||
size_classes = %{
|
||||
"sm" => "btn-sm",
|
||||
"md" => "",
|
||||
"lg" => "btn-lg"
|
||||
}
|
||||
|
||||
base_class = Map.fetch!(variant_classes, variant)
|
||||
size_class = size_classes[size]
|
||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:btn_class, btn_class)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
# For links, we can't use disabled attribute, so we use btn-disabled class
|
||||
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
|
||||
link_class =
|
||||
if assigns[:disabled],
|
||||
do: ["btn", assigns.class, "btn-disabled"],
|
||||
else: ["btn", assigns.class]
|
||||
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||
else: ["btn", btn_class] ++ @button_focus_classes
|
||||
|
||||
# Prevent interaction when disabled
|
||||
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
rest
|
||||
|
|
@ -138,13 +203,223 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @class]} disabled={@disabled} {@rest}>
|
||||
<button
|
||||
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||
|
||||
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
|
||||
use a button or link component instead, not this badge.
|
||||
|
||||
## Variants and styles
|
||||
|
||||
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
|
||||
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
|
||||
- **size:** `:sm`, `:md` (default)
|
||||
|
||||
Outline and soft styles always use a visible background so the badge remains
|
||||
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
|
||||
by default to avoid low-contrast on gray backgrounds.
|
||||
|
||||
## Examples
|
||||
|
||||
<.badge variant="success">Paid</.badge>
|
||||
<.badge variant="error" style="solid">Unpaid</.badge>
|
||||
<.badge variant="neutral" size="sm">Custom</.badge>
|
||||
<.badge variant="primary" style="outline">Label</.badge>
|
||||
<.badge variant="success" sr_label="Paid">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
</.badge>
|
||||
"""
|
||||
attr :variant, :any,
|
||||
default: "neutral",
|
||||
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
|
||||
|
||||
attr :style, :any,
|
||||
default: "soft",
|
||||
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
|
||||
|
||||
attr :size, :any,
|
||||
default: "md",
|
||||
doc: "Badge size: sm | md"
|
||||
|
||||
attr :sr_label, :string,
|
||||
default: nil,
|
||||
doc: "Optional screen-reader label for icon-only content"
|
||||
|
||||
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
|
||||
|
||||
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
|
||||
slot :icon, doc: "Optional leading icon slot"
|
||||
|
||||
def badge(assigns) do
|
||||
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
|
||||
variant = to_string(assigns.variant || "neutral")
|
||||
style = to_string(assigns.style || "soft")
|
||||
size = to_string(assigns.size || "md")
|
||||
|
||||
variant_class = "badge-#{variant}"
|
||||
style_class = badge_style_class(style)
|
||||
size_class = "badge-#{size}"
|
||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||
|
||||
rest = assigns.rest || []
|
||||
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
|
||||
extra_class = Keyword.get(rest, :class)
|
||||
rest = Keyword.drop(rest, [:class])
|
||||
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
|
||||
|
||||
class =
|
||||
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:class, class)
|
||||
|> assign(:rest, rest)
|
||||
|> assign(:has_icon, assigns.icon != [])
|
||||
|
||||
~H"""
|
||||
<span class={@class} {@rest}>
|
||||
<%= if @has_icon do %>
|
||||
{render_slot(@icon)}
|
||||
<% end %>
|
||||
{render_slot(@inner_block)}
|
||||
<%= if @sr_label do %>
|
||||
<span class="sr-only">{@sr_label}</span>
|
||||
<% end %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp badge_style_class("soft"), do: "badge-soft"
|
||||
defp badge_style_class("solid"), do: nil
|
||||
defp badge_style_class("outline"), do: "badge-outline"
|
||||
defp badge_style_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||
|
||||
Use when a table cell has no value so that:
|
||||
- The cell appears empty (no dash, no "n/a").
|
||||
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<.empty_cell sr_text={gettext("No group assignment")} />
|
||||
<.empty_cell sr_text={gettext("Not specified")} />
|
||||
"""
|
||||
attr :sr_text, :string,
|
||||
required: true,
|
||||
doc: "Text read by screen readers when the cell is visually empty"
|
||||
|
||||
def empty_cell(assigns) do
|
||||
~H"""
|
||||
<span class="sr-only">{@sr_text}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders content when value is present, otherwise an accessible empty cell.
|
||||
|
||||
Use in table cells for optional fields: when `value` is blank, only the
|
||||
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
|
||||
|
||||
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
|
||||
{member.membership_fee_type.name}
|
||||
</.maybe_value>
|
||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||
<%= for g <- member.groups do %>
|
||||
<.badge variant="primary" style="outline">{g.name}</.badge>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
"""
|
||||
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
|
||||
|
||||
attr :empty_sr_text, :string,
|
||||
default: nil,
|
||||
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def maybe_value(assigns) do
|
||||
empty_sr = assigns.empty_sr_text || gettext("Not specified")
|
||||
assigns = assign(assigns, :empty_sr_text, empty_sr)
|
||||
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
|
||||
|
||||
~H"""
|
||||
<%= if @blank? do %>
|
||||
<.empty_cell sr_text={@empty_sr_text} />
|
||||
<% else %>
|
||||
{render_slot(@inner_block)}
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp value_blank?(nil), do: true
|
||||
defp value_blank?(false), do: true
|
||||
defp value_blank?([]), do: true
|
||||
defp value_blank?(%Ash.NotLoaded{}), do: true
|
||||
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||
defp value_blank?(_), do: false
|
||||
|
||||
@doc """
|
||||
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||
or status badges that need explanation (Design Guidelines §8.2).
|
||||
|
||||
## Examples
|
||||
|
||||
<.tooltip content={gettext("Edit")}>
|
||||
<.button variant="icon" size="sm"><.icon name="hero-pencil" /></.button>
|
||||
</.tooltip>
|
||||
|
||||
<.tooltip content={@full_name} position="top">
|
||||
<span class="truncate max-w-32">{@full_name}</span>
|
||||
</.tooltip>
|
||||
"""
|
||||
attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
|
||||
|
||||
attr :position, :string,
|
||||
values: ~w(top bottom left right),
|
||||
default: "bottom"
|
||||
|
||||
attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tooltip(assigns) do
|
||||
position_class = "tooltip tooltip-#{assigns.position}"
|
||||
wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
|
||||
|
||||
assigns = assign(assigns, :wrap_class, wrap_class)
|
||||
|
||||
~H"""
|
||||
<div class={@wrap_class} data-tip={@content}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a dropdown menu.
|
||||
|
||||
|
|
@ -191,7 +466,11 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
assigns = assign(assigns, :menu_testid, menu_testid)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:menu_testid, menu_testid)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
|
|
@ -207,17 +486,10 @@ defmodule MvWeb.CoreComponents do
|
|||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-expanded={if @open, do: "true", else: "false"}
|
||||
aria-controls={@id}
|
||||
aria-label={@button_label}
|
||||
class={[
|
||||
"btn",
|
||||
"focus:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-base-content/20",
|
||||
@button_class
|
||||
]}
|
||||
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid={@button_testid}
|
||||
|
|
@ -285,7 +557,12 @@ defmodule MvWeb.CoreComponents do
|
|||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={
|
||||
[
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
|
||||
"focus-visible:ring-inset"
|
||||
] ++ @button_focus_classes
|
||||
}
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
|
|
@ -293,13 +570,17 @@ defmodule MvWeb.CoreComponents do
|
|||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
||||
tabindex="-1"
|
||||
<%!-- Visual-only indicator: do not nest an interactive control (checkbox) inside the button for screen reader and focus correctness (WCAG 2.1.2). --%>
|
||||
<span
|
||||
class={
|
||||
if Map.get(@selected, item.value, true),
|
||||
do: "text-primary",
|
||||
else: "text-base-300"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
>
|
||||
<.icon name="hero-check" class="size-4 shrink-0" />
|
||||
</span>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
|
|
@ -401,7 +682,7 @@ defmodule MvWeb.CoreComponents do
|
|||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
HTMLForm.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
# For checkboxes, we don't use HTML required attribute (means "must be checked")
|
||||
|
|
@ -437,7 +718,7 @@ defmodule MvWeb.CoreComponents do
|
|||
{@rest}
|
||||
/>{@label}<span
|
||||
:if={@is_required}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field is required")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -448,13 +729,15 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -466,7 +749,7 @@ defmodule MvWeb.CoreComponents do
|
|||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
{HTMLForm.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
|
|
@ -475,13 +758,15 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -493,7 +778,7 @@ defmodule MvWeb.CoreComponents do
|
|||
@errors != [] && (@error_class || "textarea-error")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
>{HTMLForm.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</fieldset>
|
||||
|
|
@ -502,13 +787,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -516,7 +803,7 @@ defmodule MvWeb.CoreComponents do
|
|||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
value={HTMLForm.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input",
|
||||
@errors != [] && (@error_class || "input-error")
|
||||
|
|
@ -529,6 +816,18 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
# WCAG 2.1: set aria-required when required is true so screen readers announce required state
|
||||
defp ensure_aria_required_for_input(assigns) do
|
||||
rest = assigns.rest || %{}
|
||||
|
||||
rest =
|
||||
if rest[:required],
|
||||
do: Map.put(rest, :aria_required, "true"),
|
||||
else: rest
|
||||
|
||||
assign(assigns, :rest, rest)
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
|
|
@ -541,17 +840,24 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
|
||||
Use the `:leading` slot for the Back button (left side, consistent with data fields).
|
||||
Use the `:actions` slot for primary actions (e.g. Save) on the right.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :leading, doc: "Content on the left (e.g. Back button)"
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
|
||||
<div>
|
||||
<header class={["flex items-center gap-6 pb-4", @class]}>
|
||||
<div :if={@leading != []} class="shrink-0">
|
||||
{render_slot(@leading)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-xl font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
|
|
@ -559,7 +865,9 @@ defmodule MvWeb.CoreComponents do
|
|||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
|
||||
<div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
|
||||
{render_slot(@actions)}
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
|
@ -567,18 +875,53 @@ defmodule MvWeb.CoreComponents do
|
|||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
|
||||
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
|
||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||
|
||||
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
|
||||
For interactive elements inside other columns (e.g. checkboxes, buttons), use
|
||||
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
|
||||
<.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
|
||||
<:col :let={m} label="Name">{m.name}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :selected_row_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
|
||||
|
||||
attr :row_selected?, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
|
||||
|
||||
attr :row_tooltip, :string,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
|
||||
|
||||
attr :row_value_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
|
@ -590,6 +933,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||
|
||||
attr :sticky_header, :boolean,
|
||||
default: false,
|
||||
doc:
|
||||
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
|
|
@ -607,19 +955,39 @@ defmodule MvWeb.CoreComponents do
|
|||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
# Function to get the row's value id for selected_row_id comparison (no extra DB reads)
|
||||
row_value_id_fn =
|
||||
assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
|
||||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
|
||||
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
|
||||
first_row_click_col_idx =
|
||||
if assigns[:row_click] do
|
||||
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class="overflow-auto"
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:for={col <- @col}
|
||||
class={Map.get(col, :class)}
|
||||
class={table_th_class(col, @sticky_header)}
|
||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||
>
|
||||
{col[:label]}
|
||||
</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
|
|
@ -629,15 +997,26 @@ defmodule MvWeb.CoreComponents do
|
|||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||
title={@row_click && @row_tooltip}
|
||||
>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
|
||||
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
|
||||
data-row-clickable={
|
||||
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
|
||||
}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -661,6 +1040,19 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus:ring-0",
|
||||
"focus-visible:ring-0" | classes
|
||||
]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class do
|
||||
[col_class | classes]
|
||||
|
|
@ -671,6 +1063,9 @@ defmodule MvWeb.CoreComponents do
|
|||
Enum.join(classes, " ")
|
||||
}
|
||||
>
|
||||
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
|
||||
<span class="sr-only">{@row_tooltip}</span>
|
||||
<% end %>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -704,6 +1099,43 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
|
||||
defp table_row_selected?(assigns, row) do
|
||||
item = assigns.row_item.(row)
|
||||
|
||||
if assigns[:row_selected?] do
|
||||
assigns.row_selected?.(item)
|
||||
else
|
||||
assigns[:selected_row_id] != nil and
|
||||
assigns.row_value_id_fn.(row) == assigns.selected_row_id
|
||||
end
|
||||
end
|
||||
|
||||
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||
# and stronger selected outline when selected (WCAG: not color-only).
|
||||
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||
defp table_row_tr_class(row_click, selected?) do
|
||||
has_row_click? = not is_nil(row_click)
|
||||
base = []
|
||||
|
||||
base =
|
||||
if has_row_click? and not selected?,
|
||||
do:
|
||||
base ++
|
||||
[
|
||||
"hover:ring-2",
|
||||
"hover:ring-inset",
|
||||
"hover:ring-base-content/10",
|
||||
"focus-within:ring-2",
|
||||
"focus-within:ring-inset",
|
||||
"focus-within:ring-base-content/10"
|
||||
],
|
||||
else: base
|
||||
|
||||
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||
Enum.join(base, " ")
|
||||
end
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
||||
|
|
@ -714,6 +1146,136 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
||||
defp table_th_class(col, sticky_header) do
|
||||
base = Map.get(col, :class)
|
||||
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
|
||||
[base, sticky] |> Enum.filter(& &1) |> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp table_th_sticky_class(true),
|
||||
do: "lg:sticky lg:top-0 bg-base-100 z-10"
|
||||
|
||||
defp table_th_sticky_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||
|
||||
Uses the SortableList hook for accessible drag-and-drop and keyboard reorder
|
||||
(Space to grab/drop, Arrow up/down to move, Escape to cancel). Pushes the
|
||||
given reorder event with `from_index` and `to_index`; the parent LiveView
|
||||
should reorder the list and re-render.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:id` – Required. Unique id for the list (used for hook and live region).
|
||||
* `:rows` – Required. List of row data.
|
||||
* `:row_id` – Required. Function to get a unique id for each row (e.g. for locked_ids).
|
||||
* `:locked_ids` – List of row ids that cannot be reordered (e.g. `["join-field-email"]`). Default `[]`.
|
||||
* `:reorder_event` – Required. LiveView event name to push on reorder (payload: `%{from_index: i, to_index: j}`).
|
||||
* `:row_item` – Function to map row before passing to slots. Default `&Function.identity/1`.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:col` – Data columns (attr `:label`, `:class`). Content is rendered with the row item.
|
||||
* `:action` – Optional. Last column (e.g. delete button) with the row item.
|
||||
|
||||
## Examples
|
||||
|
||||
<.sortable_table
|
||||
id="join-form-fields"
|
||||
rows={@join_form_fields}
|
||||
row_id={fn f -> "join-field-\#{f.id}" end}
|
||||
locked_ids={["join-field-email"]}
|
||||
reorder_event="reorder_join_form_field"
|
||||
>
|
||||
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">{field.label}</:col>
|
||||
<:col :let={field} label={gettext("Required")}>...</:col>
|
||||
<:action :let={field}><.button>Remove</.button></:action>
|
||||
</.sortable_table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, required: true
|
||||
attr :locked_ids, :list, default: []
|
||||
attr :reorder_event, :string, required: true
|
||||
attr :row_item, :any, default: &Function.identity/1
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string, required: true
|
||||
attr :class, :string
|
||||
end
|
||||
|
||||
slot :action
|
||||
|
||||
def sortable_table(assigns) do
|
||||
assigns = assign(assigns, :locked_set, MapSet.new(assigns.locked_ids))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-hook="SortableList"
|
||||
data-reorder-event={@reorder_event}
|
||||
data-locked-ids={Jason.encode!(@locked_ids)}
|
||||
data-list-id={@id}
|
||||
class="overflow-auto"
|
||||
>
|
||||
<span
|
||||
id={"#{@id}-announcement"}
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
class="sr-only"
|
||||
/>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10" aria-label={gettext("Reorder")}></th>
|
||||
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
||||
<th :if={@action != []} class="w-0 font-semibold">{gettext("Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
:for={{row, idx} <- Enum.with_index(@rows)}
|
||||
id={@row_id.(row)}
|
||||
data-row-index={idx}
|
||||
data-locked={if(MapSet.member?(@locked_set, @row_id.(row)), do: "true", else: nil)}
|
||||
tabindex={if(not MapSet.member?(@locked_set, @row_id.(row)), do: "0", else: nil)}
|
||||
>
|
||||
<td class="w-10 align-middle" data-sortable-handle>
|
||||
<span
|
||||
:if={MapSet.member?(@locked_set, @row_id.(row))}
|
||||
class="inline-block w-6 text-base-content/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="size-4" />
|
||||
</span>
|
||||
<span
|
||||
:if={not MapSet.member?(@locked_set, @row_id.(row))}
|
||||
class="inline-flex items-center justify-center w-6 h-6 cursor-grab text-base-content/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="size-4" />
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
class={["max-w-xs", Map.get(col, :class) || ""] |> Enum.join(" ")}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
|
@ -741,6 +1303,41 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect).
|
||||
|
||||
Wired to the theme script in root layout: checkbox uses `data-theme-toggle`,
|
||||
root script syncs checked state (checked = dark) and listens for `phx:set-theme`.
|
||||
Use in public header or sidebar. Optional `class` is applied to the wrapper.
|
||||
"""
|
||||
attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper"
|
||||
|
||||
def theme_swap(assigns) do
|
||||
assigns = assign(assigns, :wrapper_class, assigns[:class])
|
||||
|
||||
~H"""
|
||||
<div class={[@wrapper_class]}>
|
||||
<label
|
||||
class="swap swap-rotate cursor-pointer focus-within:outline-none focus-within:focus-visible:ring-2 focus-within:focus-visible:ring-primary focus-within:focus-visible:ring-offset-2 rounded"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-theme-toggle
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
||||
/>
|
||||
<span class="swap-on size-6 flex items-center justify-center" aria-hidden="true">
|
||||
<.icon name="hero-moon" class="size-5" />
|
||||
</span>
|
||||
<span class="swap-off size-6 flex items-center justify-center" aria-hidden="true">
|
||||
<.icon name="hero-sun" class="size-5" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club".
|
||||
Order is always: Mila · page title · club name.
|
||||
Uses assigns[:club_name] and the short page label from assigns[:content_title] or
|
||||
assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar)
|
||||
and then assign page_title to the result of this function so the client receives
|
||||
the full title.
|
||||
"""
|
||||
def page_title_string(assigns) do
|
||||
club = assigns[:club_name]
|
||||
page = assigns[:content_title] || assigns[:page_title]
|
||||
|
||||
parts =
|
||||
[page, club]
|
||||
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|
||||
|
||||
if parts == [] do
|
||||
"Mila"
|
||||
else
|
||||
"Mila · " <> Enum.join(parts, " · ")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Assigns content_title (short label for heading; same gettext as sidebar) and
|
||||
page_title (full browser tab title). Call from LiveView mount after club_name
|
||||
is set (e.g. from on_mount). Returns the socket.
|
||||
"""
|
||||
def assign_page_title(socket, content_title) do
|
||||
socket = assign(socket, :content_title, content_title)
|
||||
assign(socket, :page_title, page_title_string(socket.assigns))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
|
||||
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
|
||||
share the same chrome without the sidebar or authenticated layout logic.
|
||||
|
||||
Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component.
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :club_name, :string,
|
||||
default: nil,
|
||||
doc: "optional; if set, avoids get_settings() in the component"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def public_page(assigns) do
|
||||
club_name =
|
||||
assigns[:club_name] ||
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
~H"""
|
||||
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
|
||||
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
|
||||
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
||||
<span class="text-lg font-bold truncate">Mitgliederverwaltung</span>
|
||||
</div>
|
||||
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
|
||||
{@club_name}
|
||||
</span>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<form method="post" action={~p"/set_locale"}>
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<.theme_swap />
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-4 py-8 sm:px-6">
|
||||
<div class="mx-auto max-full space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the app layout. Can be used with or without a current_user.
|
||||
When current_user is present, it will show the navigation bar.
|
||||
|
|
@ -43,8 +135,19 @@ defmodule MvWeb.Layouts do
|
|||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
club_name = get_club_name()
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
# Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query.
|
||||
%{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||
|
||||
# TODO: unprocessed count runs on every page load when join form enabled; consider
|
||||
# loading only on navigation or caching briefly if performance becomes an issue.
|
||||
unprocessed_join_requests_count =
|
||||
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:club_name, club_name)
|
||||
|> assign(:join_form_enabled, join_form_enabled)
|
||||
|> assign(:unprocessed_join_requests_count, unprocessed_join_requests_count)
|
||||
|
||||
~H"""
|
||||
<%= if @current_user do %>
|
||||
|
|
@ -54,7 +157,7 @@ defmodule MvWeb.Layouts do
|
|||
data-sidebar-expanded="true"
|
||||
phx-hook="SidebarState"
|
||||
>
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" phx-update="ignore" />
|
||||
|
||||
<div class="drawer-content flex flex-col relative z-0">
|
||||
<!-- Mobile Header (only visible on mobile) -->
|
||||
|
|
@ -78,11 +181,41 @@ defmodule MvWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<div class="drawer-side z-40">
|
||||
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
|
||||
<.sidebar
|
||||
current_user={@current_user}
|
||||
club_name={@club_name}
|
||||
join_form_enabled={@join_form_enabled}
|
||||
unprocessed_join_requests_count={@unprocessed_join_requests_count}
|
||||
mobile={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Not logged in -->
|
||||
<!-- Unauthenticated: Option 3 header (logo + app name left, club name center, language selector right) -->
|
||||
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
|
||||
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
|
||||
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
||||
<span class="menu-label text-lg font-bold truncate">Mitgliederverwaltung</span>
|
||||
</div>
|
||||
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
|
||||
{@club_name}
|
||||
</span>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<form method="post" action={~p"/set_locale"}>
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<.theme_swap />
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-4 py-8 sm:px-6">
|
||||
<div class="mx-auto space-y-4 max-full">
|
||||
{render_slot(@inner_block)}
|
||||
|
|
@ -94,15 +227,27 @@ defmodule MvWeb.Layouts do
|
|||
"""
|
||||
end
|
||||
|
||||
# Helper function to get club name from settings
|
||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
||||
defp get_club_name do
|
||||
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
|
||||
defp get_layout_settings do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
{:ok, settings} ->
|
||||
%{
|
||||
club_name: settings.club_name || "Mitgliederverwaltung",
|
||||
join_form_enabled: settings.join_form_enabled == true
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_unprocessed_join_requests_count(nil, _), do: 0
|
||||
defp get_unprocessed_join_requests_count(_user, false), do: 0
|
||||
|
||||
defp get_unprocessed_join_requests_count(user, true) do
|
||||
Mv.Membership.count_submitted_join_requests(actor: user)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
|
|
@ -115,8 +260,12 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
|
||||
<.flash kind={:success} flash={@flash} />
|
||||
<div
|
||||
id={@id}
|
||||
aria-live="polite"
|
||||
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
||||
>
|
||||
<.flash kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||
<.flash kind={:warning} flash={@flash} />
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
|
|
|||
|
|
@ -7,32 +7,106 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
|
||||
<.live_title default="Mv" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
<.live_title default="Mila">
|
||||
{page_title_string(assigns)}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const systemTheme = () => (mq.matches ? "dark" : "light");
|
||||
|
||||
// Single source of truth:
|
||||
// - localStorage["phx:theme"] = "light" | "dark" (explicit override)
|
||||
// - missing key => "system"
|
||||
const storedTheme = () => localStorage.getItem("phx:theme") || "system";
|
||||
|
||||
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
|
||||
|
||||
const applyThemeNow = (t) => {
|
||||
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
const syncToggle = () => {
|
||||
const eff = effectiveTheme(storedTheme());
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
|
||||
el.checked = eff === "dark";
|
||||
});
|
||||
};
|
||||
|
||||
const setTheme = (t) => {
|
||||
if (t === "system") localStorage.removeItem("phx:theme");
|
||||
else localStorage.setItem("phx:theme", t);
|
||||
|
||||
applyThemeNow(t);
|
||||
syncToggle(); // if toggle exists already
|
||||
};
|
||||
|
||||
// 1) Apply theme ASAP to match system on first paint
|
||||
applyThemeNow(storedTheme());
|
||||
|
||||
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
|
||||
document.addEventListener("DOMContentLoaded", syncToggle);
|
||||
|
||||
// 3) If toggle appears later (LiveView render), sync immediately
|
||||
const obs = new MutationObserver(() => {
|
||||
if (document.querySelector("[data-theme-toggle]")) syncToggle();
|
||||
});
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
mq.addEventListener("change", () => {
|
||||
if (localStorage.getItem("phx:theme") === null) {
|
||||
applyThemeNow("system");
|
||||
syncToggle();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="flash-group-root"
|
||||
aria-live="polite"
|
||||
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
||||
>
|
||||
<.flash id="flash-success-root" kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error-root"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={
|
||||
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
|
||||
}
|
||||
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error-root"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={
|
||||
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
|
||||
}
|
||||
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
|
||||
attr :current_user, :map, default: nil, doc: "The current user"
|
||||
attr :club_name, :string, required: true, doc: "The name of the club"
|
||||
|
||||
attr :join_form_enabled, :boolean,
|
||||
default: false,
|
||||
doc: "Whether the public join form is enabled in settings"
|
||||
|
||||
attr :unprocessed_join_requests_count, :integer,
|
||||
default: 0,
|
||||
doc: "Count of submitted (unprocessed) join requests for sidebar indicator"
|
||||
|
||||
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
||||
|
||||
def sidebar(assigns) do
|
||||
|
|
@ -80,11 +89,11 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_item
|
||||
href={~p"/membership_fee_types"}
|
||||
icon="hero-currency-euro"
|
||||
label={gettext("Fee Types")}
|
||||
href={~p"/groups"}
|
||||
icon="hero-user-group"
|
||||
label={gettext("Groups")}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
|
|
@ -96,30 +105,41 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if @join_form_enabled and can_access_page?(@current_user, PagePaths.join_requests()) do %>
|
||||
<.menu_item
|
||||
href={~p"/join_requests"}
|
||||
icon="hero-inbox-arrow-down"
|
||||
label={gettext("Join requests")}
|
||||
indicator_dot={@unprocessed_join_requests_count > 0}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if admin_menu_visible?(@current_user) do %>
|
||||
<.menu_group
|
||||
icon="hero-cog-6-tooth"
|
||||
label={gettext("Administration")}
|
||||
testid="sidebar-administration"
|
||||
>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Basic settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_datafields()) do %>
|
||||
<.menu_subitem href={~p"/admin/datafields"} label={gettext("Datafields")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
|
||||
<.menu_subitem
|
||||
href={~p"/membership_fee_settings"}
|
||||
label={gettext("Fee Settings")}
|
||||
label={gettext("Membership fee settings")}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_import()) do %>
|
||||
<.menu_subitem href={~p"/admin/import"} label={gettext("Import")} />
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<% end %>
|
||||
</.menu_group>
|
||||
<% end %>
|
||||
|
|
@ -135,6 +155,10 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
attr :icon, :string, required: true, doc: "Heroicon name"
|
||||
attr :label, :string, required: true, doc: "Menu item label"
|
||||
|
||||
attr :indicator_dot, :boolean,
|
||||
default: false,
|
||||
doc: "Show a small dot on the icon (e.g. for unprocessed items)"
|
||||
|
||||
defp menu_item(assigns) do
|
||||
~H"""
|
||||
<li role="none">
|
||||
|
|
@ -144,7 +168,16 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
data-tip={@label}
|
||||
role="menuitem"
|
||||
>
|
||||
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||
<span class="relative shrink-0">
|
||||
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||
<%= if @indicator_dot do %>
|
||||
<span
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="menu-label">{@label}</span>
|
||||
</.link>
|
||||
</li>
|
||||
|
|
@ -218,21 +251,22 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
defp sidebar_footer(assigns) do
|
||||
~H"""
|
||||
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
|
||||
<!-- Language Selector (nur expanded) -->
|
||||
<form method="post" action={~p"/set_locale"} class="expanded-only">
|
||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Theme Toggle (immer sichtbar) -->
|
||||
<.theme_toggle />
|
||||
<!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<.theme_swap />
|
||||
<form method="post" action={~p"/set_locale"} class="expanded-only flex-1 min-w-0">
|
||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<!-- User Menu (nur wenn current_user existiert) -->
|
||||
<%= if @current_user do %>
|
||||
<.user_menu current_user={@current_user} />
|
||||
|
|
@ -241,24 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
"""
|
||||
end
|
||||
|
||||
defp theme_toggle(assigns) do
|
||||
~H"""
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
>
|
||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle toggle-sm theme-controller focus:outline-none"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :current_user, :map, default: nil, doc: "The current user"
|
||||
|
||||
defp user_menu(assigns) do
|
||||
|
|
|
|||
|
|
@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
|
|||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Config
|
||||
|
||||
def success(conn, activity, user, _token) do
|
||||
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
||||
if Config.oidc_only?() do
|
||||
conn
|
||||
|> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
else
|
||||
success_continue(conn, {:password, :sign_in}, user, token)
|
||||
end
|
||||
end
|
||||
|
||||
def success(conn, activity, user, token) do
|
||||
success_continue(conn, activity, user, token)
|
||||
end
|
||||
|
||||
defp success_continue(conn, activity, user, _token) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
message =
|
||||
|
|
@ -31,7 +46,7 @@ defmodule MvWeb.AuthController do
|
|||
|> store_in_session(user)
|
||||
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
||||
|> assign(:current_user, user)
|
||||
|> put_flash(:info, message)
|
||||
|> put_flash(:success, message)
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
|
|
@ -45,28 +60,86 @@ defmodule MvWeb.AuthController do
|
|||
- Generic authentication failures
|
||||
"""
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
log_failure_safely(activity, reason)
|
||||
|
||||
case {activity, reason} do
|
||||
{{:rauthy, _action}, reason} ->
|
||||
handle_rauthy_failure(conn, reason)
|
||||
{{:oidc, _action}, reason} ->
|
||||
handle_oidc_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"))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Incorrect email or password"))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all Rauthy (OIDC) authentication failures
|
||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
# Log authentication failures safely, avoiding sensitive data for {:oidc, _} activities
|
||||
defp log_failure_safely({:oidc, _action} = activity, reason) do
|
||||
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
|
||||
case reason do
|
||||
%Assent.ServerUnreachableError{} = err ->
|
||||
meta = safe_assent_meta(err)
|
||||
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||
Logger.warning(message)
|
||||
|
||||
%Assent.InvalidResponseError{} = err ->
|
||||
meta = safe_assent_meta(err)
|
||||
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||
Logger.warning(message)
|
||||
|
||||
_ ->
|
||||
# For other OIDC errors, log only error type, not full details
|
||||
error_type = get_error_type(reason)
|
||||
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp log_failure_safely(activity, reason) do
|
||||
# For non-OIDC activities, safe to log full reason
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
# Extract safe error type identifier without sensitive data
|
||||
defp get_error_type(%struct{}), do: "#{struct}"
|
||||
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
|
||||
defp get_error_type(_other), do: "[redacted]"
|
||||
|
||||
# Format safe log message with metadata included in the message string
|
||||
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
|
||||
activity_str = "Activity: #{inspect(activity)}"
|
||||
meta_str = format_meta_string(meta)
|
||||
"#{base_message} - #{activity_str}#{meta_str}"
|
||||
end
|
||||
|
||||
defp format_meta_string([]), do: ""
|
||||
|
||||
defp format_meta_string(meta) when is_list(meta) do
|
||||
parts =
|
||||
Enum.map(meta, fn
|
||||
{:request_url, url} -> "Request URL: #{url}"
|
||||
{:status, status} -> "Status: #{status}"
|
||||
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
|
||||
_ -> nil
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
|
||||
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
|
||||
end
|
||||
|
||||
# Handle all OIDC authentication failures
|
||||
defp handle_oidc_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
end
|
||||
|
||||
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
defp handle_oidc_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: caused_by
|
||||
}) do
|
||||
case caused_by do
|
||||
|
|
@ -74,14 +147,46 @@ defmodule MvWeb.AuthController do
|
|||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
end
|
||||
end
|
||||
|
||||
# Handle Assent server unreachable errors (network/connectivity issues)
|
||||
defp handle_oidc_failure(conn, %Assent.ServerUnreachableError{} = _err) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("The authentication server is currently unavailable. Please try again later.")
|
||||
)
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
end
|
||||
|
||||
# Handle Assent invalid response errors (configuration or malformed responses)
|
||||
defp handle_oidc_failure(conn, %Assent.InvalidResponseError{} = _err) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Authentication configuration error. Please contact the administrator.")
|
||||
)
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
end
|
||||
|
||||
# Catch-all clause for any other error types
|
||||
defp handle_rauthy_failure(conn, reason) do
|
||||
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
defp handle_oidc_failure(conn, _reason) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
|
|
@ -93,14 +198,20 @@ defmodule MvWeb.AuthController do
|
|||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
redirect_with_error(conn, message)
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
else
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_authentication_failed(conn, _other) do
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||
|
|
@ -112,10 +223,17 @@ defmodule MvWeb.AuthController do
|
|||
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)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, error_message)
|
||||
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||
end
|
||||
end
|
||||
|
||||
# Path used when redirecting to sign-in after an OIDC failure. The query param tells
|
||||
# OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
|
||||
defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
|
||||
|
||||
# Extract meaningful error message from Ash errors
|
||||
defp extract_meaningful_error_message(errors) do
|
||||
# Look for specific error messages in InvalidAttribute errors
|
||||
|
|
@ -177,19 +295,53 @@ defmodule MvWeb.AuthController do
|
|||
|> 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")
|
||||
# Extract safe metadata from Assent errors for logging
|
||||
# Never logs sensitive data: no tokens, secrets, or full request URLs
|
||||
# Returns keyword list for Logger.warning/2
|
||||
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|
||||
[
|
||||
request_url: redact_url(url),
|
||||
http_adapter: Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
|
||||
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
|
||||
[
|
||||
status: status,
|
||||
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
defp safe_assent_meta(err) do
|
||||
# Only extract safe, simple fields
|
||||
[
|
||||
http_adapter: Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
# Redact URL to only show scheme and host, hiding path, query, and fragments
|
||||
defp redact_url(url) when is_binary(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
|
||||
"#{scheme}://#{host}"
|
||||
|
||||
_ ->
|
||||
"[redacted]"
|
||||
end
|
||||
end
|
||||
|
||||
defp redact_url(_), do: "[redacted]"
|
||||
|
||||
def sign_out(conn, _params) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
conn
|
||||
|> clear_session(:mv)
|
||||
|> put_flash(:info, gettext("You are now signed out"))
|
||||
|> put_flash(:success, gettext("You are now signed out"))
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
64
lib/mv_web/controllers/join_confirm_controller.ex
Normal file
64
lib/mv_web/controllers/join_confirm_controller.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.JoinConfirmController do
|
||||
@moduledoc """
|
||||
Handles GET /confirm_join/:token for the public join flow (double opt-in).
|
||||
|
||||
Renders a full HTML page with public header and hero layout (success, expired,
|
||||
or invalid). Calls a configurable callback (default Mv.Membership) so tests can
|
||||
stub the dependency. Public route; no authentication required.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
def confirm(conn, %{"token" => token}) when is_binary(token) do
|
||||
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
|
||||
|
||||
case callback.confirm_join_request(token, actor: nil) do
|
||||
{:ok, _request} ->
|
||||
success_response(conn)
|
||||
|
||||
{:error, :token_expired} ->
|
||||
expired_response(conn)
|
||||
|
||||
{:error, _} ->
|
||||
invalid_response(conn)
|
||||
end
|
||||
end
|
||||
|
||||
def confirm(conn, _params), do: invalid_response(conn)
|
||||
|
||||
defp success_response(conn) do
|
||||
conn
|
||||
|> assign_confirm_assigns(:success)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
end
|
||||
|
||||
defp expired_response(conn) do
|
||||
conn
|
||||
|> assign_confirm_assigns(:expired)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
end
|
||||
|
||||
defp invalid_response(conn) do
|
||||
conn
|
||||
|> put_status(404)
|
||||
|> assign_confirm_assigns(:invalid)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
end
|
||||
|
||||
defp assign_confirm_assigns(conn, result) do
|
||||
page_title = page_title_for_result(result)
|
||||
|
||||
conn
|
||||
|> assign(:result, result)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
|
||||
end
|
||||
|
||||
defp page_title_for_result(:success), do: gettext("Join confirmation")
|
||||
defp page_title_for_result(:expired), do: gettext("Link expired")
|
||||
defp page_title_for_result(:invalid), do: gettext("Invalid link")
|
||||
end
|
||||
9
lib/mv_web/controllers/join_confirm_html.ex
Normal file
9
lib/mv_web/controllers/join_confirm_html.ex
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
defmodule MvWeb.JoinConfirmHTML do
|
||||
@moduledoc """
|
||||
Renders join confirmation result pages (success, expired, invalid) with
|
||||
public header and hero layout. Used by JoinConfirmController.
|
||||
"""
|
||||
use MvWeb, :html
|
||||
|
||||
embed_templates "join_confirm_html/*"
|
||||
end
|
||||
45
lib/mv_web/controllers/join_confirm_html/confirm.html.heex
Normal file
45
lib/mv_web/controllers/join_confirm_html/confirm.html.heex
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<Layouts.public_page flash={@flash}>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
|
||||
<div class="hero-content flex-col items-start text-left">
|
||||
<div class="max-w-md">
|
||||
<%= case @result do %>
|
||||
<% :success -> %>
|
||||
<h1 class="text-3xl font-bold">
|
||||
{gettext("Thank you")}
|
||||
</h1>
|
||||
<p class="py-4 text-base-content/80">
|
||||
{gettext("Thank you, we have received your request.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70">
|
||||
{gettext("You will receive an email once your application has been reviewed.")}
|
||||
</p>
|
||||
<a href={~p"/join"} class="btn btn-primary mt-4">
|
||||
{gettext("Back to join form")}
|
||||
</a>
|
||||
<% :expired -> %>
|
||||
<h1 class="text-3xl font-bold">
|
||||
{gettext("Link expired")}
|
||||
</h1>
|
||||
<p class="py-4 text-base-content/80">
|
||||
{gettext("This link has expired. Please submit the form again.")}
|
||||
</p>
|
||||
<a href={~p"/join"} class="btn btn-primary mt-4">
|
||||
{gettext("Submit new request")}
|
||||
</a>
|
||||
<% :invalid -> %>
|
||||
<h1 class="text-3xl font-bold text-error">
|
||||
{gettext("Invalid or expired link")}
|
||||
</h1>
|
||||
<p class="py-4 text-base-content/80">
|
||||
{gettext("Invalid or expired link.")}
|
||||
</p>
|
||||
<a href={~p"/join"} class="btn btn-primary mt-4">
|
||||
{gettext("Go to join form")}
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.public_page>
|
||||
|
|
@ -13,12 +13,14 @@ defmodule MvWeb.MemberExportController do
|
|||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MemberExport
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.Translations.MemberFields
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
alias MvWeb.Translations.MemberFields
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_type", "groups"]
|
||||
@computed_export_fields ["membership_fee_status"]
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
|
|
@ -64,6 +66,9 @@ defmodule MvWeb.MemberExportController do
|
|||
defp parse_and_validate(params) do
|
||||
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
||||
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
||||
custom_field_ids = filter_valid_uuids(extract_list(params, "custom_field_ids"))
|
||||
boolean_filters = extract_boolean_filters(params)
|
||||
custom_field_ids_union = (custom_field_ids ++ Map.keys(boolean_filters)) |> Enum.uniq()
|
||||
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
|
|
@ -71,18 +76,56 @@ defmodule MvWeb.MemberExportController do
|
|||
selectable_member_fields: selectable_member_fields,
|
||||
computed_fields:
|
||||
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
|
||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||
custom_field_ids: custom_field_ids,
|
||||
custom_field_ids_union: custom_field_ids_union,
|
||||
query: extract_string(params, "query"),
|
||||
sort_field: extract_string(params, "sort_field"),
|
||||
sort_order: extract_sort_order(params),
|
||||
show_current_cycle: extract_boolean(params, "show_current_cycle")
|
||||
show_current_cycle: extract_boolean(params, "show_current_cycle"),
|
||||
cycle_status_filter: extract_cycle_status_filter(params),
|
||||
boolean_filters: boolean_filters
|
||||
}
|
||||
end
|
||||
|
||||
# Only paid and unpaid are supported for list/export filter. :suspended exists in the
|
||||
# domain (e.g. membership fee status display) but is not used as a filter in the member index.
|
||||
defp extract_cycle_status_filter(params) do
|
||||
case Map.get(params, "cycle_status_filter") do
|
||||
"paid" -> :paid
|
||||
"unpaid" -> :unpaid
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Normalizes values so that "true"/"false" from query/form encoding are accepted as well as JSON booleans.
|
||||
defp extract_boolean_filters(params) do
|
||||
case Map.get(params, "boolean_filters") do
|
||||
map when is_map(map) ->
|
||||
map
|
||||
|> Enum.filter(fn {k, v} ->
|
||||
is_binary(k) and match?({:ok, _}, Ecto.UUID.cast(k)) and boolean_value?(v)
|
||||
end)
|
||||
|> Enum.map(fn {k, v} -> {k, normalize_boolean_value(v)} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp boolean_value?(v) when is_boolean(v), do: true
|
||||
defp boolean_value?(v) when v in ["true", "false"], do: true
|
||||
defp boolean_value?(_), do: false
|
||||
|
||||
defp normalize_boolean_value(v) when is_boolean(v), do: v
|
||||
defp normalize_boolean_value("true"), do: true
|
||||
defp normalize_boolean_value("false"), do: false
|
||||
|
||||
defp split_member_fields(member_fields) do
|
||||
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
||||
# "groups" is neither a domain field nor a computed field, it's handled separately
|
||||
{selectable, computed}
|
||||
end
|
||||
|
||||
|
|
@ -103,12 +146,10 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
|
||||
defp atom_exists?(name) do
|
||||
try do
|
||||
_ = String.to_existing_atom(name)
|
||||
true
|
||||
rescue
|
||||
ArgumentError -> false
|
||||
end
|
||||
_ = String.to_existing_atom(name)
|
||||
true
|
||||
rescue
|
||||
ArgumentError -> false
|
||||
end
|
||||
|
||||
defp extract_list(params, key) do
|
||||
|
|
@ -156,7 +197,8 @@ defmodule MvWeb.MemberExportController do
|
|||
parsed
|
||||
|> ensure_sort_custom_field_loaded()
|
||||
|
||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
|
||||
with {:ok, custom_fields_by_id} <-
|
||||
load_custom_fields_by_id(parsed.custom_field_ids_union, actor),
|
||||
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||
columns = build_columns(conn, parsed, custom_fields_by_id)
|
||||
csv_iodata = MembersCSV.export(members, columns)
|
||||
|
|
@ -174,13 +216,19 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
|
||||
defp ensure_sort_custom_field_loaded(
|
||||
%{custom_field_ids: ids, custom_field_ids_union: union, sort_field: sort_field} = parsed
|
||||
) do
|
||||
case extract_sort_custom_field_id(sort_field) do
|
||||
nil ->
|
||||
parsed
|
||||
|
||||
id ->
|
||||
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
|
||||
%{
|
||||
parsed
|
||||
| custom_field_ids: Enum.uniq([id | ids]),
|
||||
custom_field_ids_union: Enum.uniq([id | union])
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -233,14 +281,23 @@ defmodule MvWeb.MemberExportController do
|
|||
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
||||
|
||||
need_cycles =
|
||||
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
|
||||
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
|
||||
parsed.cycle_status_filter != nil
|
||||
|
||||
need_groups = "groups" in parsed.member_fields
|
||||
|
||||
need_membership_fee_type =
|
||||
"membership_fee_type" in parsed.member_fields or
|
||||
parsed.sort_field == "membership_fee_type"
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||
|> maybe_load_groups(need_groups)
|
||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
|
|
@ -268,6 +325,10 @@ defmodule MvWeb.MemberExportController do
|
|||
members
|
||||
end
|
||||
|
||||
# When exporting "all" (no selected_ids), apply same filters as PDF: cycle status and boolean custom fields
|
||||
members =
|
||||
MemberExport.apply_export_filters(members, parsed, custom_fields_by_id)
|
||||
|
||||
# Calculate membership_fee_status for computed fields
|
||||
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
|
||||
|
||||
|
|
@ -284,6 +345,19 @@ defmodule MvWeb.MemberExportController do
|
|||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp maybe_load_groups(query, false), do: query
|
||||
|
||||
defp maybe_load_groups(query, true) do
|
||||
# Load groups with id and name only (for export formatting)
|
||||
Ash.Query.load(query, groups: [:id, :name])
|
||||
end
|
||||
|
||||
defp maybe_load_membership_fee_type(query, false), do: query
|
||||
|
||||
defp maybe_load_membership_fee_type(query, true) do
|
||||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||
end
|
||||
|
||||
# Adds computed field values to members (e.g. membership_fee_status)
|
||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||
if "membership_fee_status" in computed_fields do
|
||||
|
|
@ -329,22 +403,47 @@ defmodule MvWeb.MemberExportController do
|
|||
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
||||
|
||||
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||
if custom_field_sort?(field) do
|
||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
||||
{query, true}
|
||||
else
|
||||
field_atom = String.to_existing_atom(field)
|
||||
cond do
|
||||
field == "groups" ->
|
||||
{query, true}
|
||||
|
||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
field == "membership_fee_type" ->
|
||||
apply_membership_fee_type_sort_export(query, order)
|
||||
|
||||
custom_field_sort?(field) ->
|
||||
{query, true}
|
||||
|
||||
true ->
|
||||
apply_member_field_sort_export(query, field, order)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp apply_membership_fee_type_sort_export(query, order) do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||
end
|
||||
|
||||
defp apply_member_field_sort_export(query, field, order) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
sortable =
|
||||
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||
field_atom == :membership_fee_type
|
||||
|
||||
if sortable do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
|
||||
sort_field =
|
||||
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
|
||||
|
||||
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -358,6 +457,15 @@ defmodule MvWeb.MemberExportController do
|
|||
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
||||
when is_binary(field) do
|
||||
order = order || "asc"
|
||||
|
||||
if field == "groups" do
|
||||
sort_members_by_groups_export(members, order)
|
||||
else
|
||||
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_by_custom_field_value(members, field, order, custom_fields) do
|
||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||
|
||||
custom_field =
|
||||
|
|
@ -387,6 +495,26 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
end
|
||||
|
||||
defp sort_members_by_groups_export(members, order) do
|
||||
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||
first_group_name = fn member ->
|
||||
(member.groups || [])
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.min(fn -> nil end)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.sort_by(fn member ->
|
||||
name = first_group_name.(member)
|
||||
# Nil (no groups) sorts last in asc, first in desc
|
||||
{name == nil, name || ""}
|
||||
end)
|
||||
|> then(fn list ->
|
||||
if order == "desc", do: Enum.reverse(list), else: list
|
||||
end)
|
||||
end
|
||||
|
||||
defp has_non_empty_custom_field_value?(member, custom_field) do
|
||||
case find_cfv(member, custom_field) do
|
||||
nil ->
|
||||
|
|
@ -441,6 +569,32 @@ defmodule MvWeb.MemberExportController do
|
|||
}
|
||||
end)
|
||||
|
||||
membership_fee_type_col =
|
||||
if "membership_fee_type" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
header: membership_fee_type_field_header(conn),
|
||||
kind: :membership_fee_type,
|
||||
key: :membership_fee_type
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
groups_col =
|
||||
if "groups" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
header: groups_field_header(conn),
|
||||
kind: :groups,
|
||||
key: :groups
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
custom_cols =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id ->
|
||||
|
|
@ -459,7 +613,8 @@ defmodule MvWeb.MemberExportController do
|
|||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||
end
|
||||
|
||||
# --- headers: use MemberFields.label for translations ---
|
||||
|
|
@ -499,6 +654,14 @@ defmodule MvWeb.MemberExportController do
|
|||
cf.name
|
||||
end
|
||||
|
||||
defp membership_fee_type_field_header(_conn) do
|
||||
MemberFields.label(:membership_fee_type)
|
||||
end
|
||||
|
||||
defp groups_field_header(_conn) do
|
||||
MemberFields.label(:groups)
|
||||
end
|
||||
|
||||
defp humanize_field(str) do
|
||||
str
|
||||
|> String.replace("_", " ")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do
|
|||
@invalid_json_message "invalid JSON"
|
||||
@export_failed_message "Failed to generate PDF export"
|
||||
|
||||
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_type", "groups"]
|
||||
|
||||
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
||||
actor = current_actor(conn)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ defmodule MvWeb.PageController do
|
|||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
conn
|
||||
|> assign(:page_title, gettext("Home"))
|
||||
|> render(:home)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue