diff --git a/.credo.exs b/.credo.exs
index a94fead..4eddee8 100644
--- a/.credo.exs
+++ b/.credo.exs
@@ -82,14 +82,8 @@
# 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,
- files: %{excluded: ["test/"]}
- ]},
+ [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{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
@@ -114,7 +108,6 @@
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
- {Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
@@ -167,19 +160,13 @@
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []},
# Module documentation check (enabled after adding @moduledoc to all modules)
- {Credo.Check.Readability.ModuleDoc, []},
-
- # Promoted in the cleanup ratchet (each currently at zero violations):
- {Credo.Check.Refactor.DoubleBooleanNegation, []},
- {Credo.Check.Refactor.FilterReject, []},
- {Credo.Check.Refactor.RejectFilter, []},
- {Credo.Check.Refactor.UtcNowTruncate, []},
- {Credo.Check.Warning.LazyLogging, []},
- {Credo.Check.Warning.LeakyEnvironment, []},
- {Credo.Check.Warning.MapGetUnsafePass, []},
- {Credo.Check.Warning.MixEnv, []}
+ {Credo.Check.Readability.ModuleDoc, []}
],
disabled: [
+ #
+ # Checks scheduled for next check update (opt-in for now)
+ {Credo.Check.Refactor.UtcNowTruncate, []},
+
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
@@ -190,7 +177,6 @@
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
- # ImplTrue: ~269 violations; deferred to a follow-up.
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
@@ -200,20 +186,24 @@
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
+ {Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
- # AppendSingleItem: ~10 violations (mostly tests); deferred to a follow-up.
{Credo.Check.Refactor.AppendSingleItem, []},
- # IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
+ {Credo.Check.Refactor.DoubleBooleanNegation, []},
+ {Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
- # MapMap: ~8 violations; deferred to a follow-up.
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
- # NegatedIsNil: ~63 violations; deferred to a follow-up.
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
+ {Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
+ {Credo.Check.Warning.LazyLogging, []},
+ {Credo.Check.Warning.LeakyEnvironment, []},
+ {Credo.Check.Warning.MapGetUnsafePass, []},
+ {Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
diff --git a/.deps_audit_ignore b/.deps_audit_ignore
deleted file mode 100644
index 27c623d..0000000
--- a/.deps_audit_ignore
+++ /dev/null
@@ -1,9 +0,0 @@
-# Temporarily ignored security advisories
-#
-# Format: one GHSA ID per line.
-# Remove an entry once a patched version is available and the dependency is updated.
-
-# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1
-# Severity: low. No patched version available as of 2026-05-20.
-# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56
-GHSA-g2wm-735q-3f56
diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs
deleted file mode 100644
index c89978c..0000000
--- a/.dialyzer_ignore.exs
+++ /dev/null
@@ -1,11 +0,0 @@
-# Dialyzer ignore list.
-#
-# This file is for PROVEN false positives only. Each entry must carry a
-# `# why:` comment explaining why Dialyzer is wrong about the call site.
-# Real findings get fixed by adjusting @spec, return types, or pattern
-# matches — never silenced here.
-#
-# Format: each entry is either a path string, a {path, warning} tuple,
-# or a {path, warning, line} tuple. See:
-# https://hexdocs.pm/dialyxir/readme.html#elixir-format
-[]
diff --git a/.drone.jsonnet b/.drone.jsonnet
deleted file mode 100644
index 388e8f4..0000000
--- a/.drone.jsonnet
+++ /dev/null
@@ -1,184 +0,0 @@
-local elixir = 'docker.io/library/elixir:1.18.3-otp-27';
-local postgres_image = 'docker.io/library/postgres:18.3';
-
-local pg_service = {
- name: 'postgres',
- image: postgres_image,
- environment: {
- POSTGRES_USER: 'postgres',
- POSTGRES_PASSWORD: 'postgres',
- },
-};
-
-local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } };
-local cache_mount = [{ name: 'cache', path: '/cache' }];
-
-local step_compute_cache = {
- name: 'compute cache key',
- image: elixir,
- commands: [
- "mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)",
- 'echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key',
- // Print cache key for debugging
- 'cat .cache_key',
- ],
-};
-
-local step_restore_cache = {
- name: 'restore-cache',
- image: 'drillster/drone-volume-cache',
- settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 },
- volumes: cache_mount,
-};
-
-local step_lint = {
- name: 'lint',
- image: elixir,
- commands: [
- 'mix local.hex --force', // Install hex package manager
- 'mix deps.get', // Fetch dependencies
- 'mix compile --warnings-as-errors', // Check for compilation errors & warnings
- 'mix format --check-formatted', // Check formatting
- 'mix sobelow --config', // Security checks
- 'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
- 'mix hex.audit', // Unmaintained dependencies
- 'mix credo --strict', // Code quality hints
- 'mix gettext.extract --check-up-to-date', // Translations up to date
- ],
-};
-
-local step_typecheck = {
- name: 'typecheck',
- image: elixir,
- commands: [
- 'mix local.hex --force',
- 'mix deps.get',
- 'mkdir -p priv/plts',
- // Build/refresh PLT — no-op on cache hit, full build (5-15 min) on cache miss.
- 'mix dialyzer --plt',
- // Actual typecheck. --format short keeps log noise down on red builds.
- 'mix dialyzer --format short',
- ],
-};
-
-local step_wait_postgres = {
- name: 'wait_for_postgres',
- image: postgres_image,
- commands: [
- |||
- for i in {1..20}; do
- if pg_isready -h postgres -U postgres; then
- exit 0
- else
- true
- fi
- sleep 2
- done
- echo "Postgres did not become available, aborting."
- exit 1
- |||,
- ],
-};
-
-local step_rebuild_cache = {
- name: 'rebuild-cache',
- image: 'drillster/drone-volume-cache',
- settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] },
- volumes: cache_mount,
-};
-
-// test_cmd is the only thing that differs between the fast and full suites.
-local test_step(name, test_cmd) = {
- name: name,
- image: elixir,
- environment: {
- MIX_ENV: 'test',
- TEST_POSTGRES_HOST: 'postgres',
- TEST_POSTGRES_PORT: '5432',
- },
- commands: ['mix local.hex --force', 'mix deps.get', test_cmd],
-};
-
-local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2');
-local test_all = test_step('test-all', 'mix test');
-
-// A full check pipeline: identical steps, only name + trigger + test step vary.
-local check_pipeline(name, trigger, test) = {
- kind: 'pipeline',
- type: 'docker',
- name: name,
- services: [pg_service],
- trigger: trigger,
- steps: [
- step_compute_cache,
- step_restore_cache,
- step_lint,
- ] + (if test.name == 'test-all' then [step_typecheck] else []) + [
- step_wait_postgres,
- test,
- step_rebuild_cache,
- ],
- volumes: [cache_volume],
-};
-
-local docker_publish(name, extra_settings, trigger_event, deps) = {
- kind: 'pipeline',
- type: 'docker',
- name: name,
- trigger: trigger_event,
- steps: [{
- name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''),
- 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' },
- } + extra_settings,
- when: trigger_event,
- }],
- depends_on: deps,
-};
-
-[
- check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast),
- check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all),
- check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all),
- check_pipeline('check-full-tag', { event: ['tag'] }, test_all),
-
- docker_publish(
- 'build-and-publish',
- { tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] },
- { branch: ['main'], event: ['push'] },
- ['check-full'],
- ),
- docker_publish(
- 'build-and-release',
- { auto_tag: true },
- { event: ['tag'] },
- ['check-full-tag'],
- ),
-
- {
- kind: 'pipeline',
- type: 'docker',
- name: 'renovate',
- trigger: { event: ['cron', 'custom'], branch: ['main'] },
- environment: { LOG_LEVEL: 'debug' },
- steps: [{
- name: 'renovate',
- image: 'renovate/renovate:43.165',
- environment: {
- RENOVATE_CONFIG_FILE: 'renovate_backend_config.js',
- RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' },
- GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' },
- },
- commands: [
- // https://github.com/renovatebot/renovate/discussions/15049
- 'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL',
- 'renovate-config-validator',
- 'renovate',
- ],
- }],
- },
-]
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..bb5ec1c
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,291 @@
+kind: pipeline
+type: docker
+name: check-fast
+
+services:
+ - name: postgres
+ image: docker.io/library/postgres:18.1
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+
+trigger:
+ event:
+ - push
+
+steps:
+ - name: compute cache key
+ image: docker.io/library/elixir:1.18.3-otp-27
+ commands:
+ - mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
+ - echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
+ # Print cache key for debugging
+ - cat .cache_key
+
+ - name: restore-cache
+ image: drillster/drone-volume-cache
+ settings:
+ restore: true
+ mount:
+ - ./deps
+ - ./_build
+ ttl: 30
+ volumes:
+ - name: cache
+ path: /cache
+
+ - 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
+ # Fetch dependencies
+ - timeout --signal=KILL 10m mix deps.get
+ # Check for compilation errors & warnings
+ - timeout --signal=KILL 10m mix compile --warnings-as-errors
+ # Check formatting
+ - timeout --signal=KILL 3m mix format --check-formatted
+ # Security checks
+ - timeout --signal=KILL 10m mix sobelow --config
+ # Check dependencies for known vulnerabilities
+ - timeout --signal=KILL 10m mix deps.audit
+ # Check for dependencies that are not maintained anymore
+ - timeout --signal=KILL 10m mix hex.audit
+ # Provide hints for improving code quality
+ - timeout --signal=KILL 15m mix credo
+ # Check that translations are up to date
+ - timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
+
+ - name: wait_for_postgres
+ image: docker.io/library/postgres:18.1
+ commands:
+ # Wait for postgres to become available
+ - |
+ for i in {1..20}; do
+ if pg_isready -h postgres -U postgres; then
+ exit 0
+ else
+ true
+ fi
+ sleep 2
+ done
+ echo "Postgres did not become available, aborting."
+ exit 1
+
+ - name: test-fast
+ image: docker.io/library/elixir:1.18.3-otp-27
+ environment:
+ MIX_ENV: test
+ 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
+ # Run fast tests (excludes slow/performance and UI tests)
+ - timeout --signal=KILL 20m mix test --exclude slow --exclude ui
+
+ - name: rebuild-cache
+ image: drillster/drone-volume-cache
+ settings:
+ rebuild: true
+ mount:
+ - ./deps
+ - ./_build
+ volumes:
+ - name: cache
+ path: /cache
+
+volumes:
+ - name: cache
+ host:
+ path: /tmp/drone_cache
+
+---
+kind: pipeline
+type: docker
+name: check-full
+
+services:
+ - name: postgres
+ image: docker.io/library/postgres:18.1
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+
+trigger:
+ event:
+ - promote
+ target:
+ - production
+
+steps:
+ - name: compute cache key
+ image: docker.io/library/elixir:1.18.3-otp-27
+ commands:
+ - mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
+ - echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
+ # Print cache key for debugging
+ - cat .cache_key
+
+ - name: restore-cache
+ image: drillster/drone-volume-cache
+ settings:
+ restore: true
+ mount:
+ - ./deps
+ - ./_build
+ ttl: 30
+ volumes:
+ - name: cache
+ path: /cache
+
+ - 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
+ # Fetch dependencies
+ - timeout --signal=KILL 10m mix deps.get
+ # Check for compilation errors & warnings
+ - timeout --signal=KILL 10m mix compile --warnings-as-errors
+ # Check formatting
+ - timeout --signal=KILL 3m mix format --check-formatted
+ # Security checks
+ - timeout --signal=KILL 10m mix sobelow --config
+ # Check dependencies for known vulnerabilities
+ - timeout --signal=KILL 10m mix deps.audit
+ # Check for dependencies that are not maintained anymore
+ - timeout --signal=KILL 10m mix hex.audit
+ # Provide hints for improving code quality
+ - timeout --signal=KILL 15m mix credo
+ # Check that translations are up to date
+ - timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
+
+ - name: wait_for_postgres
+ image: docker.io/library/postgres:18.1
+ commands:
+ # Wait for postgres to become available
+ - |
+ for i in {1..20}; do
+ if pg_isready -h postgres -U postgres; then
+ exit 0
+ else
+ true
+ fi
+ sleep 2
+ done
+ echo "Postgres did not become available, aborting."
+ exit 1
+
+ - name: test-all
+ image: docker.io/library/elixir:1.18.3-otp-27
+ environment:
+ MIX_ENV: test
+ 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
+ # Run all tests (including slow/performance and UI tests)
+ - timeout --signal=KILL 30m mix test
+
+ - name: rebuild-cache
+ image: drillster/drone-volume-cache
+ settings:
+ rebuild: true
+ mount:
+ - ./deps
+ - ./_build
+ volumes:
+ - name: cache
+ path: /cache
+
+volumes:
+ - name: cache
+ host:
+ path: /tmp/drone_cache
+
+---
+kind: pipeline
+type: docker
+name: build-and-publish
+
+trigger:
+ branch:
+ - main
+ event:
+ - push
+ - tag
+
+steps:
+ - name: build-and-publish-container
+ image: plugins/docker
+ settings:
+ registry: git.local-it.org
+ repo: git.local-it.org/local-it/mitgliederverwaltung
+ username:
+ from_secret: DRONE_REGISTRY_USERNAME
+ password:
+ from_secret: DRONE_REGISTRY_TOKEN
+ auto_tag: true
+ auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8}
+ when:
+ event:
+ - tag
+
+ - name: build-and-publish-container-branch
+ image: plugins/docker
+ settings:
+ registry: git.local-it.org
+ repo: git.local-it.org/local-it/mitgliederverwaltung
+ username:
+ from_secret: DRONE_REGISTRY_USERNAME
+ password:
+ from_secret: DRONE_REGISTRY_TOKEN
+ tags:
+ - latest
+ - ${DRONE_COMMIT_SHA:0:8}
+ when:
+ event:
+ - push
+
+depends_on:
+ - check-fast
+
+---
+kind: pipeline
+type: docker
+name: renovate
+
+trigger:
+ event:
+ - cron
+ - custom
+ branch:
+ - main
+
+environment:
+ LOG_LEVEL: debug
+
+steps:
+ - name: renovate
+ image: renovate/renovate:42.97
+ environment:
+ RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
+ RENOVATE_TOKEN:
+ from_secret: RENOVATE_TOKEN
+ GITHUB_COM_TOKEN:
+ from_secret: GITHUB_COM_TOKEN
+ commands:
+ # https://github.com/renovatebot/renovate/discussions/15049
+ - unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL
+ - renovate-config-validator
+ - renovate
diff --git a/.env.example b/.env.example
index bc0ef7a..d5d35ed 100644
--- a/.env.example
+++ b/.env.example
@@ -14,7 +14,6 @@ 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
@@ -23,34 +22,11 @@ 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/oidc/callback
-# OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
+# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
+# OIDC_CLIENT_SECRET=your-rauthy-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
diff --git a/.gitignore b/.gitignore
index b37fa85..058543c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,7 +34,6 @@ mv-*.tar
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
-/node_modules/
.cursor
@@ -46,11 +45,3 @@ npm-debug.log
# Docker secrets directory (generated by `just init-secrets`)
/secrets/
notes.md
-
-# Do NOT commit these — they are local to the dev machine
-.pipeline/
-.claude/
-
-# Dialyzer PLT files — built locally and in CI cache, never tracked.
-/priv/plts/*.plt
-/priv/plts/*.plt.hash
diff --git a/.opencode/screenshots/01_mitglieder.png b/.opencode/screenshots/01_mitglieder.png
deleted file mode 100644
index 7cf25af..0000000
Binary files a/.opencode/screenshots/01_mitglieder.png and /dev/null differ
diff --git a/.opencode/screenshots/02_statistik.png b/.opencode/screenshots/02_statistik.png
deleted file mode 100644
index 675c036..0000000
Binary files a/.opencode/screenshots/02_statistik.png and /dev/null differ
diff --git a/.opencode/screenshots/03_beitraege.png b/.opencode/screenshots/03_beitraege.png
deleted file mode 100644
index 5918953..0000000
Binary files a/.opencode/screenshots/03_beitraege.png and /dev/null differ
diff --git a/.opencode/screenshots/04_aufnahmeantraege.png b/.opencode/screenshots/04_aufnahmeantraege.png
deleted file mode 100644
index 13bb316..0000000
Binary files a/.opencode/screenshots/04_aufnahmeantraege.png and /dev/null differ
diff --git a/.tool-versions b/.tool-versions
index e815bde..275206c 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,4 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
-just 1.51.0
-nodejs 26.2.0
+just 1.46.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b2a57d..2c23c01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,95 +5,7 @@ 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).
-## [1.3.0] - 2026-06-16
-
-### Added
-- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
-- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
-- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
-- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
-- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
-- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
-- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
-- **Deactivate and reactivate members** – Members can be deactivated directly from the member page: a dialog picks the exit date (prefilled to today, future dates allowed); deactivated members can be reactivated, which clears the exit date.
-- **Tooltips and OIDC explanation** – Icon-only action buttons (including the Vereinfacht sync control) now carry tooltips and accessible labels, and the OIDC settings section explains that it enables single sign-on.
-
-### Changed
-- **Member bulk actions in one menu** – The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
-- **Dropdown buttons** – Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
-- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
-
-### Fixed
-- **Authentication background workers start correctly** – The token-cleanup (Expunger) and audit-log batching workers now boot under the application's real configuration instead of an unused OTP app, so they run as intended in production rather than silently doing nothing.
-- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
-- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
-- **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
-- **Text selection opens member** – Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them.
-- **Sort by custom date** – Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982.
-- **Concurrent member creation no longer deadlocks** – Creating members in parallel (e.g. simultaneous sign-ups, or batch operations) could intermittently fail with a PostgreSQL deadlock; the affected foreign keys are now deferrable, so concurrent member creation succeeds reliably.
-
-## [1.2.0] - 2026-05-08
-
-### Changed
-- **Clickable table row highlights** – The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style).
-- **Members overview scrolling** – The members table scrollbar now scrolls inside the table container instead of moving with the full page.
-- **Join request display and settings workflow** – Improved join request rendering and related settings behavior in one cohesive update:
- - Join request fields now respect their configured field types in the details view.
- - Custom field labels in join request views were standardized.
- - Join request field formatting was corrected for more consistent output.
- - Join link settings now include a direct "Open" action in addition to copy/share workflows.
-
-### Fixed
-- **Runtime ENV handling** – Empty or invalid environment variables (e.g. `SMTP_PORT=`, `PORT=`, `POOL_SIZE=`, `DATABASE_PORT=`) no longer cause `ArgumentError` at boot. Instead raises clear errors for required vars set but empty (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE).
-- **PostgreSQL 18 Docker volume path** – Corrected the database volume path to match PostgreSQL 18 expectations.
-- **Association name ENV handling** – `ASSOCIATION_NAME` is now treated as source of truth; the field is read-only in Global Settings when managed via ENV.
-- **Association name consistency after updates** – Layout now prefers explicitly assigned `club_name` values to avoid stale cached values right after settings changes.
-- **SMTP ENV/UI source selection** – SMTP now follows a strict single-source policy: ENV-only when `SMTP_HOST` is set, otherwise Settings-only.
-- **SMTP settings UI in ENV mode** – SMTP fields are read-only, save action is hidden, and missing required ENV keys are shown as a warning.
-
-### Dependency updates
-- Mix dependencies were updated.
-- Renovate Docker image was updated to `v43.165`.
-- Rauthy Docker image was updated to `v0.35.1`.
-- `just` was updated to `v1.50.0`.
-
-## [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
+## [Unreleased]
### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index ccd16f4..cc58ca9 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -60,10 +60,6 @@ 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
@@ -85,14 +81,9 @@ 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; 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.)
+│ ├── setting.ex # Global settings (singleton resource)
│ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource
│ └── email.ex # Email custom type
@@ -121,17 +112,10 @@ 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, vereinfacht_required_member_fields)
+│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
│ ├── 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
@@ -282,16 +266,6 @@ 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:**
@@ -411,8 +385,6 @@ 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
@@ -651,10 +623,6 @@ 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
@@ -1009,9 +977,9 @@ defmodule Mv.Accounts.User do
hashed_password_field :hashed_password
end
- oidc :oidc do
+ oauth2 :rauthy do
client_id fn _, _ ->
- Application.fetch_env!(:mv, :oidc)[:client_id]
+ Application.fetch_env!(:mv, :rauthy)[:client_id]
end
# ... other config
end
@@ -1144,12 +1112,6 @@ 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:**
@@ -1266,72 +1228,36 @@ mix deps.update phoenix
mix hex.outdated
```
-### 3.11 Email: Swoosh and Phoenix.Swoosh
+### 3.11 Email: Swoosh
-**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-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`). This keeps one source of truth for transport credentials and avoids mixed ENV/DB SMTP states.
-- **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. `smtp_config/0` returns `[]` when the mailer adapter is `Swoosh.Adapters.Test`, so per-send SMTP opts never bypass the test mailbox. Port 587/465 sockopts are unit-tested on `Mv.Smtp.ConfigBuilder.build_opts/1` (`test/mv/smtp/config_builder_test.exs`); `test/mv/mailer_smtp_config_test.exs` covers the Test-adapter guard and temporarily sets the adapter to `Swoosh.Adapters.Local` to assert `smtp_config/0` wiring from ENV. Use `Mv.DataCase` for those tests (not plain `ExUnit.Case`) because `smtp_config/0` pulls `Mv.Config` fields that may read Settings from the DB when SMTP user/password ENV vars are unset.
-- **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:**
+**Mailer Configuration:**
```elixir
-use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"}
+defmodule Mv.Mailer do
+ use Swoosh.Mailer, otp_app: :mv
+end
+```
-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})
+**Sending Emails:**
-case Mailer.deliver(email) do
- {:ok, _} -> :ok
- {:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
+```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
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
@@ -1341,9 +1267,6 @@ 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")
```
@@ -1351,20 +1274,15 @@ dgettext("auth", "Sign in with email")
**Extract and Merge:**
```bash
-# Extract new translatable strings and merge into existing .po files (recommended)
-mix gettext.extract --merge
-
-# Alternative: extract only, then merge separately
+# Extract new translatable strings
mix gettext.extract
+
+# Merge into existing translations
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
-The `Justfile` prepends `~/.asdf/shims`, `~/.asdf/bin`, and `~/.asdf` to `PATH` for all recipes (`set export := true`), so `mix` / `elixir` resolve from `.tool-versions` without shell init. The caller's `PATH` is kept (e.g. Homebrew `asdf`, Docker). Run `asdf install` once per machine; no extra `source` is required for `just run`.
-
**Common Commands:**
```bash
@@ -1589,8 +1507,6 @@ 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:
@@ -1715,8 +1631,6 @@ mix test test/membership/member_test.exs:42
### 4.7 Testing Best Practices
-**Process environment (`test/test_helper.exs`):** Vereinfacht and OIDC-related `System.get_env/1` keys are cleared at test startup so configuration comes from the test database (Membership settings) unless a test explicitly sets variables in `setup` and restores them with `on_exit`. This matches production priority (ENV over settings) while keeping the suite deterministic when `.env` is loaded (e.g. via `just`).
-
**Testing Philosophy: Focus on Business Logic, Not Framework Functionality**
We test our business logic and domain-specific behavior, not core framework features. Framework features (Ash validations, Ecto relationships, etc.) are already tested by their respective libraries.
@@ -1939,7 +1853,7 @@ authentication do
hashed_password_field :hashed_password
end
- oidc :oidc do
+ oauth2 :rauthy do
# OIDC configuration
end
end
@@ -1962,8 +1876,6 @@ 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:
@@ -2166,7 +2078,7 @@ plug :protect_from_forgery
```elixir
# config/runtime.exs
-config :mv, :oidc,
+config :mv, :rauthy,
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")
@@ -2182,14 +2094,6 @@ mix phx.gen.secret
mix phx.gen.secret
```
-**Runtime configuration (config/runtime.exs):**
-
-- Production config is loaded from `config/runtime.exs` at boot (releases and `mix phx.server`). Environment variables are read via helpers so that **empty or invalid values do not cause cryptic crashes** (e.g. `ArgumentError` from `String.to_integer("")`).
-- **Helpers used:** `get_env_or_file` / `get_env_or_file!` (with `_FILE` support); `get_env_required` (required vars: raises if missing or empty after trim); `get_env_non_empty` (optional string: empty treated as unset, returns default); `parse_positive_integer` (PORT, POOL_SIZE, SMTP_PORT: empty or invalid → default).
-- **Required vars** (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE): if set but empty, the app raises at boot with a clear message including “(Variable X is set but empty.)”.
-- **Optional numeric vars** (PORT, POOL_SIZE, SMTP_PORT, DATABASE_PORT): empty or invalid value is treated as “unset” and the documented default is used (e.g. PORT=4000, SMTP_PORT=587).
-- When adding new ENV in `runtime.exs`, use these helpers instead of raw `System.get_env(...)` and `String.to_integer(...)` so that misconfigured or empty variables fail fast with clear errors.
-
### 5.6 Security Headers
**Configure Security Headers:**
@@ -2803,9 +2707,7 @@ Building accessible applications ensures that all users, including those with di
### 8.2 ARIA Labels and Roles
-**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.
+**Use ARIA Attributes When Necessary:**
```heex
@@ -2853,14 +2755,6 @@ Building accessible applications ensures that all users, including those with di
Click me
```
-**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
@@ -2870,11 +2764,7 @@ Building accessible applications ensures that all users, including those with di
### 8.4 Color and 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.
+**Ensure Sufficient Contrast:**
```elixir
# Tailwind classes with sufficient contrast (4.5:1 minimum)
@@ -2942,14 +2832,12 @@ 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
-
+
<.input
field={@form[:first_name]}
label={gettext("First Name")}
- required={@member_field_required_map[:first_name]}
+ required
aria-required="true"
/>
```
@@ -3043,11 +2931,11 @@ end
**Announce Dynamic Content:**
```heex
-
+
<%= if @searched do %>
- <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
+ <%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
<% end %>
@@ -3093,56 +2981,24 @@ 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 Modals and Dialogs
+### 8.11 DaisyUI Accessibility
-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 `