Compare commits

..

2 commits

Author SHA1 Message Date
b952a30d37
chore: adjust to sh
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 14:36:42 +01:00
a40b430ed0
chore: try timeouts in drone tasks
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 14:19:58 +01:00
310 changed files with 12345 additions and 35651 deletions

View file

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

View file

@ -4,7 +4,7 @@ name: check-fast
services:
- name: postgres
image: docker.io/library/postgres:18.3
image: docker.io/library/postgres:18.1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -37,27 +37,28 @@ steps:
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- set -eu
# Install hex package manager
- mix local.hex --force
- timeout --signal=KILL 3m mix local.hex --force
# Fetch dependencies
- mix deps.get
- timeout --signal=KILL 10m mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
- timeout --signal=KILL 10m mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
- timeout --signal=KILL 3m mix format --check-formatted
# Security checks
- mix sobelow --config
- timeout --signal=KILL 10m mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit
- timeout --signal=KILL 10m mix deps.audit
# Check for dependencies that are not maintained anymore
- mix hex.audit
- timeout --signal=KILL 10m mix hex.audit
# Provide hints for improving code quality
- mix credo --strict
- timeout --signal=KILL 15m mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.3
image: docker.io/library/postgres:18.1
commands:
# Wait for postgres to become available
- |
@ -79,12 +80,13 @@ steps:
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
- set -eu
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
- timeout --signal=KILL 10m mix deps.get
# Run fast tests (excludes slow/performance and UI tests)
- mix test --exclude slow --exclude ui --max-cases 2
- timeout --signal=KILL 20m mix test --exclude slow --exclude ui
- name: rebuild-cache
image: drillster/drone-volume-cache
@ -109,7 +111,7 @@ name: check-full
services:
- name: postgres
image: docker.io/library/postgres:18.3
image: docker.io/library/postgres:18.1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -144,27 +146,28 @@ steps:
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- set -eu
# Install hex package manager
- mix local.hex --force
- timeout --signal=KILL 3m mix local.hex --force
# Fetch dependencies
- mix deps.get
- timeout --signal=KILL 10m mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
- timeout --signal=KILL 10m mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
- timeout --signal=KILL 3m mix format --check-formatted
# Security checks
- mix sobelow --config
- timeout --signal=KILL 10m mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit
- timeout --signal=KILL 10m mix deps.audit
# Check for dependencies that are not maintained anymore
- mix hex.audit
- timeout --signal=KILL 10m mix hex.audit
# Provide hints for improving code quality
- mix credo --strict
- timeout --signal=KILL 15m mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- timeout --signal=KILL 5m mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.3
image: docker.io/library/postgres:18.1
commands:
# Wait for postgres to become available
- |
@ -186,12 +189,13 @@ steps:
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
- set -eu
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
- timeout --signal=KILL 10m mix deps.get
# Run all tests (including slow/performance and UI tests)
- mix test
- timeout --signal=KILL 30m mix test
- name: rebuild-cache
image: drillster/drone-volume-cache
@ -219,8 +223,24 @@ 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:
@ -240,33 +260,6 @@ 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
@ -284,7 +277,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:43.59
image: renovate/renovate:42.97
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -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=your-oidc-client-secret
# 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

View file

@ -5,43 +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.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 users 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 applicants 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)

View file

@ -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,71 +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 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:**
**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
@ -1340,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")
```
@ -1350,16 +1274,13 @@ 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
**Common Commands:**
@ -1586,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:
@ -1934,7 +1853,7 @@ authentication do
hashed_password_field :hashed_password
end
oidc :oidc do
oauth2 :rauthy do
# OIDC configuration
end
end
@ -1957,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:
@ -2161,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")
@ -2790,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
<!-- Icon-only buttons need labels -->
@ -2840,14 +2755,6 @@ 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
@ -2857,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)
@ -2929,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
<!-- Mark required fields (value from settings or always true for email) -->
<!-- Mark required fields -->
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
required
aria-required="true"
/>
```
@ -3030,11 +2931,11 @@ end
**Announce Dynamic Content:**
```heex
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
<!-- Search results announcement -->
<div role="status" aria-live="polite" aria-atomic="true">
<%= if @searched do %>
<span class="sr-only">
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
</span>
<% end %>
</div>
@ -3080,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 `<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 titles `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:
DaisyUI components are designed with accessibility in mind, but ensure:
```heex
<!-- 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">
<!-- Modal accessibility -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
<div class="modal-box">
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
<p><%= gettext("Are you sure?") %></p>
<div class="modal-action">
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
<button class="btn" onclick="document.getElementById('my-modal').close()">
<%= gettext("Cancel") %>
</.button>
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
</button>
<button class="btn btn-error" phx-click="confirm-delete">
<%= gettext("Delete") %>
</button>
</div>
</div>
</dialog>

View file

@ -1,457 +0,0 @@
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
## Purpose
This document defines Milas **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:** AshAuthentications `_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 AshAuthentications 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 librarys 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 deemphasised 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 wont 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 tables `sticky_header={true}` so the tables `<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: 46s
- warning: 68s
- error: 812s (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.
---

View file

@ -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=trixie-20260202-slim - for the release image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-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"
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} AS builder
# 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 libncurses6 locales ca-certificates \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale

View file

@ -10,7 +10,6 @@ install-dependencies:
mix deps.get
migrate-database:
mix compile
mix ash.setup
reset-database:
@ -32,7 +31,7 @@ gettext:
lint:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix credo
# 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

View file

