Compare commits

...

31 commits
1.0.0 ... main

Author SHA1 Message Date
f8a3cc4c47 Run seeds only once (#475)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
continuous-integration/drone/tag Build is passing
## Description of the implemented changes
The changes were:
- [ ] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring

**Seeds run only on first startup.** On every application start (e.g. `just run`, Docker entrypoint), seed scripts are still invoked, but they exit immediately when the admin user already exists. This avoids duplicate seed data (e.g. join requests), keeps startup fast after the first run, and works the same in dev and production.

## What has been changed?

- **`lib/mv/release.ex`**
  - Added `bootstrap_seeds_applied?/0`: returns whether the admin user (from `ADMIN_EMAIL` or default `admin@localhost`) exists. We check the admin *user*, not the Admin *role*, so we do not skip when only migrations have run (migrations can create the Admin role for the system actor).
  - `run_seeds/0`: if `bootstrap_seeds_applied?()` is true, prints “Seeds already applied (admin user exists). Skipping.” and returns without running bootstrap or dev seeds; otherwise unchanged behaviour.
  - Module docs updated for the new function and the skip behaviour.

- **`priv/repo/seeds.exs`**
  - Ensures the app is started (`Application.ensure_all_started(:mv)`).
  - If `Mv.Release.bootstrap_seeds_applied?()` is true, prints the same skip message and does not run bootstrap or dev seeds; otherwise runs as before (bootstrap + dev seeds in dev/test).
  - Comment at the top updated to describe the skip behaviour.

- **Documentation**
  - `CODE_GUIDELINES.md` §1.2.1: seeds run on every start but exit early when already applied; mentions `bootstrap_seeds_applied?/0`.
  - `docs/admin-bootstrap-and-oidc-role-sync.md`: run_seeds skips when admin user exists; description of `run_seeds/0` updated.
  - `CHANGELOG.md` [Unreleased]: new “Seeds run only when needed” entry under Changed.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed

### Accessibility
- [x] New elements are properly defined with html-tags *(no new UI)*
- [x] Colour contrast follows WCAG criteria *(no new UI)*
- [x] Aria labels are added when needed *(no new UI)*
- [x] Everything is accessible by keyboard *(no new UI)*
- [x] Tab-Order is comprehensible *(no new UI)*
- [x] All interactive elements have a visible focus *(no new UI)*

### Testing
- [x] Tests for new code are written *(existing seeds and release tests cover behaviour; idempotency test still passes when second run skips)*
- [x] All tests pass
- [x] axe-core dev tools show no critical or major issues *(no UI changes)*

## Additional Notes

- **Review focus:** Logic in `Mv.Release` and `priv/repo/seeds.exs`; the “already applied” check is a single DB read for the admin user. On failure (e.g. DB down), `bootstrap_seeds_applied?/0` returns `false`, so seeds run (safe for first deploy).
- **Suggested check:** Run `mix test test/seeds_test.exs test/mv/release_test.exs` to confirm seeds and release behaviour.

Reviewed-on: #475
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-03-16 19:27:31 +01:00
c381b86b5e Improve oidc only mode (#474)
All checks were successful
continuous-integration/drone/push Build is passing
## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring

**OIDC-only mode improvements and UX tweaks (success toasts, unauthenticated redirect).**

## What has been changed?

### OIDC-only mode (new feature)
- **Admin settings:** "Only OIDC sign-in" is an immediate toggle at the top of the OIDC section (no save button). Enabling it also turns off "Allow direct registration". When OIDC-only is on, the registration checkbox is disabled and shows a tooltip (DaisyUI `<.tooltip>`).
- **Backend:** Password sign-in is forbidden via Ash policy (`OidcOnlyActive` check). Password registration is blocked via validation `OidcOnlyBlocksPasswordRegistration`. New plug `OidcOnlySignInRedirect`: when OIDC-only and OIDC are configured, GET `/sign-in` redirects to the OIDC flow; GET `/auth/user/password/sign_in_with_token` is rejected with redirect + flash. `AuthController.success/4` also rejects password sign-in when OIDC-only.
- **Tests:** GlobalSettingsLive (OIDC-only UI), AuthController (redirect and password sign-in rejection), User authentication (register_with_password blocked when OIDC-only).

### UX / behaviour (no new feature flag)
- **Success toasts:** Success flash messages auto-dismiss after 5 seconds via JS hook `FlashAutoDismiss` and optional `auto_clear_ms` on `<.flash>` (used for success in root layout and `flash_group`).
- **Unauthenticated users:** Redirect to sign-in without the "You don't have permission to access this page" flash; that message is only shown to logged-in users who lack access. Logic in `LiveHelpers` and `CheckPagePermission` plug; test updated accordingly.

### Other
- Layouts: comment about unprocessed join-request count no longer uses "TODO" (Credo).
- Gettext: German translation for "Home" (Startseite); POT/PO kept in sync.
- CHANGELOG: Unreleased section updated with the above.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed (module docs, comments where non-obvious)

### Accessibility
- [x] New elements are properly defined with html-tags (labels, aria-label on checkboxes)
- [x] Colour contrast follows WCAG criteria (unchanged)
- [x] Aria labels are added when needed (e.g. oidc-only and registration checkboxes)
- [x] Everything is accessible by keyboard (toggles and buttons unchanged)
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus (existing patterns)

### Testing
- [x] Tests for new code are written (OIDC-only UI, auth controller, user auth; SMTP config builder and mailer)
- [x] All tests pass
- [ ] axe-core dev tools show no critical or major issues (not re-run for this PR; suggest spot-check on settings and sign-in)

## Additional Notes
- **OIDC-only:** When the `OIDC_ONLY` env var is set, the toggle is read-only and shows "(From OIDC_ONLY)". When OIDC is not configured, the toggle is disabled.
- **Invalidation:** Enabling OIDC-only sets `registration_enabled: false` in one update; disabling OIDC-only only updates `oidc_only` (registration left as-is).
- **Review focus:** Plug order in router (OidcOnlySignInRedirect), policy/validation order in User, and that all OIDC-only paths (form, plug, controller) stay consistent.

Reviewed-on: #474
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-03-16 19:09:07 +01:00
9b0f269ab6 Merge pull request 'Fix TLS config' (#473) from bugfix/fix-tls-config into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #473
2026-03-16 15:04:33 +01:00
f353f1cbc0
fix: update smtp test
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-03-16 14:58:21 +01:00
e8f27690a1
refactor: unify smtp config logic
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-16 14:23:46 +01:00
e95c1d6254
fix: repaired smtp configuration for port 587
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-16 14:00:23 +01:00
837f5fd5bf Merge pull request 'Finalize join request feature' (#472) from feature/308-web-form into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #472
2026-03-13 20:51:09 +01:00
1866c79461
fix: failing tests
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-03-13 20:36:13 +01:00
171a699326
fix: failing tests
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-13 19:59:59 +01:00
86c032004e
fix: failing tests
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-13 19:43:04 +01:00
a4239ce09b
fix: failing tests
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-13 19:25:23 +01:00
c933144920
feat: unify page titles
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-13 19:01:50 +01:00
e8ec620d57
feat: add timezone handling
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-13 18:22:12 +01:00
349cee0ce6
refactor: review remarks
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 17:55:17 +01:00
f12da8a359
test: fix tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-13 17:07:25 +01:00
d54393d80b
docs: update changelog
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 16:54:03 +01:00
5e39fffce2
i18n: update gettext
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 16:47:16 +01:00
9a3cf74871
Merge remote-tracking branch 'origin/main' into feature/308-web-form
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 16:45:34 +01:00
09e4b64663
feat: allow disabling registration
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 16:40:39 +01:00
eb18209669
feat: rearrange smtp settings
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-13 15:56:02 +01:00
104faf7006
feat: add theme selector to unauthenticated pages
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 14:48:10 +01:00
99a8d64344
fix: translation of login page
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-13 14:11:54 +01:00
086ecdcb1b
feat: prevent join requests with equal mail
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-13 11:18:34 +01:00
40a4461d23
fix: join confirmation mail configuration
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-13 09:34:56 +01:00
a7481f6ab1
feat: improve field order for approvals and add seeds
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-12 16:15:57 +01:00
d94f9ae42e Merge pull request 'add smtp mailer settings' (#470) from feature/308-web-form into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #470
2026-03-12 15:58:59 +01:00
a5ce7cb921
fix group performance test
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-03-12 15:46:52 +01:00
942f2afd9e
refactor: adress review
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-12 15:29:54 +01:00
4af80a8305
Merge remote-tracking branch 'origin/main' into feature/308-web-form
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
2026-03-12 13:52:33 +01:00
a4f3aa5d6f
feat: add smtp settings
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-12 13:39:48 +01:00
c4135308e6
test: add tests for smtp mailer config 2026-03-11 09:18:37 +01:00
127 changed files with 15675 additions and 10260 deletions

View file

@ -14,6 +14,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# Optional: Admin user (created/updated on container start via Release.seed_admin)
# In production, set these so the first admin can log in. Change password without redeploy:
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields).
# ADMIN_EMAIL=admin@example.com
# ADMIN_PASSWORD=secure-password
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
@ -41,3 +42,15 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# 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,7 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.1.1] - 2026-03-16
### Added
- **FORCE_SEEDS** Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user.
- **Improved OIDC-only mode** Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
- **Success toast auto-dismiss** Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
### Changed
- **Seeds run only when needed** Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run.
- **Unauthenticated access** Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
### Fixed
- **SMTP configuration** Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
## [1.1.0] - 2026-03-13
### Added
- **Browser timezone for datetime display** Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the 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
### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)

View file

@ -90,6 +90,8 @@ lib/
│ ├── 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.)
│ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource
@ -128,6 +130,8 @@ lib/
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
│ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer
│ ├── smtp/
│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer
│ ├── release.ex # Release tasks
│ ├── repo.ex # Database repository
│ ├── secrets.ex # Secret management
@ -280,13 +284,13 @@ end
### 1.2.1 Database Seeds
Seeds are split into **bootstrap** and **dev**:
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. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`.
- **`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` executes only the bootstrap part (no dev seeds).
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
@ -1267,7 +1271,34 @@ mix hex.outdated
**Mailer and from address:**
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_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):**
@ -1287,7 +1318,11 @@ new()
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("template_name.html", %{assigns})
|> Mailer.deliver!()
case Mailer.deliver(email) do
{:ok, _} -> :ok
{:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
end
```
### 3.12 Internationalization: Gettext
@ -1315,13 +1350,16 @@ dgettext("auth", "Sign in with email")
**Extract and Merge:**
```bash
# Extract new translatable strings
mix gettext.extract
# Extract new translatable strings and merge into existing .po files (recommended)
mix gettext.extract --merge
# Merge into existing translations
# Alternative: extract only, then merge separately
mix gettext.extract
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
```
**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source.
### 3.13 Task Runner: Just
**Common Commands:**

View file

@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c
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:
@ -83,16 +98,18 @@ 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/70` |
| 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/70` |
| Fine print | small hints | `text-xs text-base-content/60` |
| Empty state | no data | `text-base-content/60 italic` |
| 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)
@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`.
- **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)

View file

@ -10,6 +10,7 @@ install-dependencies:
mix deps.get
migrate-database:
mix compile
mix ash.setup
reset-database:

View file

@ -154,6 +154,14 @@
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

View file

@ -25,6 +25,14 @@ 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 = {}
@ -105,6 +113,25 @@ Hooks.FocusRestore = {
}
}
// 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() {
@ -312,7 +339,10 @@ Hooks.SidebarState = {
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
hooks: Hooks
})

View file

@ -46,11 +46,18 @@ config :spark,
]
]
# IANA timezone database for DateTime.shift_zone (browser timezone display)
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
config :mv,
ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime],
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
# not available in releases. Set once at compile time via config_env().
config :mv, :environment, config_env()
# CSV Import configuration
config :mv,
csv_import: [
@ -89,6 +96,10 @@ 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"}
@ -96,6 +107,9 @@ 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

@ -223,19 +223,52 @@ if config_env() == :prod do
{System.get_env("MAIL_FROM_NAME", "Mila"),
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
# In production you may 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.
# SMTP configuration from environment variables (overrides base adapter in prod).
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
smtp_host_env = System.get_env("SMTP_HOST")
if smtp_host_env && String.trim(smtp_host_env) != "" do
smtp_port_env =
case System.get_env("SMTP_PORT") do
nil -> 587
v -> String.to_integer(String.trim(v))
end
smtp_password_env =
case System.get_env("SMTP_PASSWORD") do
nil ->
case System.get_env("SMTP_PASSWORD_FILE") do
nil -> nil
path -> path |> File.read!() |> String.trim()
end
v ->
v
end
smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
# SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended
# for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs.
smtp_verify_peer =
(System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes)
config :mv, :smtp_verify_peer, smtp_verify_peer
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
smtp_opts =
Mv.Smtp.ConfigBuilder.build_opts(
host: String.trim(smtp_host_env),
port: smtp_port_env,
username: System.get_env("SMTP_USERNAME"),
password: smtp_password_env,
ssl_mode: smtp_ssl_mode,
verify_mode: verify_mode
)
config :mv, Mv.Mailer, smtp_opts
end
end

View file

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

@ -2,7 +2,7 @@
## Overview
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; 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, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
## Admin Bootstrap (Part A)
@ -10,13 +10,14 @@
### 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
- `Mv.Release.run_seeds/0` Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent.
- `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
@ -38,6 +39,7 @@
### 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

View file

@ -806,7 +806,7 @@ end
- **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/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. 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.
- **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):**

View file

@ -36,10 +36,10 @@
**Closed Issues:**
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13)
**Open Issues:**
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
**Open Issues:** (none remaining for Authentication UI)
**Current State:**
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
@ -49,6 +49,11 @@
- ✅ **Page-level authorization** - LiveView page access control
- ✅ **System role protection** - Critical roles cannot be deleted
**Planned: OIDC-only mode (TDD, tests first):**
- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`).
- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`).
- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests.
**Missing Features:**
- ❌ Password reset flow
- ❌ Email verification
@ -270,6 +275,9 @@
**Open Issues:**
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Implemented Features:**
- ✅ **SMTP configuration** Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
**Missing Features:**
- ❌ Email templates configuration
- ❌ System health dashboard
@ -287,6 +295,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
- ⚠️ No member communication features
**Missing Features:**

View file

@ -93,6 +93,7 @@
- **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.
@ -115,7 +116,7 @@ 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 with all data (typed fields + `form_data`), actions Approve / Reject.
- **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)
@ -195,7 +196,7 @@ Implementation spec for Subtask 5.
- **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** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
- **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**.

View file

@ -0,0 +1,44 @@
# Settings page Authentication section (ASCII mockup)
Structure after renaming "OIDC" to "Authentication" and adding the registration toggle.
Subsections use their own headings (h3) inside the main "Authentication" form_section.
+------------------------------------------------------------------+
| Settings |
| Manage global settings for the association. |
+------------------------------------------------------------------+
+-- Club Settings -------------------------------------------------+
| Association Name: [________________] [Save Name] |
+------------------------------------------------------------------+
+-- Join Form -----------------------------------------------------+
| ... (unchanged) |
+------------------------------------------------------------------+
+-- SMTP / E-Mail -------------------------------------------------+
| ... |
+------------------------------------------------------------------+
+-- Accounting-Software (Vereinfacht) Integration -----------------+
| ... |
+------------------------------------------------------------------+
+-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)")
| |
| Direct registration | <-- subsection heading (h3)
| [x] Allow direct registration (/register) |
| If disabled, users cannot sign up via /register; sign-in |
| and the join form remain available. |
| |
| OIDC (Single Sign-On) | <-- subsection heading (h3)
| (Some values are set via environment variables...) |
| Client ID: [________________] |
| Base URL: [________________] |
| Redirect URI: [________________] |
| Client Secret: [________________] (set) |
| Admin group name: [________________] |
| Groups claim: [________________] |
| [ ] Only OIDC sign-in (hide password login) |
| [Save OIDC Settings] |
+------------------------------------------------------------------+

View file

@ -0,0 +1,133 @@
# SMTP Configuration Concept
**Status:** Implemented
**Last updated:** 2026-03-12
---
## 1. Goal
Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
---
## 2. Scope
- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
- **Out of scope:** Separate adapters per email type; retry queues.
---
## 3. Configuration Sources
| Source | Priority | Use case |
|----------|----------|-----------------------------------|
| ENV | 1 | Production, Docker, 12-factor |
| Settings | 2 | Admin UI, dev without ENV |
When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment").
---
## 4. SMTP Parameters
| Parameter | ENV | Settings attribute | Notes |
|----------------|------------------------|---------------------|---------------------------------------------|
| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4).
---
## 5. Password from File
Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
---
## 6. Behaviour When SMTP Is Not Configured
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
- **Production:** If neither ENV nor Settings provide SMTP (no host):
- Show a warning in the Settings UI.
- Delivery attempts silently fall back to the Local adapter (no crash).
---
## 7. Test Email (Settings UI)
- **Location:** SMTP / E-Mail section in Global Settings.
- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
- **Permission:** Reuses existing Settings page authorization (admin).
---
## 8. Sender Identity (`mail_from`)
`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
---
## 9. Join Confirmation Email
`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
---
## 10. AshAuthentication Senders
Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
---
## 11. TLS / SSL in OTP 27
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification:
- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config.
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only).
---
## 12. Summary Checklist
- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
- [x] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465).
- [x] Prod warning: clear message in Settings when SMTP is not configured.
- [x] Test email: form with recipient field, translatable content, classified success/error messages.
- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin.
- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
- [x] Gettext for all new UI strings, translated to German.
- [x] Docs and code guidelines updated.
---
## 13. Follow-up / Future Work
- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue.
- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used.

View file

@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# When OIDC-only is active, password sign-in is forbidden (SSO only).
policy action(:sign_in_with_password) do
forbid_if Mv.Authorization.Checks.OidcOnlyActive
authorize_if always()
end
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
@ -405,6 +411,14 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8"
# Block direct registration when disabled in global settings
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
where: [action_is(:register_with_password)]
# Block password registration when OIDC-only mode is active
validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
where: [action_is(:register_with_password)]
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember

View file

@ -0,0 +1,27 @@
defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
@moduledoc """
Validation that blocks direct registration (register_with_password) when
OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
only allowed via OIDC (SSO).
"""
use Ash.Resource.Validation
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
if Mv.Config.oidc_only?() do
{:error,
field: :base,
message:
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration with password is disabled when only OIDC sign-in is active."
)}
else
:ok
end
end
end

View file

@ -0,0 +1,31 @@
defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
@moduledoc """
Validation that blocks direct registration (register_with_password) when
registration is disabled in global settings. Used so that even direct API/form
submissions cannot register when the setting is off.
"""
use Ash.Resource.Validation
alias Mv.Membership
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
case Membership.get_settings() do
{:ok, %{registration_enabled: true}} ->
:ok
_ ->
{:error,
field: :base,
message:
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration is disabled. Please use the join form or contact an administrator."
)}
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Mv.Membership.JoinNotifier do
@moduledoc """
Behaviour for sending join-related emails (confirmation, already member, already pending).
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
does not depend on the web layer. The default implementation is set in config
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
"""
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
{:ok, term()} | {:error, term()}
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
end

View file

@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do
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
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
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

View file

@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
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,

View file

@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
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

@ -0,0 +1,33 @@
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
@moduledoc """
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
Used when the user submits the join form again with the same email while a request
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
now = DateTime.utc_now()
expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(
:confirmation_token_hash,
JoinRequest.hash_confirmation_token(token)
)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
else
changeset
end
end
end

View file

@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
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,

View file

@ -29,8 +29,10 @@ 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 MvWeb.Emails.JoinConfirmationEmail
alias Mv.Membership.Member
alias Mv.Membership.SettingsCache
require Logger
admin do
@ -114,10 +116,16 @@ defmodule Mv.Membership do
"""
def get_settings do
# Try to get the first (and only) settings record
case Process.whereis(SettingsCache) do
nil -> get_settings_uncached()
_pid -> SettingsCache.get()
end
end
@doc false
def get_settings_uncached do
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} ->
# No settings exist - create as fallback (should normally be created via seed script)
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
@ -158,9 +166,16 @@ defmodule Mv.Membership do
"""
def update_settings(settings, attrs) do
settings
case settings
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
|> Ash.update(domain: __MODULE__) do
{:ok, _updated} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
@ -224,11 +239,18 @@ defmodule Mv.Membership do
"""
def update_member_field_visibility(settings, visibility_config) do
settings
case settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
@ -261,12 +283,19 @@ defmodule Mv.Membership do
field: field,
show_in_overview: show_in_overview
) do
settings
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__)
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
@ -300,13 +329,20 @@ defmodule Mv.Membership do
show_in_overview: show_in_overview,
required: required
) do
settings
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__)
|> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end
@doc """
@ -364,15 +400,131 @@ defmodule Mv.Membership do
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation
- `{: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)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
email = normalize_submit_email(attrs)
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
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,
@ -381,8 +533,9 @@ defmodule Mv.Membership do
domain: __MODULE__
) do
{:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do
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} ->
@ -390,8 +543,7 @@ defmodule Mv.Membership do
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
)
# Request was created; return success so the user sees the confirmation message
{:ok, request}
{:error, :email_delivery_failed}
end
error ->

View file

@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do
(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
@ -56,14 +57,20 @@ defmodule Mv.Membership.Setting do
# Update membership fee settings
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
"""
# primary_read_warning?: false — We use a custom read prepare that selects only public
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
# not load all attributes; we intentionally omit the password for security.
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
primary_read_warning?: false
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
alias Ash.Resource.Info, as: ResourceInfo
postgres do
table "settings"
repo Mv.Repo
@ -73,8 +80,27 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
# read only via explicit select when needed; never loaded into default get_settings().
@excluded_from_read [:smtp_password, :oidc_client_secret]
actions do
defaults [:read]
read :read do
primary? true
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
# them via explicit select when needed. Uses all attribute names minus excluded so
# the list stays correct when new attributes are added to the resource.
prepare fn query, _context ->
select_attrs =
__MODULE__
|> ResourceInfo.attribute_names()
|> MapSet.to_list()
|> Kernel.--(@excluded_from_read)
Ash.Query.select(query, select_attrs)
end
end
# Internal create action - not exposed via code interface
# Used only as fallback in get_settings/0 if settings don't exist
@ -97,6 +123,14 @@ defmodule Mv.Membership.Setting do
: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
@ -126,6 +160,14 @@ defmodule Mv.Membership.Setting do
: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
@ -429,6 +471,61 @@ defmodule Mv.Membership.Setting do
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

View file