@ -2,12 +2,12 @@
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
[![Build Status](https://drone.cicd.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung)
[![Build Status](https://drone.dev.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
![License](https://img.shields.io/badge/license-AGPL--v3-blue)
## 🚧 Project Status
⚠️ **First Version** — Expect breaking changes.
⚠️ **Early development** — not production-ready. 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.**
## User Documentation (German)
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
## 📸 Screenshots
![Screenshot placeholder](docs/images/screenshot.png)
*This is how Mila might look in action.*
## 🔑 Features
@ -48,10 +48,9 @@ You can find our documentation for users here: https://wiki.local-it.org/s/mila-
- ✅ 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
- ✅ Integration of Accounting-Software ([Vereinfacht](https://github.com/vereinfacht/vereinfacht))
- 🚧 Email sending
## 🚀 Quick Start (Development)
@ -143,7 +142,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/oidc/callback
- redirect uris: http://localhost:4000/auth/user/rauthy/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)
@ -154,13 +153,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 `:oidc` — it works with any OIDC-compliant provider.
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
Example for Authentik:
1. Create an OAuth2/OpenID Provider in Authentik
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
3. Configure environment variables:
```bash
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
@ -169,11 +168,17 @@ 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/oidc/callback` if not explicitly set.
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
## ⚙️ Configuration
- **Env vars:** see `.env.example`
- **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
@ -188,8 +193,6 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/ca
- `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)
@ -225,19 +228,42 @@ 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 production environment:**
3. **Start development environment** (for Rauthy):
```bash
docker compose up -d
```
4. **Start production environment:**
```bash
docker compose -f docker-compose.prod.yml up
```
4. **Database migrations run automatically** on app start. For manual migration:
5. **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"
```
5. **Access the production app:**
6. **Access the production app:**
- Production App: http://localhost:4001
- Uses same Rauthy instance as dev (localhost:8080)
@ -260,9 +286,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
@ -272,4 +298,4 @@ See the [LICENSE](LICENSE) file for details.
## 📬 Contact
- Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues)
- E-Mail: info@local-it.org
- Community links: coming soon.

View file

@ -24,7 +24,7 @@
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
@ -99,193 +99,6 @@
/* 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
deemphasised 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
============================================ */
@ -525,186 +338,4 @@
}
}
/* ============================================
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;
}

View file

@ -21,18 +21,9 @@ 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 = {}
@ -82,214 +73,6 @@ 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() {
@ -303,16 +86,6 @@ Hooks.SidebarState = {
this.setSidebarState(!current)
}
},
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
@ -339,10 +112,7 @@ Hooks.SidebarState = {
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
params: {_csrf_token: csrfToken},
hooks: Hooks
})
@ -458,13 +228,6 @@ 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)

File diff suppressed because one or more lines are too long

View file

@ -46,18 +46,11 @@ 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: [
@ -96,20 +89,6 @@ 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",

View file

@ -93,13 +93,11 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
# Signing Secret for Authentication
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
# 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"
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"
# AshAuthentication development configuration
config :mv, :session_identifier, :jti

View file

@ -129,7 +129,8 @@ 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.)
# The redirect_uri callback path is /auth/user/oidc/callback.
# 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.
#
# 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).
@ -149,9 +150,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/oidc/callback"
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
config :mv, :oidc,
config :mv, :rauthy,
client_id: oidc_client_id || "mv",
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: client_secret,
@ -217,58 +218,21 @@ if config_env() == :prod do
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# 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
# ## 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.
end

View file

@ -49,16 +49,6 @@ 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

View file

@ -18,11 +18,11 @@ services:
PHX_HOST: "${PHX_HOST:-localhost}"
PORT: "4001"
PHX_SERVER: "true"
# OIDC config - use host.docker.internal to reach host services
# Rauthy 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/oidc/callback"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
secrets:
- db_password
- secret_key_base
@ -33,7 +33,7 @@ services:
restart: unless-stopped
db-prod:
image: postgres:18.3-alpine
image: postgres:18.1-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services:
db:
image: postgres:18.3-alpine
image: postgres:18.1-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres

View file

@ -2,27 +2,24 @@
## Overview
- **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()"`.
- **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()"`.
- **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 Tasks
### Release Task
- `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 run_seeds(), then seed_admin(), then starts the server.
- rel/overlays/bin/docker-entrypoint.sh After migrate, runs seed_admin(), then starts the server.
### Seeds (Dev/Test)
@ -36,19 +33,14 @@
- `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_oidc after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
### Internal Action

View file

@ -1,88 +0,0 @@
# 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).

View file

@ -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`, `country`)
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
- Validate each row (required field: `email`)
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
@ -149,26 +149,13 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
**v1 Supported Fields:**
**Core Member Fields (all importable):**
- `email` / `E-Mail` (required)
**Core Member Fields:**
- `first_name` / `Vorname` (optional)
- `last_name` / `Nachname` (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)
- `email` / `E-Mail` (required)
- `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).
- `city` / `Stadt` (optional)
**Custom Fields:**
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
@ -189,15 +176,9 @@ Address column order in import/export matches the members overview: country, cit
| `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

View file

@ -191,8 +191,7 @@ 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: optional (no format validation)
- Country: optional
- Postal code: 5 digits
### CustomFieldValue System
- Maximum one custom field value per custom field per member
@ -241,7 +240,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, country, custom_field_values
- **Weight C:** city, street, house_number, postal_code, custom_field_values
- **Weight D (lowest):** join_date, exit_date
### Group Names in Search

View file

@ -131,7 +131,6 @@ 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']
@ -189,8 +188,7 @@ 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: optional (no format validation)
- country: optional
- postal_code: exactly 5 digits (if present)
'''
}

View file

@ -792,31 +792,6 @@ 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:**
@ -911,7 +886,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/oidc/callback`
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/rauthy/callback`
4. ✅ OIDC_CLIENT_SECRET in .env
5. ✅ App restarted after .env update

View file

@ -1,26 +0,0 @@
# 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).

View file

@ -1,7 +1,7 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
**Last Updated:** 2026-03-03
**Last Updated:** 2026-01-27
**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:** (none remaining for Authentication UI)
**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)
**Current State:**
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
@ -49,11 +49,6 @@
- ✅ **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
@ -196,11 +191,6 @@
- ❌ Mobile navigation
- ❌ Context-sensitive help
- ❌ Onboarding tooltips
- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
- Auto-dismiss: info/success 46s, warning 68s, error 812s; 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.
---
@ -252,7 +242,7 @@
- ❌ Payment records/transactions (external payment tracking)
- ❌ Payment reminders
- ❌ Invoice generation
- ✅ Memberfinance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
- ❌ vereinfacht.digital API integration
- ❌ SEPA direct debit support
- ❌ Payment reports
@ -275,9 +265,6 @@
**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
@ -295,7 +282,6 @@
- ✅ 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:**
@ -380,7 +366,6 @@
- ✅ 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:**
@ -516,8 +501,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/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
@ -530,9 +515,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_oidc` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
| `User` | `:register_with_oidc` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |

View file

@ -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_oidc` action now filters by `oidc_id` instead of `email`.
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
```elixir
read :sign_in_with_oidc do
read :sign_in_with_rauthy do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation

View file

@ -1,280 +0,0 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 14) 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. UserMember 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 plugs **`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_users 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 plugs 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 plugs `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 plugs 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.

View file

@ -31,18 +31,12 @@ 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. 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).
**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.
## Public Paths (no permission check)
- `/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).
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
## Test Coverage
@ -57,7 +51,6 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c
- 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)

View file

@ -1,44 +0,0 @@
# 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] |
+------------------------------------------------------------------+

View file

@ -1,133 +0,0 @@
# 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.

View file

@ -1,47 +0,0 @@
# 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`.

View file

@ -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_oidc/1`, `read_sign_in_with_oidc/1`
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/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_oidc, action: :register_with_oidc
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
define :create_register_with_rauthy, action: :register_with_rauthy
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
end
resource Mv.Accounts.Token

View file

@ -11,11 +11,6 @@ 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
@ -33,7 +28,7 @@ defmodule Mv.Accounts.User do
@doc """
AshAuthentication specific: Defines the strategies we want to use for authentication.
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.)
Currently password and SSO with Rauthy as OIDC provider
"""
authentication do
session_identifier Application.compile_env!(:mv, :session_identifier)
@ -57,7 +52,7 @@ defmodule Mv.Accounts.User do
end
strategies do
oidc :oidc do
oidc :rauthy do
client_id Mv.Secrets
base_url Mv.Secrets
redirect_uri Mv.Secrets
@ -93,7 +88,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_oidc (for OIDC-based registration)
# - :register_with_rauthy (for OIDC-based registration)
defaults [:read]
destroy :destroy do
@ -123,8 +118,6 @@ 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
@ -152,8 +145,6 @@ 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
@ -187,8 +178,6 @@ 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.
@ -222,8 +211,6 @@ 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
@ -261,8 +248,6 @@ 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
@ -272,7 +257,7 @@ defmodule Mv.Accounts.User do
prepare AshAuthentication.Preparations.FilterBySubject
end
read :sign_in_with_oidc do
read :sign_in_with_rauthy do
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
get? true
argument :user_info, :map, allow_nil?: false
@ -287,27 +272,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 Builtins.after_action(fn query, result, _context ->
prepare Ash.Resource.Preparation.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, __MODULE__) -> [u]
u when is_struct(u, User) -> [u]
list when is_list(list) -> list
_ -> []
end
Enum.each(users, fn user ->
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
end)
{:ok, result}
end)
end
create :register_with_oidc do
create :register_with_rauthy do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
@ -343,8 +328,6 @@ 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)
@ -362,12 +345,6 @@ 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)"
@ -411,14 +388,6 @@ 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
@ -502,10 +471,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 = SystemActor.system_user_email()
system_email = Mv.Helpers.SystemActor.system_user_email()
count =
__MODULE__
Mv.Accounts.User
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email))
@ -531,7 +500,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 SystemActor.system_user?(changeset.data) do
if Mv.Helpers.SystemActor.system_user?(changeset.data) do
{:error,
field: :email,
message:
@ -660,8 +629,8 @@ defmodule Mv.Accounts.User do
case Process.get({__MODULE__, :default_role_id}) do
nil ->
role_id =
case RoleResource.get_mitglied_role() do
{:ok, %RoleResource{id: id}} -> id
case Mv.Authorization.Role.get_mitglied_role() do
{:ok, %Mv.Authorization.Role{id: id}} -> id
_ -> nil
end

View file

@ -26,9 +26,7 @@ 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}
@ -45,10 +43,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 = SystemActor.get_system_actor()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
existing_oidc_user =
case User
case Mv.Accounts.User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
@ -64,7 +62,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 User
case Mv.Accounts.User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
@ -166,7 +164,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

View file

@ -1,27 +0,0 @@
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

View file

@ -1,31 +0,0 @@
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

View file

@ -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`). Immutable after creation.
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `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,7 +28,6 @@ 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
@ -52,8 +51,7 @@ defmodule Mv.Membership.CustomField do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
primary_read_warning?: false
authorizers: [Ash.Policy.Authorizer]
postgres do
table "custom_fields"
@ -61,32 +59,15 @@ 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

View file

@ -1,13 +0,0 @@
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

View file

@ -1,219 +0,0 @@
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

View file

@ -1,33 +0,0 @@
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

View file

@ -1,25 +0,0 @@
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

View file

@ -1,38 +0,0 @@
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

View file

@ -1,39 +0,0 @@
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

View file

@ -1,33 +0,0 @@
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

View file

@ -1,32 +0,0 @@
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

View file

@ -1,32 +0,0 @@
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

View file

@ -1,15 +0,0 @@
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

View file

@ -22,6 +22,7 @@ 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`)
@ -37,20 +38,13 @@ 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
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
alias Mv.Membership.Helpers.VisibilityConfig
# Module constants
@member_search_limit 10
@ -123,9 +117,6 @@ 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,
@ -199,9 +190,6 @@ 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
@ -255,13 +243,6 @@ 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
@ -339,12 +320,6 @@ 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 memberuser 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.
@ -483,6 +458,11 @@ 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)
@ -501,92 +481,48 @@ defmodule Mv.Membership.Member do
end
end
# 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 required custom fields (actor from validation context only; no fallback)
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."}
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")}
{:error,
field: :custom_field_values,
message:
"Unable to validate required custom fields. Please try again or contact support."}
end
end
end
@ -644,10 +580,6 @@ 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,
@ -661,14 +593,6 @@ 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
@ -815,7 +739,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.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]
@ -849,7 +773,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt
@ -865,7 +789,7 @@ defmodule Mv.Membership.Member do
cycles,
fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end,
{:desc, Date}
)
@ -892,7 +816,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
@ -902,25 +826,6 @@ 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
@ -929,12 +834,15 @@ 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 = advisory_lock_key_for_member_id(member.id)
lock_key = :erlang.phash2(member.id)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Repo.in_transaction?() do
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key)
@ -944,15 +852,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
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
Ecto.Adapters.SQL.query!(Mv.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
Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
@ -960,7 +868,7 @@ defmodule Mv.Membership.Member do
notifications
{:error, reason} ->
Repo.rollback(reason)
Mv.Repo.rollback(reason)
end
end)
|> case do
@ -974,6 +882,9 @@ 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)
@ -983,7 +894,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 =
MembershipFeeCycle
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
@ -1012,7 +923,7 @@ defmodule Mv.Membership.Member do
case cycle.membership_fee_type do
%{interval: interval} ->
cycle_end =
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq]
@ -1040,17 +951,18 @@ defmodule Mv.Membership.Member do
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
# Returns the first error for debugging; uses system actor for authorization.
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
# Uses system actor for authorization to ensure deletion always works
defp delete_cycles(cycles_to_delete, actor_opts) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle, actor_opts)
end)
case Enum.find(delete_results, &match?({:error, _}, &1)) do
{:error, reason} -> {:error, reason}
nil -> :ok
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
{:error, :deletion_failed}
else
:ok
end
end
@ -1061,7 +973,7 @@ defmodule Mv.Membership.Member do
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
case CycleGenerator.generate_cycles_for_member(
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?
@ -1092,7 +1004,7 @@ defmodule Mv.Membership.Member do
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, initiator) do
case CycleGenerator.generate_cycles_for_member(
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
initiator: initiator
@ -1113,7 +1025,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 CycleGenerator.generate_cycles_for_member(member.id,
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
initiator: initiator
) do
{:ok, cycles, notifications} ->
@ -1261,8 +1173,7 @@ defmodule Mv.Membership.Member do
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(email, ^query) or
contains(city, ^query) or
contains(country, ^query)
contains(city, ^query)
)
end
@ -1362,24 +1273,17 @@ defmodule Mv.Membership.Member do
end
end
# 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.
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data, changeset) do
case Map.get(changeset.context, :actor) do
nil ->
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)
_ ->
%{}
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
@ -1482,14 +1386,4 @@ 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

View file

@ -9,7 +9,6 @@ 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:
@ -28,12 +27,6 @@ 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
@ -71,8 +64,6 @@ 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
@ -87,11 +78,6 @@ 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
@ -116,16 +102,10 @@ defmodule Mv.Membership do
"""
def get_settings do
case Process.whereis(SettingsCache) do
nil -> get_settings_uncached()
_pid -> SettingsCache.get()
end
end
@doc false
def get_settings_uncached do
# Try to get the first (and only) settings record
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} ->
# No settings exist - create as fallback (should normally be created via seed script)
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
@ -166,16 +146,9 @@ defmodule Mv.Membership do
"""
def update_settings(settings, attrs) do
case settings
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) do
{:ok, _updated} = result ->
SettingsCache.invalidate()
result
error ->
error
end
settings
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
@doc """
@ -239,18 +212,11 @@ defmodule Mv.Membership do
"""
def update_member_field_visibility(settings, visibility_config) do
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
settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
end
@doc """
@ -283,66 +249,12 @@ defmodule Mv.Membership do
field: field,
show_in_overview: show_in_overview
) do
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
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__)
end
@doc """
@ -388,511 +300,4 @@ 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

View file

@ -11,17 +11,8 @@ 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.
@ -51,25 +42,12 @@ 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,
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
data_layer: AshPostgres.DataLayer
postgres do
table "settings"
@ -80,27 +58,8 @@ 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
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
defaults [:read]
# Internal create action - not exposed via code interface
# Used only as fallback in get_settings/0 if settings don't exist
@ -109,34 +68,9 @@ defmodule Mv.Membership.Setting do
accept [
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
: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
:default_membership_fee_type_id
]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end
update :update do
@ -146,34 +80,9 @@ defmodule Mv.Membership.Setting do
accept [
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
: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
:default_membership_fee_type_id
]
change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end
update :update_member_field_visibility do
@ -192,17 +101,6 @@ 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
@ -256,71 +154,6 @@ 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 =
@ -378,12 +211,6 @@ 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
@ -398,158 +225,6 @@ 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

View file

@ -1,60 +0,0 @@
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

View file

@ -1,179 +0,0 @@
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

View file

@ -1,85 +0,0 @@
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

View file

@ -1,75 +0,0 @@
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

View file

@ -1,22 +1,12 @@
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 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
import Swoosh.Email
alias Mv.Mailer
@ -32,39 +22,25 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
- `_opts` - Additional options (unused)
## Returns
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
confirm_url = url(~p"/confirm_new_user/#{token}")
subject = gettext("Confirm your email address")
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
assigns = %{
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
defp body(params) do
url = url(~p"/confirm_new_user/#{params[:token]}")
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
"""
<p>Click this link to confirm your email:</p>
<p><a href="#{url}">#{url}</a></p>
"""
end
end

View file

@ -1,22 +1,12 @@
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
@moduledoc """
Sends a password reset email.
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
central mail from config (Mv.Mailer.mail_from/0).
Sends a password reset email
"""
use AshAuthentication.Sender
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
import Swoosh.Email
alias Mv.Mailer
@ -32,36 +22,25 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
- `_opts` - Additional options (unused)
## Returns
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
"""
@impl true
def send(user, token, _) do
reset_url = url(~p"/password-reset/#{token}")
subject = gettext("Reset your password")
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
assigns = %{
reset_url: reset_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
defp body(params) do
url = url(~p"/password-reset/#{params[:token]}")
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
"""
<p>Click this link to reset your password:</p>
<p><a href="#{url}">#{url}</a></p>
"""
end
end

View file

@ -5,40 +5,21 @@ 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
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
]
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
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options

View file

@ -1,17 +0,0 @@
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

View file

@ -1,17 +0,0 @@
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

View file

@ -22,7 +22,6 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end
"""
use Ash.Policy.Check
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets
@impl true
@ -68,5 +67,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end
end
defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
end

View file

@ -1,32 +0,0 @@
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

View file

@ -81,7 +81,6 @@ 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
@ -398,6 +397,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
Actor.ensure_loaded(actor)
Mv.Authorization.Actor.ensure_loaded(actor)
end
end

View file

@ -1,16 +0,0 @@
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

View file

@ -218,11 +218,7 @@ defmodule Mv.Authorization.PermissionSets do
perm("MembershipFeeCycle", :update, :all),
perm("MembershipFeeCycle", :destroy, :all)
] ++
role_read_all() ++
[
perm("JoinRequest", :read, :all),
perm("JoinRequest", :update, :all)
],
role_read_all(),
pages: [
"/",
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
@ -251,10 +247,7 @@ defmodule Mv.Authorization.PermissionSets do
# Edit group
"/groups/:slug/edit",
# Statistics
"/statistics",
# Approval UI (Step 2)
"/join_requests",
"/join_requests/:id"
"/statistics"
]
}
end
@ -277,8 +270,7 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Group") ++
member_group_perms ++
perm_all("MembershipFeeType") ++
perm_all("MembershipFeeCycle") ++
perm_all("JoinRequest"),
perm_all("MembershipFeeCycle"),
pages: [
# Explicit admin-only pages (for clarity and future restrictions)
"/settings",

View file

@ -94,16 +94,14 @@ defmodule Mv.Authorization.Role do
end
end
alias Mv.Authorization.PermissionSets
validations do
validate one_of(
:permission_set_name,
PermissionSets.all_permission_sets()
Mv.Authorization.PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
),
message:
"must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context ->
if changeset.data.is_system_role do

View file

@ -142,528 +142,4 @@ 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

View file

@ -10,7 +10,6 @@ defmodule Mv.Constants do
:join_date,
:exit_date,
:notes,
:country,
:city,
:street,
:house_number,
@ -22,38 +21,14 @@ 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.
@ -74,16 +49,6 @@ 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.

View file

@ -1,191 +1,3 @@
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

View file

@ -17,24 +17,15 @@ defmodule Mv.Membership.Import.HeaderMapper do
## Member Field Mapping
Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for
importable attributes). All DB-backed member attributes can be imported.
Maps CSV headers to canonical member fields:
- `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)
- `first_name` (optional)
- `last_name` (optional)
- `street` (optional)
- `postal_code` (optional)
- `city` (optional)
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.
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
## Custom Field Detection
@ -84,37 +75,11 @@ 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",
@ -128,18 +93,6 @@ 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"
]
}

View file

@ -549,12 +549,9 @@ 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 =
member_attrs
trimmed_member_attrs
|> Map.put(:custom_field_values, custom_field_values)
# Only include custom_field_values if not empty
@ -796,23 +793,6 @@ 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)

View file

@ -13,11 +13,10 @@ 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_type", "membership_fee_status", "groups"]
["membership_fee_status"]
@computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -170,7 +169,7 @@ defmodule Mv.Membership.MemberExport do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> Index.apply_boolean_custom_field_filters(
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
@ -324,14 +323,10 @@ defmodule Mv.Membership.MemberExport do
|> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table()
# 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
# final member_fields list (used for column specs order): table order + computed inserted
ordered_member_fields =
selectable_member_fields
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|> then(fn fields -> fields ++ groups_field end)
|> insert_computed_fields_like_table(computed_fields)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
@ -378,38 +373,6 @@ 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
@ -453,52 +416,27 @@ defmodule Mv.Membership.MemberExport do
table_order |> Enum.filter(&(&1 in fields))
end
defp insert_fee_type_and_computed_fields_like_table(
db_fields_ordered,
computed_fields,
member_fields
) do
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.
computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert =
Enum.flat_map(db_fields_ordered, fn f ->
expand_field_with_computed(f, member_fields, computed_fields)
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
[f, "membership_fee_status"]
else
[f]
end
end)
# 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 =
computed_fields
|> Enum.reject(&(&1 in db_with_insert))
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)

View file

@ -21,7 +21,6 @@ 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()
@ -133,20 +132,12 @@ 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
@ -170,7 +161,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)
|> Index.apply_boolean_custom_field_filters(
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
@ -202,10 +193,8 @@ 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() 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)
if field_atom in Mv.Constants.member_fields() do
sort_by_field(members, field_atom, order)
else
members
end
@ -215,16 +204,12 @@ defmodule Mv.Membership.MemberExport.Build do
defp sort_members_in_memory(members, _field, _order), do: members
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_by_field(members, field_atom, order) do
key_fn = fn member -> Map.get(member, field_atom) end
compare_fn = build_compare_fn(order)
defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end
Enum.sort_by(members, key_fn, compare_fn)
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
@ -256,65 +241,30 @@ 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
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)
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
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
else
sort_members_with_custom_field(members, custom_field, order)
end
end
if is_nil(custom_field), do: members
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
@ -327,26 +277,6 @@ 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 ->
@ -364,19 +294,6 @@ 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
@ -426,32 +343,6 @@ 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 ->
@ -470,8 +361,7 @@ defmodule Mv.Membership.MemberExport.Build do
end)
|> Enum.reject(&is_nil/1)
# 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
member_cols ++ computed_cols ++ custom_cols
end
defp build_rows(members, columns, custom_fields_by_id) do
@ -501,28 +391,14 @@ 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
String.to_existing_atom(k)
rescue
ArgumentError -> k
try do
String.to_existing_atom(k)
rescue
ArgumentError -> k
end
end
defp get_cfv_by_id(member, id) do
@ -548,15 +424,6 @@ 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(),

View file

@ -59,24 +59,14 @@ 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
String.to_existing_atom(k)
rescue
ArgumentError -> k
try do
String.to_existing_atom(k)
rescue
ArgumentError -> k
end
end
defp get_cfv_by_id(member, id) do
@ -107,13 +97,4 @@ 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

View file

@ -299,9 +299,11 @@ defmodule Mv.Membership.MembersPDF do
defp date_column?(_), do: false
defp key_to_atom_safe(key) when is_binary(key) do
String.to_existing_atom(key)
rescue
ArgumentError -> key
try do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
end
defp key_to_atom_safe(key), do: key

View file

@ -54,8 +54,6 @@ defmodule Mv.MembershipFees.CycleGenerator do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo
alias Ecto.Adapters.SQL, as: EctoSQL
require Ash.Query
require Logger
@ -112,10 +110,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = Member.advisory_lock_key_for_member_id(member.id)
lock_key = :erlang.phash2(member.id)
Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} ->

View file

@ -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_oidc) and on sign-in so that
Used after OIDC registration (register_with_rauthy) 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,9 +82,11 @@ defmodule Mv.OidcRoleSync do
end
defp safe_get_atom(map, key) when is_binary(key) do
Map.get(map, String.to_existing_atom(key))
rescue
ArgumentError -> nil
try do
Map.get(map, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
end
defp safe_get_atom(_map, _key), do: nil

View file

@ -2,19 +2,23 @@ defmodule Mv.OidcRoleSyncConfig do
@moduledoc """
Runtime configuration for OIDC group role sync (e.g. admin group Admin role).
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"`).
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"`).
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings OIDC).
Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
"""
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
def oidc_admin_group_name do
Mv.Config.oidc_admin_group_name()
get(:admin_group_name)
end
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
Mv.Config.oidc_groups_claim() || "groups"
get(:groups_claim) || "groups"
end
defp get(key) do
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
end
end

View file

@ -6,8 +6,6 @@ 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.
@ -19,7 +17,6 @@ defmodule Mv.Release do
alias Mv.Authorization.Role
require Ash.Query
require Logger
def migrate do
load_app()
@ -29,68 +26,6 @@ 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))

View file

@ -7,66 +7,59 @@ defmodule Mv.Secrets do
particularly for OIDC (Rauthy) authentication.
## Configuration Source
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
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`
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.
## Usage
This module is automatically called by AshAuthentication when resolving
secrets for the User resource's OIDC strategy.
"""
use AshAuthentication.Secret
alias AshAuthentication.Errors.MissingSecret
def secret_for(
[:authentication, :strategies, :oidc, :client_id],
resource,
[:authentication, :strategies, :rauthy, :client_id],
Mv.Accounts.User,
_opts,
_meth
) do
secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id)
get_config(:client_id)
end
def secret_for(
[:authentication, :strategies, :oidc, :redirect_uri],
resource,
[:authentication, :strategies, :rauthy, :redirect_uri],
Mv.Accounts.User,
_opts,
_meth
) do
secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri)
get_config(:redirect_uri)
end
def secret_for(
[:authentication, :strategies, :oidc, :client_secret],
resource,
[:authentication, :strategies, :rauthy, :client_secret],
Mv.Accounts.User,
_opts,
_meth
) do
secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret)
get_config(:client_secret)
end
def secret_for(
[:authentication, :strategies, :oidc, :base_url],
resource,
[:authentication, :strategies, :rauthy, :base_url],
Mv.Accounts.User,
_opts,
_meth
) do
secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url)
get_config(:base_url)
end
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
defp get_config(key) do
:mv
|> Application.fetch_env!(:rauthy)
|> Keyword.fetch!(key)
|> then(&{:ok, &1})
end
end

View file

@ -1,58 +0,0 @@
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

View file

@ -1,95 +0,0 @@
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

View file

@ -1,71 +0,0 @@
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

View file

@ -1,392 +0,0 @@
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

View file

@ -1,46 +0,0 @@
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

View file

@ -1,186 +0,0 @@
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

View file

@ -94,8 +94,8 @@ defmodule MvWeb do
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
# Common modules used in templates
alias MvWeb.Layouts
alias Phoenix.LiveView.JS
alias MvWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())

View file

@ -3,70 +3,46 @@ defmodule MvWeb.AuthOverrides do
UI customizations for AshAuthentication Phoenix components.
## Overrides
- `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)
- `SignIn` - Restricts form width to prevent full-width display
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- `HorizontalRule` - Translates "or" text to German
## Documentation
For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
"""
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
# Avoid full-width for the Sign In Form.
# Banner is hidden because SignInLive renders its own locale-aware title.
# 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
override AshAuthentication.Phoenix.Components.SignIn do
set :root_class, "md:min-w-md"
set :show_banner, false
end
# Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
# Replace banner logo with text
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
set :dark_image_url, nil
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.
# Translate the or in the horizontal rule to German
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..."
set :text,
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
Gettext.gettext(MvWeb.Gettext, "or")
end)
end
end

View file

@ -29,24 +29,8 @@ 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.
@ -63,11 +47,6 @@ 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"
@ -79,20 +58,17 @@ 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="pointer-events-auto"
class="z-50 toast toast-top toast-end"
{@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 && "alert-success",
@kind == :warning && "alert-warning"
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
]}>
<.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" />
@ -114,74 +90,33 @@ 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"/"} variant="secondary">Home</.button>
<.button variant="ghost" size="sm">Edit</.button>
<.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
"""
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 :rest, :global, include: ~w(href navigate patch method data-testid)
attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(assigns) do
rest = assigns.rest
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)
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
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", btn_class, "btn-disabled"] ++ @button_focus_classes,
else: ["btn", btn_class] ++ @button_focus_classes
do: ["btn", assigns.class, "btn-disabled"],
else: ["btn", assigns.class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs =
if assigns[:disabled] do
rest
@ -203,223 +138,13 @@ defmodule MvWeb.CoreComponents do
"""
else
~H"""
<button
class={["btn", @btn_class] ++ @button_focus_classes}
disabled={@disabled}
{@rest}
>
<button class={["btn", @class]} 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.
@ -466,11 +191,7 @@ defmodule MvWeb.CoreComponents do
def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
assigns =
assigns
|> assign(:menu_testid, menu_testid)
|> assign(:button_focus_classes, @button_focus_classes)
assigns = assign(assigns, :menu_testid, menu_testid)
~H"""
<div
@ -486,10 +207,17 @@ defmodule MvWeb.CoreComponents do
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded={if @open, do: "true", else: "false"}
aria-expanded={@open}
aria-controls={@id}
aria-label={@button_label}
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid={@button_testid}
@ -557,12 +285,7 @@ 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-visible:ring-inset"
] ++ @button_focus_classes
}
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"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
@ -570,17 +293,13 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<%!-- 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"
}
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
>
<.icon name="hero-check" class="size-4 shrink-0" />
</span>
/>
<% end %>
<span>{item.label}</span>
</button>
@ -682,7 +401,7 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
HTMLForm.normalize_value("checkbox", assigns[:value])
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
# For checkboxes, we don't use HTML required attribute (means "must be checked")
@ -718,7 +437,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
/>{@label}<span
:if={@is_required}
class="text-error tooltip tooltip-right"
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field is required")}
>*</span>
</span>
@ -729,15 +448,13 @@ 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-error tooltip tooltip-right"
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -749,7 +466,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{HTMLForm.options_for_select(@options, @value)}
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
@ -758,15 +475,13 @@ 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-error tooltip tooltip-right"
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -778,7 +493,7 @@ defmodule MvWeb.CoreComponents do
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{HTMLForm.normalize_value("textarea", @value)}</textarea>
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</fieldset>
@ -787,15 +502,13 @@ 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-error tooltip tooltip-right"
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -803,7 +516,7 @@ defmodule MvWeb.CoreComponents do
type={@type}
name={@name}
id={@id}
value={HTMLForm.normalize_value(@type, @value)}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
@ -816,18 +529,6 @@ 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"""
@ -840,24 +541,17 @@ 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={["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">
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
<div>
<h1 class="text-xl font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
@ -865,9 +559,7 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
</p>
</div>
<div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
{render_slot(@actions)}
</div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
</header>
"""
end
@ -875,53 +567,18 @@ 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"
@ -933,11 +590,6 @@ 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
@ -955,39 +607,19 @@ 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
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<div class="overflow-auto">
<table class="table table-zebra">
<thead>
<tr>
<th
:for={col <- @col}
class={table_th_class(col, @sticky_header)}
class={Map.get(col, :class)}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
</th>
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
<th :for={dyn_col <- @dynamic_cols}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -997,26 +629,15 @@ defmodule MvWeb.CoreComponents do
sort_order={@sort_order}
/>
</th>
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
<th :if={@action != []}>
<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)}
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}
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
: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
}
:for={col <- @col}
phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row))
@ -1040,19 +661,6 @@ 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]
@ -1063,9 +671,6 @@ 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
@ -1099,43 +704,6 @@ 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)
@ -1146,136 +714,6 @@ 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.
@ -1303,41 +741,6 @@ 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).

View file

@ -8,16 +8,6 @@ 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)}
@ -69,7 +59,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
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"
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
@ -85,7 +75,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
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"
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>

View file

@ -13,98 +13,6 @@ 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.
@ -135,19 +43,8 @@ defmodule MvWeb.Layouts do
slot :inner_block, required: true
def app(assigns) do
# 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)
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<%= if @current_user do %>
@ -157,7 +54,7 @@ defmodule MvWeb.Layouts do
data-sidebar-expanded="true"
phx-hook="SidebarState"
>
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" phx-update="ignore" />
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col relative z-0">
<!-- Mobile Header (only visible on mobile) -->
@ -181,41 +78,11 @@ defmodule MvWeb.Layouts do
</div>
<div class="drawer-side z-40">
<.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}
/>
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
</div>
</div>
<% else %>
<!-- 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>
<!-- Not logged in -->
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}
@ -227,27 +94,15 @@ defmodule MvWeb.Layouts do
"""
end
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
defp get_layout_settings do
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Mv.Membership.get_settings() do
{:ok, settings} ->
%{
club_name: settings.club_name || "Mitgliederverwaltung",
join_form_enabled: settings.join_form_enabled == true
}
_ ->
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
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.
@ -260,12 +115,8 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
<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} />
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />

View file

@ -7,106 +7,32 @@
<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="Mila">
{page_title_string(assigns)}
<.live_title default="Mv" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.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 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));
};
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();
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);
}
});
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
})();
</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>

View file

@ -8,15 +8,6 @@ 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
@ -89,11 +80,11 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
<.menu_item
href={~p"/groups"}
icon="hero-user-group"
label={gettext("Groups")}
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<% end %>
@ -105,41 +96,30 @@ 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.settings()) do %>
<.menu_subitem href={~p"/settings"} label={gettext("Basic settings")} />
<%= 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_datafields()) do %>
<.menu_subitem href={~p"/admin/datafields"} label={gettext("Datafields")} />
<%= 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")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Membership fee settings")}
label={gettext("Fee Settings")}
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.admin_import()) do %>
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
<.menu_subitem href={~p"/admin/import"} label={gettext("Import")} />
<% 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")} />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
<% end %>
</.menu_group>
<% end %>
@ -155,10 +135,6 @@ 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">
@ -168,16 +144,7 @@ defmodule MvWeb.Layouts.Sidebar do
data-tip={@label}
role="menuitem"
>
<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>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</.link>
</li>
@ -251,22 +218,21 @@ 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">
<!-- 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>
<!-- 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 />
<!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %>
<.user_menu current_user={@current_user} />
@ -275,6 +241,24 @@ 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

View file

@ -15,23 +15,8 @@ defmodule MvWeb.AuthController do
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Config
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
def success(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
message =
@ -46,7 +31,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(:success, message)
|> put_flash(:info, message)
|> redirect(to: return_to)
end
@ -60,86 +45,28 @@ defmodule MvWeb.AuthController do
- Generic authentication failures
"""
def failure(conn, activity, reason) do
log_failure_safely(activity, reason)
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
case {activity, reason} do
{{:oidc, _action}, reason} ->
handle_oidc_failure(conn, reason)
{{:rauthy, _action}, reason} ->
handle_rauthy_failure(conn, reason)
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
handle_authentication_failed(conn, caused_by)
_ ->
conn
|> put_flash(:error, gettext("Incorrect email or password"))
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, gettext("Incorrect email or password"))
end
end
# 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 all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors)
end
defp handle_oidc_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
caused_by: caused_by
}) do
case caused_by do
@ -147,46 +74,14 @@ defmodule MvWeb.AuthController do
handle_oidc_email_collision(conn, errors)
_ ->
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: sign_in_path_after_oidc_failure())
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
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_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())
defp handle_rauthy_failure(conn, reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
end
# Handle generic AuthenticationFailed errors
@ -198,20 +93,14 @@ defmodule MvWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password.
""")
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, message)
else
conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
end
defp handle_authentication_failed(conn, _other) do
conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
# Handle OIDC email collision - user needs to verify password to link accounts
@ -223,17 +112,10 @@ 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)
conn
|> put_flash(:error, error_message)
|> redirect(to: sign_in_path_after_oidc_failure())
redirect_with_error(conn, error_message)
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
@ -295,53 +177,19 @@ defmodule MvWeb.AuthController do
|> redirect(to: ~p"/auth/link-oidc-account")
end
# 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)
# Generic error redirect helper
defp redirect_with_error(conn, message) do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
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(:success, gettext("You are now signed out"))
|> put_flash(:info, gettext("You are now signed out"))
|> redirect(to: return_to)
end
end

View file

@ -1,64 +0,0 @@
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

View file

@ -1,9 +0,0 @@
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

View file

@ -1,45 +0,0 @@
<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>

View file

@ -13,14 +13,12 @@ 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.MemberLive.Index.MembershipFeeStatus
alias MvWeb.Translations.MemberFields
alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "groups"]
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -66,9 +64,6 @@ 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")),
@ -76,56 +71,18 @@ 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: custom_field_ids,
custom_field_ids_union: custom_field_ids_union,
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
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"),
cycle_status_filter: extract_cycle_status_filter(params),
boolean_filters: boolean_filters
show_current_cycle: extract_boolean(params, "show_current_cycle")
}
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
@ -146,10 +103,12 @@ defmodule MvWeb.MemberExportController do
end
defp atom_exists?(name) do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
end
defp extract_list(params, key) do
@ -197,8 +156,7 @@ 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_union, actor),
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, 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)
@ -216,19 +174,13 @@ defmodule MvWeb.MemberExportController do
end
end
defp ensure_sort_custom_field_loaded(
%{custom_field_ids: ids, custom_field_ids_union: union, sort_field: sort_field} = parsed
) do
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, 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]),
custom_field_ids_union: Enum.uniq([id | union])
}
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
end
end
@ -281,23 +233,14 @@ 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) 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"
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|> load_custom_field_values_query(parsed.custom_field_ids)
|> 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
@ -325,10 +268,6 @@ 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)
@ -345,19 +284,6 @@ 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
@ -403,47 +329,22 @@ 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
cond do
field == "groups" ->
{query, true}
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)
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)
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
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)
# ------------------------------------------------------------------
@ -457,15 +358,6 @@ 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 =
@ -495,26 +387,6 @@ 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 ->
@ -569,32 +441,6 @@ 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 ->
@ -613,8 +459,7 @@ defmodule MvWeb.MemberExportController do
end)
|> Enum.reject(&is_nil/1)
# 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
member_cols ++ computed_cols ++ custom_cols
end
# --- headers: use MemberFields.label for translations ---
@ -654,14 +499,6 @@ 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("_", " ")

View file

@ -20,8 +20,7 @@ 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)) ++
["membership_fee_type", "groups"]
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
def export(conn, %{"payload" => payload}) when is_binary(payload) do
actor = current_actor(conn)

View file

@ -7,11 +7,7 @@ defmodule MvWeb.PageController do
"""
use MvWeb, :controller
use Gettext, backend: MvWeb.Gettext
def home(conn, _params) do
conn
|> assign(:page_title, gettext("Home"))
|> render(:home)
render(conn, :home)
end
end

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