@ -0,0 +1,85 @@
defmodule Mv.Membership.SettingsCache do
@moduledoc """
Process-based cache for global settings to avoid repeated DB reads on hot paths
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
update so that changes take effect quickly. If no settings process exists
(e.g. in tests), get/1 falls back to direct read.
"""
use GenServer
@default_ttl_seconds 60
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
"""
def get do
case Process.whereis(__MODULE__) do
nil ->
# No cache process (e.g. test) read directly
do_fetch()
_pid ->
GenServer.call(__MODULE__, :get, 10_000)
end
end
@doc """
Invalidates the cache so the next get/0 will refetch from the database.
Call after update_settings and any other path that mutates settings.
"""
def invalidate do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> GenServer.cast(__MODULE__, :invalidate)
end
end
@impl true
def init(opts) do
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
{:ok, state}
end
@impl true
def handle_call(:get, _from, state) do
now = System.monotonic_time(:second)
expired? = state.expires_at == nil or state.expires_at <= now
{result, new_state} =
if expired? do
fetch_and_cache(now, state)
else
{{:ok, state.cached}, state}
end
{:reply, result, new_state}
end
defp fetch_and_cache(now, state) do
case do_fetch() do
{:ok, settings} = ok ->
expires = now + state.ttl_seconds
{ok, %{state | cached: settings, expires_at: expires}}
err ->
result = if state.cached, do: {:ok, state.cached}, else: err
{result, state}
end
end
@impl true
def handle_cast(:invalidate, state) do
{:noreply, %{state | cached: nil, expires_at: nil}}
end
defp do_fetch do
Mv.Membership.get_settings_uncached()
end
end

View file

@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger
alias Mv.Mailer
@doc """
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
"""
@impl true
def send(user, token, _) do
@ -44,12 +47,24 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("user_confirmation.html", assigns)
|> Mailer.deliver!()
case Mailer.deliver(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error(
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
)
:ok
end
end
end

View file

@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger
alias Mv.Mailer
@doc """
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
- `_opts` - Additional options (unused)
## Returns
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
`:ok` always. Delivery errors are logged and not re-raised so they do not
crash the caller process (AshAuthentication ignores the return value).
"""
@impl true
def send(user, token, _) do
@ -44,12 +47,21 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("password_reset.html", assigns)
|> Mailer.deliver!()
case Mailer.deliver(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
:ok
end
end
end

View file

@ -6,6 +6,7 @@ defmodule Mv.Application do
use Application
alias Mv.Helpers.SystemActor
alias Mv.Membership.SettingsCache
alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint
@ -16,9 +17,17 @@ defmodule Mv.Application do
def start(_type, _args) do
SyncFlash.create_table!()
children = [
# 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,
Repo
] ++
cache_children ++
[
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},

View file

@ -0,0 +1,16 @@
defmodule Mv.Authorization.Checks.OidcOnlyActive do
@moduledoc """
Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
"""
use Ash.Policy.SimpleCheck
alias Mv.Config
@impl true
def describe(_opts), do: "OIDC-only mode is active"
@impl true
def match?(_actor, _context, _opts), do: Config.oidc_only?()
end

View file

@ -362,26 +362,41 @@ defmodule Mv.Config do
@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.
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))
_ -> env_or_setting("OIDC_CLIENT_SECRET", :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: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
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: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
end
defp oidc_client_secret_from_config(_),
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
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.
@ -449,4 +464,206 @@ defmodule Mv.Config do
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

@ -4,16 +4,188 @@ defmodule Mv.Mailer do
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
@doc """
Returns the configured "from" address for transactional emails.
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
Default: `{"Mila", "noreply@example.com"}`.
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
Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
{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

@ -6,8 +6,8 @@ defmodule Mv.Release do
## Tasks
- `migrate/0` - Runs all pending Ecto migrations.
- `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings).
In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data).
- `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,6 +19,7 @@ defmodule Mv.Release do
alias Mv.Authorization.Role
require Ash.Query
require Logger
def migrate do
load_app()
@ -28,13 +29,37 @@ 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).
- Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings).
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample 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). Idempotent.
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
@ -42,6 +67,9 @@ defmodule Mv.Release do
{: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")
@ -61,6 +89,7 @@ defmodule Mv.Release do
Code.compiler_options(prev)
end
end
end
def rollback(repo, version) do
load_app()

View file

@ -0,0 +1,58 @@
defmodule Mv.Smtp.ConfigBuilder do
@moduledoc """
Builds Swoosh/gen_smtp SMTP adapter options from connection parameters.
Single source of truth for TLS/sockopts logic (port 587 vs 465):
- Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`.
- Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`.
Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only).
"""
@doc """
Builds the keyword list of Swoosh SMTP adapter options.
Options (keyword list):
- `:host` (required) relay hostname
- `:port` (required) port number (e.g. 587 or 465)
- `:ssl_mode` (required) `"tls"` or `"ssl"`
- `:verify_mode` (required) `:verify_peer` or `:verify_none`
- `:username` (optional)
- `:password` (optional)
Nil values are stripped from the result.
"""
@spec build_opts(keyword()) :: keyword()
def build_opts(opts) do
host = Keyword.fetch!(opts, :host)
port = Keyword.fetch!(opts, :port)
username = Keyword.get(opts, :username)
password = Keyword.get(opts, :password)
ssl_mode = Keyword.fetch!(opts, :ssl_mode)
verify_mode = Keyword.fetch!(opts, :verify_mode)
base_opts = [
adapter: Swoosh.Adapters.SMTP,
relay: host,
port: port,
username: username,
password: password,
ssl: ssl_mode == "ssl",
tls: if(ssl_mode == "tls", do: :always, else: :never),
auth: :always,
# tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect.
tls_options: [verify: verify_mode]
]
# Port 465: initial connection is ssl:connect; pass verify in sockopts.
# Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it).
opts =
if ssl_mode == "ssl" do
Keyword.put(base_opts, :sockopts, verify: verify_mode)
else
base_opts
end
Enum.reject(opts, fn {_k, v} -> is_nil(v) end)
end
end

View file

@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do
UI customizations for AshAuthentication Phoenix components.
## Overrides
- `SignIn` - Restricts form width to prevent full-width display
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- `HorizontalRule` - Translates "or" text to German
- `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
- `Banner` - Replaces default logo with text for reset/confirm pages
- `Flash` - Hides library flash (we use flash_group in root layout)
## Documentation
For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
"""
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
# configure your UI overrides here
# First argument to `override` is the component name you are overriding.
# The body contains any number of configurations you wish to override
# Below are some examples
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
# override AshAuthentication.Phoenix.Components.Banner do
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
# set :text_class, "bg-red-500"
# end
# Avoid full-width for the Sign In Form
# Avoid full-width for the Sign In Form.
# Banner is hidden because SignInLive renders its own locale-aware title.
override AshAuthentication.Phoenix.Components.SignIn do
set :root_class, "md:min-w-md"
set :show_banner, false
end
# Replace banner logo with text (no image in light or dark so link has discernible text)
# Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
set :dark_image_url, nil
end
# Translate the "or" in the horizontal rule (between password form and SSO).
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, dgettext("auth", "or")
end
# Hide AshAuthentication's Flash component since we use flash_group in root layout
# This prevents duplicate flash messages
# Hide AshAuthentication's Flash component since we use flash_group in root layout.
# This prevents duplicate flash messages.
override AshAuthentication.Phoenix.Components.Flash do
set :message_class_info, "hidden"
set :message_class_error, "hidden"
end
end
defmodule MvWeb.AuthOverridesRegistrationDisabled do
@moduledoc """
When direct registration is disabled in global settings, this override is
prepended in SignInLive so the Password component hides the "Need an account?"
toggle (register_toggle_text: nil disables the register link per library docs).
"""
use AshAuthentication.Phoenix.Overrides
override AshAuthentication.Phoenix.Components.Password do
set :register_toggle_text, nil
end
end
defmodule MvWeb.AuthOverridesDE do
@moduledoc """
German locale-specific overrides for AshAuthentication Phoenix components.
Prepended to the overrides list in SignInLive when the locale is "de".
Provides runtime-static German text for components that do not use
the `_gettext` mechanism (e.g. HorizontalRule renders its text directly),
and for submit buttons whose disable_text bypasses the POT extraction pipeline.
"""
use AshAuthentication.Phoenix.Overrides
# HorizontalRule renders text without `_gettext`, so we need a static German string.
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, "oder"
end
# Registering ... disable-text is passed through _gettext but "Registering ..."
# has no dgettext source reference, so we supply the German string directly.
override AshAuthentication.Phoenix.Components.Password.RegisterForm do
set :disable_button_text, "Registrieren..."
end
end

View file

@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :auto_clear_ms, :integer,
default: nil,
doc:
"when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -74,6 +79,9 @@ 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"
@ -1295,6 +1303,41 @@ defmodule MvWeb.CoreComponents do
"""
end
@doc """
Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect).
Wired to the theme script in root layout: checkbox uses `data-theme-toggle`,
root script syncs checked state (checked = dark) and listens for `phx:set-theme`.
Use in public header or sidebar. Optional `class` is applied to the wrapper.
"""
attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper"
def theme_swap(assigns) do
assigns = assign(assigns, :wrapper_class, assigns[:class])
~H"""
<div class={[@wrapper_class]}>
<label
class="swap swap-rotate cursor-pointer focus-within:outline-none focus-within:focus-visible:ring-2 focus-within:focus-visible:ring-primary focus-within:focus-visible:ring-offset-2 rounded"
aria-label={gettext("Toggle dark mode")}
>
<input
type="checkbox"
data-theme-toggle
aria-label={gettext("Toggle dark mode")}
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
/>
<span class="swap-on size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-moon" class="size-5" />
</span>
<span class="swap-off size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-sun" class="size-5" />
</span>
</label>
</div>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).

View file

@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do
embed_templates "layouts/*"
@doc """
Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club".
Order is always: Mila · page title · club name.
Uses assigns[:club_name] and the short page label from assigns[:content_title] or
assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar)
and then assign page_title to the result of this function so the client receives
the full title.
"""
def page_title_string(assigns) do
club = assigns[:club_name]
page = assigns[:content_title] || assigns[:page_title]
parts =
[page, club]
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
if parts == [] do
"Mila"
else
"Mila · " <> Enum.join(parts, " · ")
end
end
@doc """
Assigns content_title (short label for heading; same gettext as sidebar) and
page_title (full browser tab title). Call from LiveView mount after club_name
is set (e.g. from on_mount). Returns the socket.
"""
def assign_page_title(socket, content_title) do
socket = assign(socket, :content_title, content_title)
assign(socket, :page_title, page_title_string(socket.assigns))
end
@doc """
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
share the same chrome without the sidebar or authenticated layout logic.
Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component.
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :club_name, :string,
default: nil,
doc: "optional; if set, avoids get_settings() in the component"
slot :inner_block, required: true
def public_page(assigns) do
club_name =
assigns[:club_name] ||
case Mv.Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="text-lg font-bold truncate">Mitgliederverwaltung</span>
</div>
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<div class="shrink-0 flex items-center gap-2">
<form method="post" action={~p"/set_locale"}>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<.theme_swap />
</div>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto max-full space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Renders the app layout. Can be used with or without a current_user.
When current_user is present, it will show the navigation bar.
@ -43,11 +135,11 @@ defmodule MvWeb.Layouts do
slot :inner_block, required: true
def app(assigns) do
club_name = get_club_name()
join_form_enabled = Mv.Membership.join_form_enabled?()
# 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: get_join_form_enabled and unprocessed count run on every page load; consider
# loading count only on navigation or caching briefly if performance becomes an issue.
# 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)
@ -99,13 +191,17 @@ defmodule MvWeb.Layouts do
</div>
</div>
<% else %>
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<!-- 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 flex-1">
<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>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<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"
@ -113,10 +209,12 @@ defmodule MvWeb.Layouts do
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() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<.theme_swap />
</div>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
@ -129,12 +227,17 @@ defmodule MvWeb.Layouts do
"""
end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
defp get_layout_settings do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
{:ok, settings} ->
%{
club_name: settings.club_name || "Mitgliederverwaltung",
join_form_enabled: settings.join_form_enabled == true
}
_ ->
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
end
end
@ -162,7 +265,7 @@ defmodule MvWeb.Layouts do
aria-live="polite"
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
>
<.flash kind={:success} flash={@flash} />
<.flash kind={:success} flash={@flash} auto_clear_ms={5000} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />

View file

@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
<.live_title default="Mv" suffix=" · Phoenix Framework">
{assigns[:page_title]}
<.live_title default="Mila">
{page_title_string(assigns)}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
@ -74,7 +74,7 @@
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} />
<.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} />

View file

@ -251,8 +251,10 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_footer(assigns) do
~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only">
<!-- 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"
@ -260,12 +262,11 @@ defmodule MvWeb.Layouts.Sidebar do
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>
<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 Toggle (immer sichtbar) -->
<.theme_toggle />
</div>
<!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %>
<.user_menu current_user={@current_user} />
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
"""
end
defp theme_toggle(assigns) do
~H"""
<label
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
aria-label={gettext("Toggle dark mode")}
>
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
<div id="theme-toggle" phx-update="ignore">
<input
id="theme-toggle-input"
type="checkbox"
class="toggle toggle-sm focus:outline-none"
data-theme-toggle
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
aria-label={gettext("Toggle dark mode")}
/>
</div>
<.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,8 +15,23 @@ defmodule MvWeb.AuthController do
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Config
def success(conn, activity, user, _token) do
def success(conn, {:password, :sign_in} = _activity, user, token) do
if Config.oidc_only?() do
conn
|> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
|> redirect(to: sign_in_path_after_oidc_failure())
else
success_continue(conn, {:password, :sign_in}, user, token)
end
end
def success(conn, activity, user, token) do
success_continue(conn, activity, user, token)
end
defp success_continue(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
message =
@ -134,7 +149,7 @@ defmodule MvWeb.AuthController do
_ ->
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path_after_oidc_failure())
end
end
@ -148,7 +163,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle Assent invalid response errors (configuration or malformed responses)
@ -161,7 +176,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path_after_oidc_failure())
end
# Catch-all clause for any other error types
@ -171,7 +186,7 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle generic AuthenticationFailed errors
@ -211,10 +226,14 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
|> redirect(to: sign_in_path_after_oidc_failure())
end
end
# Path used when redirecting to sign-in after an OIDC failure. The query param tells
# OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
# Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors

View file

@ -2,11 +2,14 @@ defmodule MvWeb.JoinConfirmController do
@moduledoc """
Handles GET /confirm_join/:token for the public join flow (double opt-in).
Calls a configurable callback (default Mv.Membership) so tests can stub the
dependency. Public route; no authentication required.
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)
@ -26,20 +29,36 @@ defmodule MvWeb.JoinConfirmController do
defp success_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("Thank you, we have received your request."))
|> assign_confirm_assigns(:success)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end
defp expired_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("This link has expired. Please submit the form again."))
|> assign_confirm_assigns(:expired)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end
defp invalid_response(conn) do
conn
|> put_resp_content_type("text/html")
|> put_status(404)
|> send_resp(404, gettext("Invalid or expired link."))
|> 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

@ -0,0 +1,9 @@
defmodule MvWeb.JoinConfirmHTML do
@moduledoc """
Renders join confirmation result pages (success, expired, invalid) with
public header and hero layout. Used by JoinConfirmController.
"""
use MvWeb, :html
embed_templates "join_confirm_html/*"
end

View file

@ -0,0 +1,45 @@
<Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-md">
<%= case @result do %>
<% :success -> %>
<h1 class="text-3xl font-bold">
{gettext("Thank you")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Thank you, we have received your request.")}
</p>
<p class="text-sm text-base-content/70">
{gettext("You will receive an email once your application has been reviewed.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Back to join form")}
</a>
<% :expired -> %>
<h1 class="text-3xl font-bold">
{gettext("Link expired")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("This link has expired. Please submit the form again.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Submit new request")}
</a>
<% :invalid -> %>
<h1 class="text-3xl font-bold text-error">
{gettext("Invalid or expired link")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Invalid or expired link.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Go to join form")}
</a>
<% end %>
</div>
</div>
</div>
</div>
</Layouts.public_page>

View file

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

View file

@ -0,0 +1,42 @@
defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that is already a member.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout.
"""
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
alias Mv.Mailer
@doc """
Sends the "already a member" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already a member")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_member.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that already
has a submitted (confirmed) application under review.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout.
"""
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
alias Mv.Mailer
@doc """
Sends the "application already under review" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already under review")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_pending.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -15,13 +15,19 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
@doc """
Sends the join confirmation email to the given address with the confirmation link.
Called from the domain after a JoinRequest is created (submit flow).
Uses the same SMTP configuration as the test mail (Settings or boot ENV) via
`Mailer.deliver/2` with `Mailer.smtp_config/0` for consistency.
Called from the domain after a JoinRequest is created (submit flow) or when
resending to an existing pending request.
## Options
- `:resend` - If true, adds a short note that the link is being sent again for an existing request.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
Callers should log errors and may still return success for the overall operation
(e.g. join request created) so the user is not shown a generic error when only
the email failed.
"""
def send(email_address, token) when is_binary(email_address) and is_binary(token) do
def send(email_address, token, opts \\ [])
when is_binary(email_address) and is_binary(token) do
confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request")
@ -29,15 +35,18 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
locale: Gettext.get_locale(MvWeb.Gettext),
resend: Keyword.get(opts, :resend, false)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_confirmation.html", assigns)
|> Mailer.deliver()
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
@moduledoc """
Centralized date formatting helper for the application.
Formats dates in European format (dd.mm.yyyy).
DateTime can be shown in UTC or in a given IANA timezone (e.g. from browser).
"""
use Gettext, backend: MvWeb.Gettext
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
@doc """
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
When `timezone` is a valid IANA timezone string (e.g. from the browser),
the datetime is converted to that zone before formatting. When `timezone` is
nil or invalid, the datetime is formatted in UTC.
## Examples
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
"15.03.2024 10:30"
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z], "Europe/Berlin")
"15.03.2024 11:30"
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
""
"""
def format_datetime(%DateTime{} = dt) do
def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
def format_datetime(nil), do: ""
def format_datetime(_), do: "Invalid datetime"
def format_datetime(%DateTime{} = dt, nil), do: format_datetime_utc(dt)
def format_datetime(%DateTime{} = dt, ""), do: format_datetime_utc(dt)
def format_datetime(%DateTime{} = dt, tz) when is_binary(tz) do
case DateTime.shift_zone(dt, tz, Tz.TimeZoneDatabase) do
{:ok, shifted} -> Calendar.strftime(shifted, "%d.%m.%Y %H:%M")
{:error, _} -> format_datetime_utc(dt)
end
end
def format_datetime(nil, _timezone), do: ""
def format_datetime(_, _timezone), do: "Invalid datetime"
defp format_datetime_utc(%DateTime{} = dt) do
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
end
def format_datetime(nil), do: ""
def format_datetime(_), do: "Invalid datetime"
end

View file

@ -0,0 +1,25 @@
defmodule MvWeb.JoinNotifierImpl do
@moduledoc """
Default implementation of Mv.Membership.JoinNotifier that delegates to MvWeb.Emails.
"""
@behaviour Mv.Membership.JoinNotifier
alias MvWeb.Emails.JoinAlreadyMemberEmail
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
@impl true
def send_confirmation(email, token, opts \\ []) do
JoinConfirmationEmail.send(email, token, opts)
end
@impl true
def send_already_member(email) do
JoinAlreadyMemberEmail.send(email)
end
@impl true
def send_already_pending(email) do
JoinAlreadyPendingEmail.send(email)
end
end

View file

@ -1,28 +1,61 @@
defmodule MvWeb.SignInLive do
@moduledoc """
Custom sign-in page with language selector and conditional Single Sign-On button.
Custom sign-in page with public header and hero layout (same as Join/Join Confirm).
- Renders a language selector (same pattern as LinkOidcAccountLive).
- Wraps the default AshAuthentication SignIn component in a container with
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
Uses Layouts.public_page (no sidebar, no app-layout hooks). Wraps the AshAuthentication
SignIn component in a hero section. Container has data-oidc-configured so CSS can hide
the SSO button when OIDC is not configured.
Keeps `use Phoenix.LiveView` (not MvWeb :live_view) so AshAuthentication's sign_in_route
live_session on_mount chain is not mixed with LiveHelpers hooks.
## Locale overrides
`MvWeb.AuthOverridesDE` is prepended to the overrides list when the locale is "de",
providing static German strings for components that do not use `_gettext` internally
(e.g. HorizontalRule renders its `:text` override directly).
"""
use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext
alias AshAuthentication.Phoenix.Components
alias Mv.Config
alias Mv.Membership
alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
@impl true
def mount(_params, session, socket) do
overrides =
session
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
locale =
session["locale"] || Application.get_env(:mv, :default_locale, "de")
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
# Set both backend-specific and global locale so Gettext.get_locale/0 and
# Gettext.get_locale/1 both return the correct value (important for the
# language-selector `selected` attribute in Layouts.public_page).
Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
# Prepend DE-specific overrides when locale is German so that components
# without _gettext support (e.g. HorizontalRule) still render in German.
base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default])
locale_overrides = if locale == "de", do: [AuthOverridesDE], else: []
registration_disabled =
if session["registration_enabled"] == false,
do: [AuthOverridesRegistrationDisabled],
else: []
# When registration is disabled: hide register link (register_path: nil) and hide
# "Need an account?" toggle (override register_toggle_text: nil so it takes precedence).
overrides = registration_disabled ++ locale_overrides ++ base_overrides
register_path =
if session["registration_enabled"] == false, do: nil, else: session["register_path"]
# Club name and page title for browser tab (root layout: Mila · Club · Page)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket =
socket
@ -30,18 +63,19 @@ defmodule MvWeb.SignInLive do
|> assign_new(:otp_app, fn -> nil end)
|> assign(:path, session["path"] || "/")
|> assign(:reset_path, session["reset_path"])
|> assign(:register_path, session["register_path"])
|> assign(:register_path, register_path)
|> assign(:current_tenant, session["tenant"])
|> assign(:resources, session["resources"])
|> assign(:context, session["context"] || %{})
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|> assign(:gettext_fn, session["gettext_fn"])
|> assign(:live_action, :sign_in)
|> assign_new(:live_action, fn -> :sign_in end)
|> assign(:oidc_configured, Config.oidc_configured?())
|> assign(:oidc_only, Config.oidc_only?())
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|> assign(:sign_in_id, "sign-in")
|> assign(:locale, locale)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(gettext("Sign in"))
{:ok, socket}
end
@ -54,34 +88,23 @@ defmodule MvWeb.SignInLive do
@impl true
def render(assigns) do
~H"""
<main
<Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div
class="hero min-h-[60vh] bg-base-200 rounded-lg"
id="sign-in-page"
role="main"
class={@root_class}
data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)}
data-locale={@locale}
>
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
<%!-- Language selector --%>
<nav
aria-label={dgettext("auth", "Language selection")}
class="absolute top-4 right-4 flex justify-end z-10"
>
<form method="post" action="/set_locale" class="text-sm">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm select-bordered bg-base-100"
aria-label={dgettext("auth", "Select language")}
>
<option value="de" selected={@locale == "de"}>Deutsch</option>
<option value="en" selected={@locale == "en"}>English</option>
</select>
</form>
</nav>
<div class="hero-content flex-col items-start text-left">
<div class="w-full max-w-md">
<h1 class="text-xl font-semibold leading-8">
{if @live_action == :register,
do: dgettext("auth", "Register"),
else: dgettext("auth", "Sign in")}
</h1>
<.live_component
module={Components.SignIn}
otp_app={@otp_app}
@ -97,7 +120,11 @@ defmodule MvWeb.SignInLive do
context={@context}
gettext_fn={@gettext_fn}
/>
</main>
</div>
</div>
</div>
</div>
</Layouts.public_page>
"""
end
end

View file

@ -17,7 +17,7 @@ defmodule MvWeb.DatafieldsLive do
{:ok,
socket
|> assign(:page_title, gettext("Datafields"))
|> Layouts.assign_page_title(gettext("Datafields"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
@ -50,7 +50,7 @@ defmodule MvWeb.DatafieldsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header>
{gettext("Datafields")}
{@content_title}
<:subtitle>
{gettext(
"Configure which data you want to save for your members. Define individual datafields."

View file

@ -11,12 +11,15 @@ defmodule MvWeb.GlobalSettingsLive do
## Settings
- `club_name` - The name of the association/club (required)
- `registration_enabled` - Whether direct registration via /register is allowed
- `join_form_enabled` - Whether the public /join page is active
- `join_form_field_ids` - Ordered list of field IDs shown on the join form
- `join_form_field_required` - Map of field ID => required boolean
## Events
- `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
- `toggle_oidc_only` - Enable/disable OIDC-only sign-in (immediate, outside OIDC form)
- `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field
@ -54,11 +57,14 @@ defmodule MvWeb.GlobalSettingsLive do
actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor)
environment = Application.get_env(:mv, :environment, :dev)
socket =
socket
|> assign(:page_title, gettext("Settings"))
|> Layouts.assign_page_title(gettext("Basic settings"))
|> assign(:settings, settings)
|> assign(:locale, locale)
|> assign(:environment, environment)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
@ -75,9 +81,24 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:registration_enabled, settings.registration_enabled != false)
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
|> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
|> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
|> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_test_result, nil)
|> assign(:smtp_test_to_email, "")
|> assign_join_form_state(settings, custom_fields)
|> assign(:join_url, url(socket.endpoint, ~p"/join"))
|> assign_form()
{:ok, socket}
@ -93,12 +114,13 @@ defmodule MvWeb.GlobalSettingsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header>
{gettext("Settings")}
{gettext("Basic settings")}
<:subtitle>
{gettext("Manage global settings for the association.")}
</:subtitle>
</.header>
<div class="mt-6 space-y-6 max-w-4xl px-4">
<%!-- Club Settings Section --%>
<.form_section title={gettext("Club Settings")}>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
@ -119,7 +141,9 @@ defmodule MvWeb.GlobalSettingsLive do
<%!-- Join Form Section (Beitrittsformular) --%>
<.form_section title={gettext("Join Form")}>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Configure the public join form that allows new members to submit a join request.")}
{gettext(
"Configure the public join form that allows new members to submit a join request."
)}
</p>
<%!-- Enable/disable --%>
@ -137,22 +161,34 @@ defmodule MvWeb.GlobalSettingsLive do
</label>
</div>
<%!-- Board approval (future feature) --%>
<div class="flex items-center gap-3 mb-6">
<div :if={@join_form_enabled}>
<%!-- Copyable join page link (below checkbox, above field list) --%>
<div class="mb-4 p-3 rounded-lg border border-base-300 bg-base-200/50">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Link to the public join page (share this with applicants):")}
</p>
<div class="flex flex-wrap items-center gap-2">
<input
type="checkbox"
id="join-form-board-approval-checkbox"
class="checkbox checkbox-sm"
checked={false}
disabled
aria-label={gettext("Board approval required (in development)")}
type="text"
readonly
value={@join_url}
class="input input-bordered input-sm flex-1 min-w-0 font-mono text-sm"
aria-label={gettext("Join page URL")}
/>
<label for="join-form-board-approval-checkbox" class="text-base-content/60 font-medium">
{gettext("Board approval required (in development)")}
</label>
<.button
variant="secondary"
size="sm"
id="copy-join-url-btn"
phx-hook="CopyToClipboard"
phx-click="copy_join_url"
aria-label={gettext("Copy join page URL")}
>
<.icon name="hero-clipboard-document" class="size-4" />
{gettext("Copy")}
</.button>
</div>
</div>
<div :if={@join_form_enabled}>
<%!-- Field list header + Add button (left-aligned) --%>
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
@ -225,7 +261,7 @@ defmodule MvWeb.GlobalSettingsLive do
</p>
<%!-- Fields table (compact width, reorderable) --%>
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl">
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4">
<.sortable_table
id="join-form-fields-table"
rows={@join_form_fields}
@ -235,7 +271,11 @@ defmodule MvWeb.GlobalSettingsLive do
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
{field.label}
</:col>
<:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center">
<:col
:let={field}
label={gettext("Required")}
class="w-24 max-w-[9.375rem] text-center"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
@ -269,6 +309,180 @@ defmodule MvWeb.GlobalSettingsLive do
</div>
</div>
</.form_section>
<%!-- SMTP / E-Mail Section --%>
<.form_section title={gettext("SMTP / E-Mail")}>
<%= if @smtp_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<%= if @environment == :prod and not @smtp_configured do %>
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
)}
</span>
</div>
<% end %>
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
<div class="">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_5rem_1fr]">
<.input
field={@form[:smtp_host]}
type="text"
label={gettext("Host")}
disabled={@smtp_host_env_set}
placeholder={
if(@smtp_host_env_set,
do: gettext("From SMTP_HOST"),
else: "smtp.example.com"
)
}
/>
<.input
field={@form[:smtp_port]}
type="number"
label={gettext("Port")}
disabled={@smtp_port_env_set}
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
/>
<.input
field={@form[:smtp_ssl]}
type="select"
label={gettext("TLS/SSL")}
disabled={@smtp_ssl_env_set}
options={[
{gettext("TLS (port 587, recommended)"), "tls"},
{gettext("SSL (port 465)"), "ssl"},
{gettext("None (port 25, insecure)"), "none"}
]}
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
/>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<.input
field={@form[:smtp_username]}
type="text"
label={gettext("Username")}
disabled={@smtp_username_env_set}
placeholder={
if(@smtp_username_env_set,
do: gettext("From SMTP_USERNAME"),
else: "user@example.com"
)
}
/>
<.input
field={@form[:smtp_password]}
type="password"
label={gettext("Password")}
disabled={@smtp_password_env_set}
placeholder={
if(@smtp_password_env_set,
do: gettext("From SMTP_PASSWORD"),
else:
if(@smtp_password_set,
do: gettext("Leave blank to keep current"),
else: nil
)
)
}
/>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<.input
field={@form[:smtp_from_email]}
type="email"
label={gettext("Sender email (From)")}
disabled={@smtp_from_email_env_set}
placeholder={
if(@smtp_from_email_env_set,
do: gettext("From MAIL_FROM_EMAIL"),
else: "noreply@example.com"
)
}
/>
<.input
field={@form[:smtp_from_name]}
type="text"
label={gettext("Sender name (From)")}
disabled={@smtp_from_name_env_set}
placeholder={
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
}
/>
</div>
</div>
<p class="mb-3 text-sm text-base-content/60">
{gettext(
"The sender email must be owned by or authorized for the SMTP user on most servers."
)}
</p>
<.button
:if={
not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
@smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
@smtp_from_name_env_set)
}
phx-disable-with={gettext("Saving...")}
variant="primary"
class="mt-2"
>
{gettext("Save SMTP Settings")}
</.button>
</.form>
<%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
<div class="mt-6">
<h3 class="font-medium mb-3">{gettext("Test email")}</h3>
<.form
for={%{}}
id="smtp-test-email-form"
data-testid="smtp-test-email-form"
phx-submit="send_smtp_test_email"
class="space-y-3"
>
<div class="flex flex-wrap items-end gap-3">
<fieldset class="fieldset">
<label>
<span class="mb-1 label">{gettext("Recipient")}</span>
<input
id="smtp-test-to-email"
type="email"
name="to_email"
data-testid="smtp-test-email-input"
value={@smtp_test_to_email}
class="w-full input input-bordered"
placeholder="test@example.com"
phx-change="update_smtp_test_to_email"
/>
</label>
</fieldset>
<.button
type="submit"
variant="secondary"
class="mb-1"
data-testid="smtp-send-test-email"
phx-disable-with={gettext("Sending...")}
>
{gettext("Send test email")}
</.button>
</div>
</.form>
<%= if @smtp_test_result do %>
<div data-testid="smtp-test-result">
<.smtp_test_result result={@smtp_test_result} />
</div>
<% end %>
</div>
</.form_section>
<%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
<%= if @vereinfacht_env_configured do %>
@ -290,19 +504,27 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
<div class="form-control">
<label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.input
field={@form[:vereinfacht_api_key]}
<input
type="password"
label=""
name={@form[:vereinfacht_api_key].name}
id={@form[:vereinfacht_api_key].id}
value={
Phoenix.HTML.Form.normalize_value("password", @form[:vereinfacht_api_key].value)
}
class={
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) &&
@form[:vereinfacht_api_key].errors != [],
do: "w-full input input-error",
else: "w-full input"
}
disabled={@vereinfacht_api_key_env_set}
placeholder={
if(@vereinfacht_api_key_env_set,
@ -315,7 +537,20 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
</div>
</label>
<%= for msg <- (
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) do
Enum.map(@form[:vereinfacht_api_key].errors, &MvWeb.CoreComponents.translate_error/1)
else
[]
end
) do %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
</fieldset>
<.input
field={@form[:vereinfacht_club_id]}
type="text"
@ -353,7 +588,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="outline"
variant="secondary"
phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")}
>
@ -362,7 +597,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="outline"
variant="secondary"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
>
@ -377,13 +612,85 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %>
</.form>
</.form_section>
<%!-- OIDC Section --%>
<.form_section title={gettext("OIDC (Single Sign-On)")}>
<%!-- Authentication: Direct registration + OIDC --%>
<.form_section title={gettext("Authentication")}>
<h3 class="font-medium mb-3">{gettext("Direct registration")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"If disabled, users cannot sign up via /register; sign-in and the join form remain available."
)}
</p>
<div class="flex items-center gap-3 mb-6">
<input
type="checkbox"
id="registration-enabled-checkbox"
class="checkbox checkbox-sm"
checked={@registration_enabled}
phx-click="toggle_registration_enabled"
disabled={@oidc_only}
aria-label={gettext("Allow direct registration (/register)")}
/>
<label
for="registration-enabled-checkbox"
class={
if @oidc_only, do: "cursor-not-allowed opacity-70", else: "cursor-pointer font-medium"
}
>
{gettext("Allow direct registration (/register)")}
</label>
<%= if @oidc_only do %>
<.tooltip
content={gettext("Only OIDC sign-in is active. This option is disabled.")}
position="top"
>
<span
data-testid="oidc-only-registration-hint"
class="cursor-help text-base-content/70"
>
</span>
</.tooltip>
<% end %>
</div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
<%= if @oidc_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<div class="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="oidc-only-checkbox"
data-testid="oidc-only-checkbox"
class="checkbox checkbox-sm"
checked={@oidc_only}
phx-click="toggle_oidc_only"
disabled={@oidc_only_env_set or not @oidc_configured}
aria-label={gettext("Only OIDC sign-in (hide password login)")}
/>
<label
for="oidc-only-checkbox"
class={
if @oidc_only_env_set or not @oidc_configured,
do: "cursor-not-allowed opacity-70",
else: "cursor-pointer font-medium"
}
>
{if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end}
</label>
</div>
<p class="label-text-alt text-base-content/70 mb-4">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.input
@ -419,19 +726,27 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
<div class="form-control">
<label class="label" for={@form[:oidc_client_secret].id}>
<span class="label-text">{gettext("Client Secret")}</span>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.input
field={@form[:oidc_client_secret]}
<input
type="password"
label=""
name={@form[:oidc_client_secret].name}
id={@form[:oidc_client_secret].id}
value={
Phoenix.HTML.Form.normalize_value("password", @form[:oidc_client_secret].value)
}
class={
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) &&
@form[:oidc_client_secret].errors != [],
do: "w-full input input-error",
else: "w-full input"
}
disabled={@oidc_client_secret_env_set}
placeholder={
if(@oidc_client_secret_env_set,
@ -444,7 +759,20 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
</div>
</label>
<%= for msg <- (
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) do
Enum.map(@form[:oidc_client_secret].errors, &MvWeb.CoreComponents.translate_error/1)
else
[]
end
) do %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
</fieldset>
<.input
field={@form[:oidc_admin_group_name]}
type="text"
@ -469,27 +797,6 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
<div class="form-control">
<.input
field={@form[:oidc_only]}
type="checkbox"
class="checkbox checkbox-sm"
disabled={@oidc_only_env_set or not @oidc_configured}
label={
if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end
}
/>
<p class="label-text-alt text-base-content/70 mt-1">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
</div>
</div>
<.button
:if={
@ -506,6 +813,7 @@ defmodule MvWeb.GlobalSettingsLive do
</.button>
</.form>
</.form_section>
</div>
</Layouts.app>
"""
end
@ -516,6 +824,27 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do
{:noreply, assign(socket, :smtp_test_to_email, email)}
end
@impl true
def handle_event("send_smtp_test_email", params, socket) do
to_email =
(params["to_email"] || socket.assigns.smtp_test_to_email || "")
|> String.trim()
result = Mv.Mailer.send_test_email(to_email)
{:noreply, assign(socket, :smtp_test_result, result)}
end
@impl true
def handle_event("test_vereinfacht_connection", _params, socket) do
result = Mv.Vereinfacht.test_connection()
@ -560,27 +889,35 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
# Never send blank API key / client secret so we do not overwrite stored secrets
# Never send blank API key / client secret / smtp password so we do not overwrite stored secrets
setting_params_clean =
setting_params
|> drop_blank_vereinfacht_api_key()
|> drop_blank_oidc_client_secret()
|> drop_blank_smtp_password()
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} ->
{:ok, fresh_settings} = Membership.get_settings()
{:ok, updated_settings} ->
# Use the returned record for the form so saved values show immediately;
# get_settings() can return cached data without the new attribute until reload.
test_result =
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
socket =
socket
|> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:settings, updated_settings)
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign(:vereinfacht_api_key_set, present?(updated_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|> assign(:vereinfacht_test_result, test_result)
|> put_flash(:success, gettext("Settings updated successfully"))
|> assign_form()
@ -594,12 +931,74 @@ defmodule MvWeb.GlobalSettingsLive do
# ---- Join form event handlers ----
@impl true
def handle_event("copy_join_url", _params, socket) do
socket =
socket
|> push_event("copy_to_clipboard", %{text: socket.assigns.join_url})
|> put_flash(:success, gettext("Join page URL copied to clipboard."))
{:noreply, socket}
end
@impl true
def handle_event("toggle_join_form_enabled", _params, socket) do
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
{:noreply, persist_join_form_settings(socket)}
end
@impl true
def handle_event("toggle_registration_enabled", _params, socket) do
if Mv.Config.oidc_only?() do
{:noreply, socket}
else
settings = socket.assigns.settings
new_value = not socket.assigns.registration_enabled
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
{:ok, updated_settings} ->
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign_form()}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
end
end
end
@impl true
def handle_event("toggle_oidc_only", _params, socket) do
if socket.assigns.oidc_only_env_set do
{:noreply, socket}
else
settings = socket.assigns.settings
new_value = not socket.assigns.oidc_only
# When enabling OIDC-only, also disable direct registration; when disabling, only change oidc_only.
params =
if new_value,
do: %{oidc_only: true, registration_enabled: false},
else: %{oidc_only: false}
case Membership.update_settings(settings, params) do
{:ok, updated_settings} ->
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:oidc_only, updated_settings.oidc_only == true)
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign_form()
|> put_flash(:success, gettext("Settings updated successfully"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
end
end
end
@impl true
def handle_event("toggle_add_field_dropdown", _params, socket) do
{:noreply,
@ -760,17 +1159,29 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
defp drop_blank_smtp_password(params) when is_map(params) do
case params do
%{"smtp_password" => v} when v in [nil, ""] ->
Map.delete(params, "smtp_password")
_ ->
params
end
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
# Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form
settings_display =
settings
|> merge_vereinfacht_env_values()
|> merge_oidc_env_values()
|> merge_smtp_env_values()
settings_for_form = %{
settings_display
| vereinfacht_api_key: nil,
oidc_client_secret: nil
oidc_client_secret: nil,
smtp_password: nil
}
form =
@ -845,6 +1256,28 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
defp merge_smtp_env_values(s) do
s
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|> put_if_env_set(
:smtp_username,
Mv.Config.smtp_username_env_set?(),
Mv.Config.smtp_username()
)
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|> put_if_env_set(
:smtp_from_email,
Mv.Config.mail_from_email_env_set?(),
Mv.Config.mail_from_email()
)
|> put_if_env_set(
:smtp_from_name,
Mv.Config.mail_from_name_env_set?(),
Mv.Config.mail_from_name()
)
end
defp enrich_sync_errors([]), do: []
defp enrich_sync_errors(errors) when is_list(errors) do
@ -1018,6 +1451,115 @@ defmodule MvWeb.GlobalSettingsLive do
"""
end
# ---- SMTP test result component ----
attr :result, :any, required: true
defp smtp_test_result(%{result: {:ok, _}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
<.icon name="hero-check-circle" class="size-5 shrink-0" />
<span>{gettext("Test email sent successfully.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Invalid email address. Please enter a valid recipient address.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<span>{gettext("SMTP is not configured. Please set at least the SMTP host.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext(
"Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Authentication failed. Please check the SMTP username and password.")}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Recipient address rejected by the server.")}</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext(
"TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Server unreachable. Check host and port.")}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns)
when is_binary(message) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
</span>
</div>
"""
end
defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Failed to send test email. Please check your SMTP configuration.")}</span>
</div>
"""
end
# ---- Join form helper functions ----
defp assign_join_form_state(socket, settings, custom_fields) do

View file

@ -32,7 +32,7 @@ defmodule MvWeb.GroupLive.Form do
socket
|> assign(:actor, actor)
|> assign(:group, nil)
|> assign(:page_title, page_title_for_params(params))
|> Layouts.assign_page_title(page_title_for_params(params))
|> assign(:return_to, return_to_for_params(params))}
else
{:ok, redirect(socket, to: ~p"/groups")}
@ -56,7 +56,7 @@ defmodule MvWeb.GroupLive.Form do
{:noreply,
socket
|> assign(:group, group)
|> assign(:page_title, gettext("Edit Group"))
|> Layouts.assign_page_title(gettext("Edit Group"))
|> assign(:return_to, :show)
|> assign_form(actor)}
@ -85,7 +85,7 @@ defmodule MvWeb.GroupLive.Form do
{gettext("Back")}
</.button>
</:leading>
{@page_title}
{@content_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}

View file

@ -28,7 +28,7 @@ defmodule MvWeb.GroupLive.Index do
{:ok,
socket
|> assign(:page_title, gettext("Groups"))
|> Layouts.assign_page_title(gettext("Groups"))
|> assign(:groups, groups)}
else
{:ok, redirect(socket, to: ~p"/members")}
@ -40,7 +40,7 @@ defmodule MvWeb.GroupLive.Index do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Groups")}
{@content_title}
<:actions>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary">

View file

@ -70,9 +70,11 @@ defmodule MvWeb.GroupLive.Show do
{:ok, group} ->
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
content_title = gettext("Group %{name}", name: group.name)
socket =
socket
|> assign(:page_title, group.name)
|> Layouts.assign_page_title(content_title)
|> assign(:group, group)
|> assign(:show_delete_modal, open_delete)
|> assign(:name_confirmation, "")
@ -102,7 +104,7 @@ defmodule MvWeb.GroupLive.Show do
{gettext("Back")}
</.button>
</:leading>
{@group.name}
{@content_title}
<:actions>
<%= if can?(@current_user, :update, @group) do %>
<.button

View file

@ -65,7 +65,7 @@ defmodule MvWeb.ImportLive do
socket =
socket
|> assign(:page_title, gettext("Import"))
|> Layouts.assign_page_title(gettext("Import"))
|> assign(:club_name, club_name)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
@ -94,7 +94,7 @@ defmodule MvWeb.ImportLive do
<%!-- CSV Import Section --%>
<div data-testid="import-page">
<.header>
{gettext("Import Members")}
{@content_title}
<:subtitle>
{gettext("Import members from CSV files.")}
</:subtitle>

View file

@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
# Honeypot field name (legitimate-sounding to avoid bot detection)
@honeypot_field "website"
# Anti-enumeration: delay before showing success (ms). Applied in LiveView so the process is not blocked.
@anti_enumeration_delay_ms_min 100
@anti_enumeration_delay_ms_rand 200
@impl true
def mount(_params, _session, socket) do
allowlist = Membership.get_join_form_allowlist()
join_fields = build_join_fields_with_labels(allowlist)
client_ip = client_ip_from_socket(socket)
club_name =
case Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
socket =
socket
|> assign(:join_fields, join_fields)
@ -25,6 +35,8 @@ defmodule MvWeb.JoinLive do
|> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(gettext("Join"))
|> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket}
@ -33,8 +45,11 @@ defmodule MvWeb.JoinLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="max-w-xl mx-auto mt-8 space-y-6">
<Layouts.public_page flash={@flash} club_name={@club_name}>
<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-xl w-full space-y-6">
<.header>
{gettext("Become a member")}
</.header>
@ -97,13 +112,13 @@ defmodule MvWeb.JoinLive do
/>
</div>
<p class="text-sm text-base-content/70">
<p class="text-sm text-base-content/85">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
<p class="text-xs text-base-content/80">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}
@ -117,7 +132,10 @@ defmodule MvWeb.JoinLive do
</.form>
<% end %>
</div>
</Layouts.app>
</div>
</div>
</div>
</Layouts.public_page>
"""
end
@ -142,8 +160,26 @@ defmodule MvWeb.JoinLive do
case build_submit_attrs(params, socket.assigns.join_fields) do
{:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} -> {:noreply, assign(socket, :submitted, true)}
{:error, _} -> validation_error_reply(socket, params)
{:ok, _} ->
delay_ms =
@anti_enumeration_delay_ms_min + :rand.uniform(@anti_enumeration_delay_ms_rand)
Process.send_after(self(), :show_join_success, delay_ms)
{:noreply, socket}
{:error, :email_delivery_failed} ->
{:noreply,
socket
|> put_flash(
:error,
gettext(
"We could not send the confirmation email. Please try again later or contact support."
)
)
|> assign(:form, to_form(params, as: "join"))}
{:error, _} ->
validation_error_reply(socket, params)
end
{:error, message} ->
@ -161,6 +197,16 @@ defmodule MvWeb.JoinLive do
|> assign(:form, to_form(params, as: "join"))}
end
@impl true
def handle_info(:show_join_success, socket) do
{:noreply, assign(socket, :submitted, true)}
end
# Swoosh (e.g. in test) may send {:email, email} to the LiveView process; ignore.
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp rate_limited_reply(socket, params) do
{:noreply,
socket

View file

@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do
@doc """
Returns the reviewer display string (e.g. email) for a join request, or nil if none.
Accepts a join request struct or map with optional :reviewed_by_user (loaded User struct).
Prefers the denormalized :reviewed_by_display (set on approve/reject) so the UI
works for all roles without loading the User resource. Falls back to
:reviewed_by_user when loaded (e.g. admin or legacy data before backfill).
"""
def reviewer_display(req) when is_map(req) do
case Map.get(req, :reviewed_by_display) do
s when is_binary(s) ->
trimmed = String.trim(s)
if trimmed == "", do: reviewer_display_from_user(req), else: trimmed
_ ->
reviewer_display_from_user(req)
end
end
def reviewer_display(_), do: nil
defp reviewer_display_from_user(req) do
user = Map.get(req, :reviewed_by_user)
case user do
@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do
nil
end
end
def reviewer_display(_), do: nil
end

View file

@ -43,7 +43,7 @@ defmodule MvWeb.JoinRequestLive.Index do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Join requests")}
{@content_title}
</.header>
<div class="mt-6 space-y-8 max-w-4xl">
@ -63,7 +63,7 @@ defmodule MvWeb.JoinRequestLive.Index do
>
<:col :let={req} label={gettext("Submitted at")}>
<%= if req.submitted_at do %>
{DateFormatter.format_datetime(req.submitted_at)}
{DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
<% else %>
<.empty_cell sr_text={gettext("Not submitted yet")} />
<% end %>
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
</.badge>
</:col>
<:col :let={req} label={gettext("Reviewed at")}>
{review_date(req)}
{review_date(req, @browser_timezone)}
</:col>
<:col :let={req} label={gettext("Review by")}>
{JoinRequestHelpers.reviewer_display(req) || ""}
@ -159,10 +159,10 @@ defmodule MvWeb.JoinRequestLive.Index do
assign(socket, :join_requests_history, [])
end
assign(socket, :page_title, gettext("Join requests"))
Layouts.assign_page_title(socket, gettext("Join requests"))
end
defp review_date(req) do
defp review_date(req, timezone) do
date =
case req.status do
:approved -> req.approved_at
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
_ -> nil
end
if date, do: DateFormatter.format_datetime(date), else: ""
if date, do: DateFormatter.format_datetime(date, timezone), else: ""
end
end

View file

@ -32,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do
socket
|> assign(:join_request, nil)
|> assign(:join_form_field_ids, [])
|> assign(:page_title, gettext("Join request"))}
|> Layouts.assign_page_title(gettext("Join request"))}
else
{:ok, redirect(socket, to: ~p"/members")}
end
@ -57,7 +57,7 @@ defmodule MvWeb.JoinRequestLive.Show do
socket
|> assign(:join_request, request)
|> assign(:join_form_field_ids, field_ids)
|> assign(:page_title, gettext("Join request %{email}", email: request.email))}
|> Layouts.assign_page_title(gettext("Join request %{email}", email: request.email))}
{:error, _error} ->
{:noreply,
@ -123,28 +123,28 @@ defmodule MvWeb.JoinRequestLive.Show do
{gettext("Back")}
</.button>
</:leading>
{gettext("Join request")}
{@content_title}
</.header>
<%= if @join_request do %>
<div class="mt-6 space-y-6 max-w-2xl">
<%!-- Single block: all applicant-provided data in join form order --%>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Request data")}</h2>
<h2 class="text-lg font-semibold mb-2">{gettext("Applicant data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %>
<.field_row label={label} value={value} empty_text={gettext("Not specified")} />
<% end %>
</div>
</div>
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Status and review")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<.field_row label={gettext("Email")} value={@join_request.email} />
<.field_row
label={gettext("First name")}
value={@join_request.first_name}
empty_text={gettext("Not specified")}
/>
<.field_row
label={gettext("Last name")}
value={@join_request.last_name}
empty_text={gettext("Not specified")}
/>
<.field_row
label={gettext("Submitted at")}
value={DateFormatter.format_datetime(@join_request.submitted_at)}
value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
/>
<div class="flex gap-2">
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
@ -154,34 +154,21 @@ defmodule MvWeb.JoinRequestLive.Show do
</.badge>
</span>
</div>
</div>
</div>
<%= if map_size(@join_request.form_data || %{}) > 0 do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Additional form data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
<.field_row label={key} value={to_string(value)} />
<% end %>
</div>
</div>
<% end %>
<%= if @join_request.status in [:approved, :rejected] do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Review information")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= if @join_request.approved_at do %>
<.field_row
label={gettext("Approved at")}
value={DateFormatter.format_datetime(@join_request.approved_at)}
value={
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
}
/>
<% end %>
<%= if @join_request.rejected_at do %>
<.field_row
label={gettext("Rejected at")}
value={DateFormatter.format_datetime(@join_request.rejected_at)}
value={
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
}
/>
<% end %>
<.field_row
@ -189,9 +176,9 @@ defmodule MvWeb.JoinRequestLive.Show do
value={JoinRequestHelpers.reviewer_display(@join_request)}
empty_text="-"
/>
</div>
</div>
<% end %>
</div>
</div>
<%= if @join_request.status == :submitted do %>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
@ -240,40 +227,78 @@ defmodule MvWeb.JoinRequestLive.Show do
"""
end
# Formats form_data for display in join-form order; legacy keys (not in current
# join_form_field_ids) are appended at the end, sorted by label for stability.
# Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs).
defp format_form_data(nil, _ordered_field_ids), do: []
defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do
# Builds a single list of {label, display_value} for all applicant-provided data in join form
# order. Typed fields (email, first_name, last_name) and form_data are merged; legacy
# form_data keys (not in current join form config) are appended at the end.
defp applicant_data_rows(join_request, ordered_field_ids) do
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
form_data = join_request.form_data || %{}
typed = %{
"email" => join_request.email,
"first_name" => join_request.first_name,
"last_name" => join_request.last_name
}
# First: entries in current join form order (only keys present in form_data)
in_order =
ordered_field_ids
|> Enum.filter(&Map.has_key?(form_data, &1))
|> Enum.map(fn key ->
value = form_data[key]
value = Map.get(typed, key) || Map.get(form_data, key)
label = field_key_to_label(key, member_field_strings)
{label, value}
{label, format_applicant_value(value)}
end)
# Then: keys in form_data that are not in current settings (e.g. removed fields on old requests)
legacy_keys =
form_data
|> Map.keys()
|> Enum.reject(&(&1 in ordered_field_ids))
|> Enum.reject(fn k ->
k in ordered_field_ids or k in ["email", "first_name", "last_name"]
end)
|> Enum.sort()
legacy_entries =
Enum.map(legacy_keys, fn key ->
label = field_key_to_label(key, member_field_strings)
{label, form_data[key]}
{label, format_applicant_value(form_data[key])}
end)
in_order ++ legacy_entries
end
defp format_applicant_value(nil), do: nil
defp format_applicant_value(""), do: nil
defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date)
defp format_applicant_value(value) when is_map(value),
do: format_applicant_value_from_map(value)
defp format_applicant_value(value) when is_boolean(value),
do: if(value, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value(value) when is_binary(value) or is_number(value),
do: to_string(value)
defp format_applicant_value(value), do: to_string(value)
defp format_applicant_value_from_map(value) do
raw = Map.get(value, "_union_value") || Map.get(value, "value")
type = Map.get(value, "_union_type") || Map.get(value, "type")
if raw && type in ["date", :date] do
format_applicant_value(raw)
else
format_applicant_value_simple(raw, value)
end
end
defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw
defp format_applicant_value_simple(raw, _value) when is_boolean(raw),
do: if(raw, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw)
defp format_applicant_value_simple(_raw, value), do: to_string(value)
defp field_key_to_label(key, member_field_strings) when is_binary(key) do
if key in member_field_strings,
do: MemberFieldsTranslations.label(String.to_existing_atom(key)),

View file

@ -374,7 +374,7 @@ defmodule MvWeb.MemberLive.Form do
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
end
page_title =
content_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
@ -389,7 +389,7 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:custom_fields, custom_fields)
|> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(member: member)
|> assign(:page_title, page_title)
|> Layouts.assign_page_title(content_title)
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map)

View file

@ -127,7 +127,7 @@ defmodule MvWeb.MemberLive.Index do
socket =
socket
|> assign(:page_title, gettext("Members"))
|> Layouts.assign_page_title(gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Members")}
{@content_title}
<:actions>
<.live_component
module={MvWeb.Components.ExportDropdown}

View file

@ -47,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
{gettext("Back")}
</.button>
</:leading>
{MemberHelpers.display_name(@member)}
{@content_title}
<:actions>
<%= if can?(@current_user, :update, @member) do %>
<.button
@ -435,9 +435,12 @@ defmodule MvWeb.MemberLive.Show do
|> Map.put(:last_cycle_status, last_cycle_status)
|> Map.put(:current_cycle_status, current_cycle_status)
content_title =
gettext("Member %{name}", name: MemberHelpers.display_name(member))
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> Layouts.assign_page_title(content_title)
|> assign(:member, member)}
end
@ -565,9 +568,6 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :member, member)}
end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn

View file

@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{:ok,
socket
|> assign(:page_title, gettext("Membership Fee Settings"))
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|> assign(:settings, settings)
|> assign(:membership_fee_types, membership_fee_types)
|> assign(:member_counts, member_counts)
@ -140,7 +140,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Membership Fee Settings")}
{@content_title}
<:subtitle>
{gettext("Configure fee types for membership fees.")}
</:subtitle>

View file

@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
{gettext("Back")}
</.button>
</:leading>
{@page_title}
{@content_title}
<:actions>
<.button
form="membership-fee-type-form"
@ -221,7 +221,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
end
page_title =
content_title =
if is_nil(membership_fee_type),
do: gettext("New Membership Fee Type"),
else: gettext("Edit Membership Fee Type")
@ -230,7 +230,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:membership_fee_type, membership_fee_type)
|> assign(:page_title, page_title)
|> Layouts.assign_page_title(content_title)
|> assign(:show_amount_warning, false)
|> assign(:old_amount, nil)
|> assign(:new_amount, nil)

View file

@ -32,7 +32,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
{:ok,
socket
|> assign(:page_title, gettext("Membership Fee Types"))
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|> assign(:membership_fee_types, fee_types)
|> assign(:member_counts, member_counts)}
end
@ -42,7 +42,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Membership Fee Types")}
{@content_title}
<:subtitle>
{gettext("Manage membership fee types for membership fees.")}
</:subtitle>

View file

@ -29,7 +29,7 @@ defmodule MvWeb.RoleLive.Form do
{gettext("Back")}
</.button>
</:leading>
{@page_title}
{@content_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
@ -94,14 +94,13 @@ defmodule MvWeb.RoleLive.Form do
def mount(params, _session, socket) do
case params["id"] do
nil ->
action = gettext("New")
page_title = action <> " " <> gettext("Role")
content_title = gettext("New") <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, nil)
|> assign(:page_title, page_title)
|> Layouts.assign_page_title(content_title)
|> assign_form()}
id ->
@ -113,14 +112,13 @@ defmodule MvWeb.RoleLive.Form do
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
action = gettext("Edit")
page_title = action <> " " <> gettext("Role")
content_title = gettext("Edit") <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, role)
|> assign(:page_title, page_title)
|> Layouts.assign_page_title(content_title)
|> assign_form()}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->

View file

@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.Index do
{:ok,
socket
|> assign(:page_title, gettext("Listing Roles"))
|> Layouts.assign_page_title(gettext("Roles"))
|> assign(:roles, roles)
|> assign(:user_counts, user_counts)}
end

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Listing Roles")}
{@content_title}
<:subtitle>
{gettext("Manage roles and their permission sets.")}
</:subtitle>

View file

@ -30,9 +30,11 @@ defmodule MvWeb.RoleLive.Show do
{:ok, role} ->
user_count = load_user_count(role, socket.assigns[:current_user])
content_title = gettext("Role %{name}", name: role.name)
{:ok,
socket
|> assign(:page_title, gettext("Show Role"))
|> Layouts.assign_page_title(content_title)
|> assign(:role, role)
|> assign(:user_count, user_count)
|> assign(:show_delete_modal, false)}
@ -202,7 +204,7 @@ defmodule MvWeb.RoleLive.Show do
{gettext("Back")}
</.button>
</:leading>
{gettext("Role")} {@role.name}
{@content_title}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions>

View file

@ -18,7 +18,7 @@ defmodule MvWeb.StatisticsLive do
# Only static assigns and fee types here; load_statistics runs once in handle_params
socket =
socket
|> assign(:page_title, gettext("Statistics"))
|> Layouts.assign_page_title(gettext("Statistics"))
|> assign(:selected_fee_type_id, nil)
|> load_fee_types()
@ -58,7 +58,7 @@ defmodule MvWeb.StatisticsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Statistics")}
{@content_title}
</.header>
<section class="mb-8" aria-labelledby="members-heading">

View file

@ -59,7 +59,7 @@ defmodule MvWeb.UserLive.Form do
{gettext("Back")}
</.button>
</:leading>
{@page_title}
{@content_title}
<:actions>
<.button
form="user-form"
@ -423,8 +423,9 @@ defmodule MvWeb.UserLive.Form do
defp mount_continue(user, params, socket) do
actor = current_actor(socket)
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
page_title = action <> " " <> gettext("User")
content_title =
if(is_nil(user), do: gettext("New"), else: gettext("Edit")) <> " " <> gettext("User")
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, UserResource)
@ -436,7 +437,7 @@ defmodule MvWeb.UserLive.Form do
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(user: user)
|> assign(:page_title, page_title)
|> Layouts.assign_page_title(content_title)
|> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:can_assign_role, can_assign_role)
|> assign(:roles, roles)

View file

@ -38,7 +38,7 @@ defmodule MvWeb.UserLive.Index do
{:ok,
socket
|> assign(:page_title, gettext("Listing Users"))
|> Layouts.assign_page_title(gettext("Users"))
|> assign(:sort_field, :email)
|> assign(:sort_order, :asc)
|> assign(:users, sorted)}

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Users")}
{@content_title}
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
<:actions>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>

View file

@ -48,7 +48,7 @@ defmodule MvWeb.UserLive.Show do
{gettext("Back")}
</.button>
</:leading>
{gettext("User")} {@user.email}
{@content_title}
<:actions>
<%= if can?(@current_user, :update, @user) do %>
<.button
@ -179,9 +179,11 @@ defmodule MvWeb.UserLive.Show do
|> put_flash(:error, gettext("This user cannot be viewed."))
|> push_navigate(to: ~p"/users")}
else
content_title = gettext("User %{email}", email: user.email)
{:ok,
socket
|> assign(:page_title, gettext("Show User"))
|> Layouts.assign_page_title(content_title)
|> assign(:user, user)
|> assign(:show_delete_modal, false)}
end

View file

@ -17,11 +17,29 @@ defmodule MvWeb.LiveHelpers do
"""
import Phoenix.Component
alias Mv.Authorization.Actor
alias Mv.Membership
alias MvWeb.Plugs.CheckPagePermission
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
connect_params = socket.private[:connect_params] || %{}
timezone = connect_params["timezone"] || connect_params[:timezone]
# Club name for browser tab title (Mila · Club · Page)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket =
socket
|> assign(:browser_timezone, timezone)
|> assign(:club_name, club_name)
{:cont, socket}
end
@ -56,7 +74,7 @@ defmodule MvWeb.LiveHelpers do
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|> maybe_put_access_denied_flash(user)
|> Phoenix.LiveView.push_navigate(to: redirect_to)
{:halt, socket}
@ -64,6 +82,13 @@ defmodule MvWeb.LiveHelpers do
end
end
# Only show "no permission" when user is logged in; unauthenticated users are redirected to sign-in without flash.
defp maybe_put_access_denied_flash(socket, nil), do: socket
defp maybe_put_access_denied_flash(socket, _user) do
Phoenix.LiveView.put_flash(socket, :error, "You don't have permission to access this page.")
end
defp ensure_user_role_loaded(socket) do
user = socket.assigns[:current_user]

View file

@ -0,0 +1,22 @@
defmodule MvWeb.Plugs.AssignClubName do
@moduledoc """
Assigns :club_name from settings for controller-rendered pages.
Used by the root layout to build the browser tab title (Mila · Club · Page).
LiveViews set club_name in on_mount instead.
"""
import Plug.Conn
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
assign(conn, :club_name, club_name)
end
end

View file

@ -54,7 +54,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
conn
|> fetch_session()
|> fetch_flash()
|> put_flash(:error, "You don't have permission to access this page.")
|> maybe_put_access_denied_flash(user)
|> redirect(to: redirect_to)
|> halt()
end
@ -75,6 +75,13 @@ defmodule MvWeb.Plugs.CheckPagePermission do
defp redirect_target(user), do: redirect_target_for_user(user)
# Only set "no permission" flash when user is logged in; unauthenticated users get redirect only, no flash.
defp maybe_put_access_denied_flash(conn, nil), do: conn
defp maybe_put_access_denied_flash(conn, _user) do
put_flash(conn, :error, "You don't have permission to access this page.")
end
@doc """
Returns true if the path is public (no auth/permission check).
Used by LiveView hook to skip redirect on sign-in etc.

View file

@ -0,0 +1,73 @@
defmodule MvWeb.Plugs.OidcOnlySignInRedirect do
@moduledoc """
When OIDC-only mode is active:
- GET /sign-in redirects to the OIDC flow when OIDC is configured (sign-in page skipped).
- GET /sign-in?oidc_failed=1 is not redirected, so the sign-in page is shown after an OIDC
failure (avoids redirect loop when the provider is down or misconfigured).
- GET /auth/user/password/sign_in_with_token is rejected (redirect to /sign-in with error)
so password sign-in cannot complete.
"""
import Plug.Conn
import Phoenix.Controller
alias Mv.Config
def init(opts), do: opts
def call(conn, _opts) do
conn
|> maybe_redirect_sign_in_to_oidc()
|> maybe_reject_password_token_sign_in()
end
defp maybe_redirect_sign_in_to_oidc(conn) do
if conn.request_path != "/sign-in" or conn.method != "GET" do
conn
else
conn = fetch_query_params(conn)
maybe_redirect_sign_in_to_oidc_checked(conn)
end
end
defp maybe_redirect_sign_in_to_oidc_checked(conn) do
cond do
# Show sign-in page when returning from OIDC failure to avoid redirect loop.
conn.query_params["oidc_failed"] -> conn
Config.oidc_only?() and Config.oidc_configured?() -> redirect_and_halt(conn)
true -> conn
end
end
defp redirect_and_halt(conn) do
conn
|> redirect(to: "/auth/user/oidc")
|> halt()
end
defp maybe_reject_password_token_sign_in(conn) do
if conn.halted, do: conn, else: reject_password_token_sign_in_if_applicable(conn)
end
defp reject_password_token_sign_in_if_applicable(conn) do
path = conn.request_path
password_token_path? =
path =~ ~r|/auth/user/password/sign_in_with_token| and conn.method == "GET"
if password_token_path? and Config.oidc_only?() do
message =
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Only sign-in via Single Sign-On (SSO) is allowed."
)
conn
|> put_flash(:error, message)
|> redirect(to: "/sign-in")
|> halt()
else
conn
end
end
end

View file

@ -0,0 +1,55 @@
defmodule MvWeb.Plugs.RegistrationEnabled do
@moduledoc """
When direct registration is disabled in settings:
- GET /register is redirected to /sign-in with a flash message.
Puts registration_enabled from settings into session for /sign-in and /register
so the sign-in LiveView can show or hide the register link.
"""
import Plug.Conn
import Phoenix.Controller
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
conn
|> maybe_redirect_register()
|> maybe_put_registration_enabled_in_session()
end
defp maybe_redirect_register(conn) do
if conn.request_path == "/register" and conn.method == "GET" do
case Membership.get_settings() do
{:ok, %{registration_enabled: true}} ->
conn
_ ->
conn
|> put_flash(:info, get_flash_message(conn))
|> redirect(to: "/sign-in")
|> halt()
end
else
conn
end
end
defp get_flash_message(_conn) do
Gettext.dgettext(MvWeb.Gettext, "default", "Registration is disabled.")
end
defp maybe_put_registration_enabled_in_session(conn) do
if conn.request_path in ["/sign-in", "/register"] do
enabled =
case Membership.get_settings() do
{:ok, %{registration_enabled: enabled?}} -> enabled?
_ -> true
end
put_session(conn, "registration_enabled", enabled)
else
conn
end
end
end

View file

@ -14,8 +14,11 @@ defmodule MvWeb.Router do
plug :put_secure_browser_headers
plug :load_from_session
plug :set_locale
plug MvWeb.Plugs.AssignClubName
plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled
plug MvWeb.Plugs.OidcOnlySignInRedirect
end
pipeline :api do

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. The email address you entered is already registered as a member."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. You already have a membership application that is being reviewed."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -1,4 +1,9 @@
<div style="color: #111827;">
<%= if @resend do %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext("You already had a pending request. Here is a new confirmation link.")}
</p>
<% end %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your membership request. To complete it, please click the link below."

View file

@ -67,6 +67,8 @@ defmodule Mv.MixProject do
depth: 1},
{:phoenix_swoosh, "~> 1.0"},
{:swoosh, "~> 1.16"},
# Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in)
{:gen_smtp, "~> 1.0"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
@ -83,7 +85,8 @@ defmodule Mv.MixProject do
{:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"},
{:imprintor, "~> 0.5.0"},
{:hammer, "~> 7.0"}
{:hammer, "~> 7.0"},
{:tz, "~> 0.28"}
]
end

View file

@ -35,6 +35,7 @@
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
@ -95,6 +96,7 @@
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},

View file

@ -139,18 +139,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/auth_overrides.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "or"
msgid "Register"
msgstr ""

View file

@ -135,18 +135,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr "Dieses OIDC-Konto ist bereits mit einer*m anderen Benutzer*in verknüpft. Bitte kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr "Sprachauswahl"
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
#: lib/mv_web/auth_overrides.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "or"
msgstr "oder"
msgid "Register"
msgstr "Registrieren"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -132,18 +132,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/auth_overrides.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "or"
msgstr "or"
msgid "Register"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
defmodule Mv.Repo.Migrations.AddSmtpToSettings do
@moduledoc """
Adds SMTP configuration attributes to the settings table.
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :smtp_host, :text
add :smtp_port, :bigint
add :smtp_username, :text
add :smtp_password, :text
add :smtp_ssl, :text
end
end
def down do
alter table(:settings) do
remove :smtp_ssl
remove :smtp_password
remove :smtp_username
remove :smtp_port
remove :smtp_host
end
end
end

View file

@ -0,0 +1,18 @@
defmodule Mv.Repo.Migrations.AddMailFromToSettings do
@moduledoc "Adds smtp_from_name and smtp_from_email attributes to the settings table."
use Ecto.Migration
def up do
alter table(:settings) do
add :smtp_from_name, :text
add :smtp_from_email, :text
end
end
def down do
alter table(:settings) do
remove :smtp_from_email
remove :smtp_from_name
end
end
end

View file

@ -0,0 +1,30 @@
defmodule Mv.Repo.Migrations.AddReviewedByDisplayToJoinRequests do
@moduledoc """
Adds reviewed_by_display to join_requests for showing reviewer in UI without loading User.
Backfills existing rows from users.email where reviewed_by_user_id is set.
"""
use Ecto.Migration
def up do
alter table(:join_requests) do
add :reviewed_by_display, :text
end
# Backfill from users.email for rows that have reviewed_by_user_id
execute """
UPDATE join_requests j
SET reviewed_by_display = u.email
FROM users u
WHERE j.reviewed_by_user_id = u.id
AND j.reviewed_by_user_id IS NOT NULL
"""
end
def down do
alter table(:join_requests) do
remove :reviewed_by_display
end
end
end

View file

@ -0,0 +1,20 @@
defmodule Mv.Repo.Migrations.AddRegistrationEnabledToSettings do
@moduledoc """
Adds registration_enabled flag to settings. When false, direct registration
via /register is disabled; sign-in and join form remain available.
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :registration_enabled, :boolean, default: true, null: false
end
end
def down do
alter table(:settings) do
remove :registration_enabled
end
end
end

View file

@ -3,7 +3,9 @@
# mix run priv/repo/seeds.exs
#
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
# run only in dev and test.
# run only in dev and test. Skips entirely if bootstrap was already applied
# (admin user exists), so safe to run on every start. Set FORCE_SEEDS=true to
# re-run seeds even when already applied.
#
# In production (release): seeds are run via Mv.Release.run_seeds/0 from the
# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there.
@ -12,6 +14,11 @@
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
_ = Application.ensure_all_started(:mv)
if Mv.Release.bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
else
prev = Code.compiler_options()
Code.compiler_options(ignore_module_conflict: true)
@ -28,3 +35,4 @@ try do
after
Code.compiler_options(prev)
end
end

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