From c4135308e69be6536c9f17395cba677759ca5666 Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 11 Mar 2026 09:18:37 +0100
Subject: [PATCH 01/26] test: add tests for smtp mailer config
---
docs/feature-roadmap.md | 2 +
docs/smtp-configuration-concept.md | 101 ++++++++++++++
lib/mv/config.ex | 38 ++++++
lib/mv/mailer.ex | 11 ++
test/membership/setting_smtp_test.exs | 63 +++++++++
test/mv/config_smtp_test.exs | 129 ++++++++++++++++++
test/mv/mailer_test.exs | 46 +++++++
.../mv_web/live/global_settings_live_test.exs | 48 +++++++
test/mv_web/live/join_live_test.exs | 2 +
9 files changed, 440 insertions(+)
create mode 100644 docs/smtp-configuration-concept.md
create mode 100644 test/membership/setting_smtp_test.exs
create mode 100644 test/mv/config_smtp_test.exs
create mode 100644 test/mv/mailer_test.exs
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 89c2f39..f3b1e27 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -271,6 +271,7 @@
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
**Missing Features:**
+- ❌ **SMTP configuration** – Configure mail server via ENV and Admin Settings, test email from Settings. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
- ❌ Email templates configuration
- ❌ System health dashboard
- ❌ Audit log viewer
@@ -287,6 +288,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
+- ⚠️ No SMTP configuration (mailer uses Local/Test adapter; prod not configured)
- ⚠️ No member communication features
**Missing Features:**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
new file mode 100644
index 0000000..b0ca8cc
--- /dev/null
+++ b/docs/smtp-configuration-concept.md
@@ -0,0 +1,101 @@
+# SMTP Configuration – Concept
+
+**Status:** Draft
+**Last updated:** 2026-03-11
+
+---
+
+## 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), test email from Settings UI, warning when SMTP is not configured in production.
+- **Out of scope:** Changing how AshAuthentication or existing senders use the mailer; they keep using `Mv.Mailer` and `mail_from/0`. No separate "form_mail" config – the existing **mail_from** (MAIL_FROM_NAME, MAIL_FROM_EMAIL) remains the single sender identity for all transactional mails.
+
+---
+
+## 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 | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
+| TLS/SSL | `SMTP_SSL` or similar | `smtp_ssl` | e.g. `tls` / `ssl` / `none` (default: tls)|
+
+**Sender (unchanged):** `mail_from` stays separate (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` in ENV; no DB fields for from-address).
+
+---
+
+## 5. Password from File
+
+Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` and `TOKEN_SIGNING_SECRET_FILE` in `runtime.exs`. Read once at runtime when building mailer config; ENV `SMTP_PASSWORD` overrides file if both are set (or define explicit precedence and document it).
+
+---
+
+## 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 (e.g. no host):
+ - Keep using the default adapter (e.g. Local) or a no-op adapter so the app does not crash.
+ - **Show a clear warning in the Settings UI** (SMTP section): e.g. "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." and optionally list consequences (no join confirmations, no password resets, etc.).
+ - Log a warning at startup or when sending is attempted if SMTP is not configured in prod.
+
+---
+
+## 7. Test Email (Settings UI)
+
+- **Location:** SMTP / E-Mail section in Global Settings (same page as OIDC, Vereinfacht).
+- **Elements:**
+ - Input: **recipient email address** (required for sending).
+ - Button: **"Send test email"** (or similar).
+- **Behaviour:** On click, send one simple transactional-style email to the given address (subject and body translatable via Gettext, e.g. "Mila – Test email" / "This is a test."). Use current SMTP config and `mail_from`.
+- **Feedback:** Show success message or error (e.g. connection refused, auth failed, invalid address). Reuse the same UI pattern as Vereinfacht "Test Integration" (result assign, small result component with success/error states).
+- **Permission:** Reuse existing Settings page authorization (admin); no extra check for the test-email action.
+
+---
+
+## 8. Implementation Hints
+
+- **Config module:** Extend `Mv.Config` with `smtp_*` helpers (e.g. `smtp_host/0`, `smtp_port/0`, …) using `env_or_setting/2` and, for password, ENV vs `SMTP_PASSWORD_FILE` vs Settings (sensitive).
+- **runtime.exs:** When SMTP is configured (e.g. host present), set `config :mv, Mv.Mailer, adapter: Swoosh.Adapters.SMTP, ...` with the merged options. Otherwise leave adapter as in base config (Local in dev, Test in test, and in prod either Local with warning or explicit "not configured" behaviour).
+- **Setting resource:** New attributes: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password` (sensitive), `smtp_ssl` (string or enum). Add to create/update `accept` lists and to seeds if needed.
+- **Migration:** Add columns for the new Setting attributes.
+- **Test email:** New function (e.g. `Mv.Mailer.send_test_email(to_email)`) returning `{:ok, _}` or `{:error, reason}`; call from LiveView event and render result in the SMTP section.
+
+---
+
+## 9. Documentation and i18n
+
+- **Gettext:** Use Gettext for test email subject and body and for all new Settings labels/hints (including the "SMTP not configured" warning).
+- **Docs:** Update `CODE_GUIDELINES.md` (e.g. §3.11 Email) and deployment/configuration docs to describe ENV and Settings for SMTP and the test email. Add this feature to `docs/feature-roadmap.md` (e.g. under Admin Panel & Configuration or Communication).
+
+---
+
+## 10. Summary Checklist
+
+- [ ] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL` (or equivalent).
+- [ ] Settings: attributes and UI for host, port, username, password, TLS/SSL; ENV-override hints.
+- [ ] Password from file: `SMTP_PASSWORD_FILE` supported in runtime config.
+- [ ] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
+- [ ] Prod warning: clear message in Settings when SMTP is not configured, with consequences.
+- [ ] Test email: button + recipient field, translatable content, success/error display; existing permission sufficient.
+- [ ] Gettext for new UI and test email text.
+- [ ] Feature roadmap and code guidelines updated.
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index 8b8c088..e176b8c 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -449,4 +449,42 @@ 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 (stubs for TDD – ENV overrides Settings; see docs/smtp-configuration-concept.md)
+ # ---------------------------------------------------------------------------
+
+ @doc "Returns SMTP host. ENV SMTP_HOST overrides Settings. Stub: always nil until implemented."
+ @spec smtp_host() :: String.t() | nil
+ def smtp_host, do: nil
+
+ @doc "Returns SMTP port (e.g. 587). ENV SMTP_PORT overrides Settings. Stub: always nil until implemented."
+ @spec smtp_port() :: non_neg_integer() | nil
+ def smtp_port, do: nil
+
+ @doc "Returns SMTP username. ENV SMTP_USERNAME overrides Settings. Stub: always nil until implemented."
+ @spec smtp_username() :: String.t() | nil
+ def smtp_username, do: nil
+
+ @doc "Returns SMTP password. ENV SMTP_PASSWORD overrides SMTP_PASSWORD_FILE overrides Settings. Stub: always nil until implemented."
+ @spec smtp_password() :: String.t() | nil
+ def smtp_password, do: nil
+
+ @doc "Returns SMTP TLS/SSL mode (e.g. 'tls', 'ssl', 'none'). Stub: always nil until implemented."
+ @spec smtp_ssl() :: String.t() | nil
+ def smtp_ssl, do: nil
+
+ @doc "Returns true when SMTP is configured (e.g. host present). Stub: always false until implemented."
+ @spec smtp_configured?() :: boolean()
+ def smtp_configured?, do: false
+
+ @doc "Returns true when any SMTP ENV variable is set (for Settings UI hint). Stub: always false until implemented."
+ @spec smtp_env_configured?() :: boolean()
+ def smtp_env_configured?, do: false
+
+ def smtp_host_env_set?, do: env_set?("SMTP_HOST")
+ def smtp_port_env_set?, do: env_set?("SMTP_PORT")
+ def smtp_username_env_set?, do: env_set?("SMTP_USERNAME")
+ def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE")
+ def smtp_ssl_env_set?, do: env_set?("SMTP_SSL")
end
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index 3d83636..e78735b 100644
--- a/lib/mv/mailer.ex
+++ b/lib/mv/mailer.ex
@@ -16,4 +16,15 @@ defmodule Mv.Mailer do
def mail_from do
Application.get_env(:mv, :mail_from, {"Mila", "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 (e.g. invalid address,
+ SMTP not configured, connection error). Stub: always returns error until implemented.
+ """
+ @spec send_test_email(String.t()) :: {:ok, Swoosh.Email.t()} | {:error, term()}
+ def send_test_email(_to_email) do
+ {:error, :not_implemented}
+ end
end
diff --git a/test/membership/setting_smtp_test.exs b/test/membership/setting_smtp_test.exs
new file mode 100644
index 0000000..ea4a954
--- /dev/null
+++ b/test/membership/setting_smtp_test.exs
@@ -0,0 +1,63 @@
+defmodule Mv.Membership.SettingSmtpTest do
+ @moduledoc """
+ Unit tests for Setting resource SMTP attributes.
+
+ TDD: tests expect smtp_host, smtp_port, smtp_username, smtp_password, smtp_ssl
+ to be accepted on update and persisted. Password must not be exposed in plaintext
+ when reading settings (sensitive). Tests will fail until Setting has these attributes.
+ """
+ use Mv.DataCase, async: false
+
+ alias Mv.Helpers.SystemActor
+ alias Mv.Membership
+
+ setup do
+ {:ok, settings} = Membership.get_settings()
+ # Save current SMTP values to restore in on_exit (when attributes exist)
+ saved = %{
+ smtp_host: Map.get(settings, :smtp_host),
+ smtp_port: Map.get(settings, :smtp_port),
+ smtp_username: Map.get(settings, :smtp_username),
+ smtp_ssl: Map.get(settings, :smtp_ssl)
+ }
+
+ on_exit(fn ->
+ {:ok, s} = Membership.get_settings()
+ attrs = Enum.reject(saved, fn {_k, v} -> is_nil(v) end) |> Map.new()
+ if attrs != %{}, do: Membership.update_settings(s, attrs)
+ end)
+
+ {:ok, settings: settings, saved: saved}
+ end
+
+ describe "SMTP attributes update and persistence" do
+ test "update_settings accepts smtp_host, smtp_port, smtp_username, smtp_ssl and persists", %{
+ settings: settings
+ } do
+ attrs = %{
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_username: "user",
+ smtp_ssl: "tls"
+ }
+
+ assert {:ok, updated} = Membership.update_settings(settings, attrs)
+ assert updated.smtp_host == "smtp.example.com"
+ assert updated.smtp_port == 587
+ assert updated.smtp_username == "user"
+ assert updated.smtp_ssl == "tls"
+ end
+
+ test "smtp_password can be set and is not exposed in plaintext when reading settings", %{
+ settings: settings
+ } do
+ secret = "sensitive-password-#{System.unique_integer([:positive])}"
+ assert {:ok, _} = Membership.update_settings(settings, %{smtp_password: secret})
+
+ {:ok, read_back} = Membership.get_settings()
+ # Sensitive: raw password must not be returned (e.g. nil or redacted)
+ refute read_back.smtp_password == secret,
+ "smtp_password must not be returned in plaintext when reading settings"
+ end
+ end
+end
diff --git a/test/mv/config_smtp_test.exs b/test/mv/config_smtp_test.exs
new file mode 100644
index 0000000..5359366
--- /dev/null
+++ b/test/mv/config_smtp_test.exs
@@ -0,0 +1,129 @@
+defmodule Mv.ConfigSmtpTest do
+ @moduledoc """
+ Unit tests for Mv.Config SMTP-related helpers.
+
+ ENV overrides Settings (same pattern as OIDC/Vereinfacht). Uses real ENV and
+ Settings; no mocking so we test the actual precedence. async: false because
+ we mutate ENV.
+ """
+ use Mv.DataCase, async: false
+
+ describe "smtp_host/0" do
+ test "returns ENV value when SMTP_HOST is set" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_host() == "smtp.example.com"
+ after
+ clear_smtp_env()
+ end
+
+ test "returns nil when SMTP_HOST is not set and Settings have no smtp_host" do
+ clear_smtp_env()
+ assert Mv.Config.smtp_host() == nil
+ end
+ end
+
+ describe "smtp_port/0" do
+ test "returns parsed integer when SMTP_PORT ENV is set" do
+ set_smtp_env("SMTP_PORT", "587")
+ assert Mv.Config.smtp_port() == 587
+ after
+ clear_smtp_env()
+ end
+
+ test "returns nil or default when SMTP_PORT is not set" do
+ clear_smtp_env()
+ port = Mv.Config.smtp_port()
+ assert port == nil or (is_integer(port) and port in [25, 465, 587])
+ end
+ end
+
+ describe "smtp_configured?/0" do
+ test "returns true when smtp_host is present (from ENV or Settings)" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_configured?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "returns false when no SMTP host is set" do
+ clear_smtp_env()
+ refute Mv.Config.smtp_configured?()
+ end
+ end
+
+ describe "smtp_env_configured?/0" do
+ test "returns true when any SMTP ENV variable is set" do
+ set_smtp_env("SMTP_HOST", "smtp.example.com")
+ assert Mv.Config.smtp_env_configured?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "returns false when no SMTP ENV variables are set" do
+ clear_smtp_env()
+ refute Mv.Config.smtp_env_configured?()
+ end
+ end
+
+ describe "smtp_password/0 and SMTP_PASSWORD_FILE" do
+ test "returns value from SMTP_PASSWORD when set" do
+ set_smtp_env("SMTP_PASSWORD", "env-secret")
+ assert Mv.Config.smtp_password() == "env-secret"
+ after
+ clear_smtp_env()
+ end
+
+ test "returns content of file when SMTP_PASSWORD_FILE is set and SMTP_PASSWORD is not" do
+ clear_smtp_env()
+ path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
+ File.write!(path, "file-secret\n")
+ Process.put(:smtp_password_file_path, path)
+ set_smtp_env("SMTP_PASSWORD_FILE", path)
+ assert Mv.Config.smtp_password() == "file-secret"
+ after
+ clear_smtp_env()
+ if path = Process.get(:smtp_password_file_path), do: File.rm(path)
+ end
+
+ test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE when both are set" do
+ path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}")
+ File.write!(path, "file-secret")
+ Process.put(:smtp_password_file_path, path)
+ set_smtp_env("SMTP_PASSWORD_FILE", path)
+ set_smtp_env("SMTP_PASSWORD", "env-wins")
+ assert Mv.Config.smtp_password() == "env-wins"
+ after
+ clear_smtp_env()
+ if path = Process.get(:smtp_password_file_path), do: File.rm(path)
+ end
+ end
+
+ describe "smtp_*_env_set?/0" do
+ test "smtp_host_env_set? returns true when SMTP_HOST is set" do
+ set_smtp_env("SMTP_HOST", "x")
+ assert Mv.Config.smtp_host_env_set?() == true
+ after
+ clear_smtp_env()
+ end
+
+ test "smtp_password_env_set? returns true when SMTP_PASSWORD or SMTP_PASSWORD_FILE is set" do
+ set_smtp_env("SMTP_PASSWORD", "x")
+ assert Mv.Config.smtp_password_env_set?() == true
+ after
+ clear_smtp_env()
+ end
+ end
+
+ defp set_smtp_env(key, value) do
+ System.put_env(key, value)
+ end
+
+ defp clear_smtp_env do
+ System.delete_env("SMTP_HOST")
+ System.delete_env("SMTP_PORT")
+ System.delete_env("SMTP_USERNAME")
+ System.delete_env("SMTP_PASSWORD")
+ System.delete_env("SMTP_PASSWORD_FILE")
+ System.delete_env("SMTP_SSL")
+ end
+end
diff --git a/test/mv/mailer_test.exs b/test/mv/mailer_test.exs
new file mode 100644
index 0000000..22cc49f
--- /dev/null
+++ b/test/mv/mailer_test.exs
@@ -0,0 +1,46 @@
+defmodule Mv.MailerTest do
+ @moduledoc """
+ Unit tests for Mv.Mailer, in particular send_test_email/1.
+
+ Uses Swoosh.Adapters.Test (configured in test.exs); no real SMTP. Asserts
+ success/error contract and that one test email is sent on success.
+ """
+ use Mv.DataCase, async: true
+
+ import Swoosh.TestAssertions
+
+ alias Mv.Mailer
+
+ describe "send_test_email/1" do
+ test "returns {:ok, email} and sends one email with expected subject/body when successful" do
+ to_email = "test-#{System.unique_integer([:positive])}@example.com"
+
+ assert {:ok, _email} = Mailer.send_test_email(to_email)
+
+ assert_email_sent(fn email ->
+ to_addresses = Enum.map(email.to, &elem(&1, 1))
+ subject = email.subject || ""
+ body = email.html_body || email.text_body || ""
+
+ to_email in to_addresses and
+ (String.contains?(subject, "Test") or String.contains?(body, "test"))
+ end)
+ end
+
+ test "returns {:error, reason} for invalid email address" do
+ result = Mailer.send_test_email("not-an-email")
+ assert {:error, _reason} = result
+ end
+
+ test "uses mail_from as sender" do
+ to_email = "recipient-#{System.unique_integer([:positive])}@example.com"
+ assert {:ok, _} = Mailer.send_test_email(to_email)
+
+ assert_email_sent(fn email ->
+ {_name, from_email} = Mailer.mail_from()
+ from_addresses = Enum.map(email.from, &elem(&1, 1))
+ from_email in from_addresses
+ end)
+ end
+ end
+end
diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs
index 6a739b5..0cb4ead 100644
--- a/test/mv_web/live/global_settings_live_test.exs
+++ b/test/mv_web/live/global_settings_live_test.exs
@@ -65,4 +65,52 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "must be present"
end
end
+
+ describe "SMTP / E-Mail section" do
+ setup %{conn: conn} do
+ user = create_test_user(%{email: "admin@example.com"})
+ conn = conn_with_oidc_user(conn, user)
+ {:ok, conn: conn, user: user}
+ end
+
+ test "renders SMTP section with host/port fields and test email area", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/settings")
+ # Section title (Gettext key: SMTP or E-Mail per concept)
+ assert html =~ "SMTP" or html =~ "E-Mail"
+ end
+
+ test "shows Send test email button when SMTP is configured", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ # When Mv.Config.smtp_configured?() is true, button and recipient input should be present
+ # In test env SMTP is typically not configured; we only assert the section exists
+ html = render(view)
+ assert html =~ "SMTP" or html =~ "E-Mail"
+ end
+
+ test "send test email with valid address shows success or error result", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ # If test email UI exists: fill recipient, click button, assert result area updates
+ # Uses data-testid or button text "Send test email" / "Test email"
+ if has_element?(view, "[data-testid='smtp-test-email-form']") do
+ view
+ |> element("[data-testid='smtp-test-email-input']")
+ |> render_change(%{"to_email" => "test@example.com"})
+ view
+ |> element("[data-testid='smtp-send-test-email']")
+ |> render_click()
+ # Result is either success or error message
+ assert has_element?(view, "[data-testid='smtp-test-result']")
+ else
+ # Section not yet implemented: just ensure page still renders
+ assert render(view) =~ "Settings"
+ end
+ end
+
+ test "shows warning when SMTP is not configured in production", %{conn: conn} do
+ # Concept: in prod, show warning "SMTP is not configured. Transactional emails..."
+ # In test we only check that the section exists; warning visibility is env-dependent
+ {:ok, view, html} = live(conn, ~p"/settings")
+ assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
+ end
+ end
end
diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs
index bd133cd..1458973 100644
--- a/test/mv_web/live/join_live_test.exs
+++ b/test/mv_web/live/join_live_test.exs
@@ -39,6 +39,8 @@ defmodule MvWeb.JoinLiveTest do
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
conn: conn
} do
+ # Re-apply allowlist so this test is robust when run in parallel with others (Settings singleton).
+ enable_join_form_for_test(%{})
count_before = count_join_requests()
{:ok, view, _html} = live(conn, "/join")
From a4f3aa5d6ff6ee903f1a2157d7f1941045b09b52 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 12 Mar 2026 13:39:48 +0100
Subject: [PATCH 02/26] feat: add smtp settings
---
CODE_GUIDELINES.md | 28 +-
config/runtime.exs | 61 ++-
docs/feature-roadmap.md | 6 +-
docs/smtp-configuration-concept.md | 99 +++--
lib/membership/setting.ex | 107 ++++-
.../send_new_user_confirmation_email.ex | 31 +-
.../user/senders/send_password_reset_email.ex | 28 +-
lib/mv/config.ex | 181 ++++++++-
lib/mv/mailer.ex | 187 ++++++++-
lib/mv_web/live/global_settings_live.ex | 381 +++++++++++++++++-
mix.exs | 2 +
mix.lock | 1 +
priv/gettext/de/LC_MESSAGES/default.po | 186 ++++++++-
priv/gettext/default.pot | 186 ++++++++-
priv/gettext/en/LC_MESSAGES/default.po | 186 ++++++++-
.../20260311082352_add_smtp_to_settings.exs | 27 ++
...260311140000_add_mail_from_to_settings.exs | 18 +
.../repo/join_requests/20260311082353.json | 243 +++++++++++
.../repo/members/20260311082354.json | 246 +++++++++++
.../repo/settings/20260311082355.json | 347 ++++++++++++++++
test/membership/setting_smtp_test.exs | 1 -
test/mv/mailer_test.exs | 7 +-
.../mv_web/live/global_settings_live_test.exs | 17 +-
23 files changed, 2424 insertions(+), 152 deletions(-)
create mode 100644 priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
create mode 100644 priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs
create mode 100644 priv/resource_snapshots/repo/join_requests/20260311082353.json
create mode 100644 priv/resource_snapshots/repo/members/20260311082354.json
create mode 100644 priv/resource_snapshots/repo/settings/20260311082355.json
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 6f8deb5..e1dfc75 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -1267,7 +1267,27 @@ 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).
+- 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:** `tls_options: [verify: :verify_none]` (STARTTLS/587) and `sockopts: [verify: :verify_none]` (SSL/465) are set to allow self-signed / internal certs.
+- **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.
+- 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.
**Unified layout (transactional emails):**
@@ -1287,7 +1307,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
diff --git a/config/runtime.exs b/config/runtime.exs
index b8570d8..b522426 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -223,19 +223,50 @@ 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.Config.smtp_*() helpers.
+ 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_opts =
+ [
+ adapter: Swoosh.Adapters.SMTP,
+ relay: String.trim(smtp_host_env),
+ port: smtp_port_env,
+ username: System.get_env("SMTP_USERNAME"),
+ password: smtp_password_env,
+ ssl: smtp_ssl_mode == "ssl",
+ tls: if(smtp_ssl_mode == "tls", do: :always, else: :never),
+ auth: :always,
+ # Allow self-signed or internal SMTP server certs (OTP 26+ enforces verify_peer with cacerts).
+ # tls_options: STARTTLS (587); sockopts: direct SSL (465).
+ tls_options: [verify: :verify_none],
+ sockopts: [verify: :verify_none]
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+
+ config :mv, Mv.Mailer, smtp_opts
+ end
end
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index f3b1e27..c74f064 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -270,8 +270,10 @@
**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:**
-- ❌ **SMTP configuration** – Configure mail server via ENV and Admin Settings, test email from Settings. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
- ❌ Email templates configuration
- ❌ System health dashboard
- ❌ Audit log viewer
@@ -288,7 +290,7 @@
- ✅ Swoosh mailer integration
- ✅ Email confirmation (via AshAuthentication)
- ✅ Password reset emails (via AshAuthentication)
-- ⚠️ No SMTP configuration (mailer uses Local/Test adapter; prod not configured)
+- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
- ⚠️ No member communication features
**Missing Features:**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index b0ca8cc..75e3e85 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -1,7 +1,7 @@
# SMTP Configuration – Concept
-**Status:** Draft
-**Last updated:** 2026-03-11
+**Status:** Implemented
+**Last updated:** 2026-03-12
---
@@ -13,8 +13,8 @@ Enable configurable SMTP for sending transactional emails (join confirmation, us
## 2. Scope
-- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), test email from Settings UI, warning when SMTP is not configured in production.
-- **Out of scope:** Changing how AshAuthentication or existing senders use the mailer; they keep using `Mv.Mailer` and `mail_from/0`. No separate "form_mail" config – the existing **mail_from** (MAIL_FROM_NAME, MAIL_FROM_EMAIL) remains the single sender identity for all transactional mails.
+- **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.
---
@@ -31,71 +31,84 @@ When an ENV variable is set, the corresponding Settings field is read-only in th
## 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 | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
-| TLS/SSL | `SMTP_SSL` or similar | `smtp_ssl` | e.g. `tls` / `ssl` / `none` (default: tls)|
+| 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 |
-**Sender (unchanged):** `mail_from` stays separate (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` in ENV; no DB fields for from-address).
+**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.
---
## 5. Password from File
-Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` and `TOKEN_SIGNING_SECRET_FILE` in `runtime.exs`. Read once at runtime when building mailer config; ENV `SMTP_PASSWORD` overrides file if both are set (or define explicit precedence and document it).
+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 (e.g. no host):
- - Keep using the default adapter (e.g. Local) or a no-op adapter so the app does not crash.
- - **Show a clear warning in the Settings UI** (SMTP section): e.g. "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." and optionally list consequences (no join confirmations, no password resets, etc.).
- - Log a warning at startup or when sending is attempted if SMTP is not configured in prod.
+- **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 (same page as OIDC, Vereinfacht).
-- **Elements:**
- - Input: **recipient email address** (required for sending).
- - Button: **"Send test email"** (or similar).
-- **Behaviour:** On click, send one simple transactional-style email to the given address (subject and body translatable via Gettext, e.g. "Mila – Test email" / "This is a test."). Use current SMTP config and `mail_from`.
-- **Feedback:** Show success message or error (e.g. connection refused, auth failed, invalid address). Reuse the same UI pattern as Vereinfacht "Test Integration" (result assign, small result component with success/error states).
-- **Permission:** Reuse existing Settings page authorization (admin); no extra check for the test-email action.
+- **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. Implementation Hints
+## 8. Sender Identity (`mail_from`)
-- **Config module:** Extend `Mv.Config` with `smtp_*` helpers (e.g. `smtp_host/0`, `smtp_port/0`, …) using `env_or_setting/2` and, for password, ENV vs `SMTP_PASSWORD_FILE` vs Settings (sensitive).
-- **runtime.exs:** When SMTP is configured (e.g. host present), set `config :mv, Mv.Mailer, adapter: Swoosh.Adapters.SMTP, ...` with the merged options. Otherwise leave adapter as in base config (Local in dev, Test in test, and in prod either Local with warning or explicit "not configured" behaviour).
-- **Setting resource:** New attributes: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password` (sensitive), `smtp_ssl` (string or enum). Add to create/update `accept` lists and to seeds if needed.
-- **Migration:** Add columns for the new Setting attributes.
-- **Test email:** New function (e.g. `Mv.Mailer.send_test_email(to_email)`) returning `{:ok, _}` or `{:error, reason}`; call from LiveView event and render result in the SMTP section.
+`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. Documentation and i18n
+## 9. AshAuthentication Senders
-- **Gettext:** Use Gettext for test email subject and body and for all new Settings labels/hints (including the "SMTP not configured" warning).
-- **Docs:** Update `CODE_GUIDELINES.md` (e.g. §3.11 Email) and deployment/configuration docs to describe ENV and Settings for SMTP and the test email. Add this feature to `docs/feature-roadmap.md` (e.g. under Admin Panel & Configuration or Communication).
+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`.
---
-## 10. Summary Checklist
+## 10. TLS / SSL in OTP 27
-- [ ] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL` (or equivalent).
-- [ ] Settings: attributes and UI for host, port, username, password, TLS/SSL; ENV-override hints.
-- [ ] Password from file: `SMTP_PASSWORD_FILE` supported in runtime config.
-- [ ] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
-- [ ] Prod warning: clear message in Settings when SMTP is not configured, with consequences.
-- [ ] Test email: button + recipient field, translatable content, success/error display; existing permission sufficient.
-- [ ] Gettext for new UI and test email text.
-- [ ] Feature roadmap and code guidelines updated.
+OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
+
+Both `tls_options: [verify: :verify_none]` (for STARTTLS, port 587) and `sockopts: [verify: :verify_none]` (for direct SSL, port 465) are set in `Mv.Mailer.smtp_config/0` to allow such certificates.
+
+For ENV-based boot config, the same options are set in `config/runtime.exs`.
+
+---
+
+## 11. 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 + sockopts).
+- [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] 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.
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index bc2b1e7..827e194 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -58,7 +58,8 @@ defmodule Mv.Membership.Setting do
"""
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
@@ -73,8 +74,50 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
+ # All public attributes except smtp_password, used to exclude it from default reads.
+ # This list is used in the read prepare to prevent the sensitive password from being
+ # returned in standard reads (it can still be read via explicit select in Config).
+ @public_attributes [
+ :id,
+ :club_name,
+ :member_field_visibility,
+ :member_field_required,
+ :include_joining_cycle,
+ :default_membership_fee_type_id,
+ :vereinfacht_api_url,
+ :vereinfacht_api_key,
+ :vereinfacht_club_id,
+ :vereinfacht_app_url,
+ :oidc_client_id,
+ :oidc_base_url,
+ :oidc_redirect_uri,
+ :oidc_client_secret,
+ :oidc_admin_group_name,
+ :oidc_groups_claim,
+ :oidc_only,
+ :smtp_host,
+ :smtp_port,
+ :smtp_username,
+ :smtp_ssl,
+ :smtp_from_name,
+ :smtp_from_email,
+ :join_form_enabled,
+ :join_form_field_ids,
+ :join_form_field_required,
+ :inserted_at,
+ :updated_at
+ ]
+
actions do
- defaults [:read]
+ read :read do
+ primary? true
+
+ # smtp_password is excluded from the default select to prevent it from being returned
+ # in plaintext via standard reads. Config reads it via an explicit select internally.
+ prepare fn query, _context ->
+ Ash.Query.select(query, @public_attributes)
+ 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 +140,13 @@ 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,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -126,6 +176,13 @@ 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,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -429,6 +486,52 @@ 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
+
# Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do
allow_nil? false
diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
index 393a220..7312b91 100644
--- a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
+++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
@@ -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)
}
- new()
- |> from(Mailer.mail_from())
- |> to(to_string(user.email))
- |> subject(subject)
- |> put_view(MvWeb.EmailsView)
- |> render_body("user_confirmation.html", assigns)
- |> Mailer.deliver!()
+ email =
+ new()
+ |> from(Mailer.mail_from())
+ |> to(to_string(user.email))
+ |> subject(subject)
+ |> put_view(MvWeb.EmailsView)
+ |> render_body("user_confirmation.html", assigns)
+
+ case Mailer.deliver(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error(
+ "Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
+ )
+
+ :ok
+ end
end
end
diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex
index 74d5d47..e276e20 100644
--- a/lib/mv/accounts/user/senders/send_password_reset_email.ex
+++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex
@@ -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)
}
- new()
- |> from(Mailer.mail_from())
- |> to(to_string(user.email))
- |> subject(subject)
- |> put_view(MvWeb.EmailsView)
- |> render_body("password_reset.html", assigns)
- |> Mailer.deliver!()
+ email =
+ new()
+ |> from(Mailer.mail_from())
+ |> to(to_string(user.email))
+ |> subject(subject)
+ |> put_view(MvWeb.EmailsView)
+ |> render_body("password_reset.html", assigns)
+
+ case Mailer.deliver(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
+ :ok
+ end
end
end
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index e176b8c..b824c1d 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -451,40 +451,191 @@ defmodule Mv.Config do
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
# ---------------------------------------------------------------------------
- # SMTP configuration (stubs for TDD – ENV overrides Settings; see docs/smtp-configuration-concept.md)
+ # SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
# ---------------------------------------------------------------------------
- @doc "Returns SMTP host. ENV SMTP_HOST overrides Settings. Stub: always nil until implemented."
+ @doc """
+ Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
+ """
@spec smtp_host() :: String.t() | nil
- def smtp_host, do: nil
+ def smtp_host do
+ smtp_env_or_setting("SMTP_HOST", :smtp_host)
+ end
- @doc "Returns SMTP port (e.g. 587). ENV SMTP_PORT overrides Settings. Stub: always nil until implemented."
+ @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: nil
+ def smtp_port do
+ case System.get_env("SMTP_PORT") do
+ nil ->
+ get_from_settings_integer(:smtp_port)
- @doc "Returns SMTP username. ENV SMTP_USERNAME overrides Settings. Stub: always nil until implemented."
+ 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: nil
+ def smtp_username do
+ smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
+ end
- @doc "Returns SMTP password. ENV SMTP_PASSWORD overrides SMTP_PASSWORD_FILE overrides Settings. Stub: always nil until implemented."
+ @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: 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
- @doc "Returns SMTP TLS/SSL mode (e.g. 'tls', 'ssl', 'none'). Stub: always nil until implemented."
+ 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: nil
+ def smtp_ssl do
+ smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
+ end
- @doc "Returns true when SMTP is configured (e.g. host present). Stub: always false until implemented."
+ @doc """
+ Returns true when SMTP is configured (host present from ENV or Settings).
+ """
@spec smtp_configured?() :: boolean()
- def smtp_configured?, do: false
+ def smtp_configured? do
+ present?(smtp_host())
+ end
- @doc "Returns true when any SMTP ENV variable is set (for Settings UI hint). Stub: always false until implemented."
+ @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: false
+ 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
end
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index e78735b..8fca77b 100644
--- a/lib/mv/mailer.ex
+++ b/lib/mv/mailer.ex
@@ -4,27 +4,194 @@ 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"}`.
+ require Logger
+
+ @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 (e.g. invalid address,
- SMTP not configured, connection error). Stub: always returns error until implemented.
+ 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, term()}
- def send_test_email(_to_email) do
- {:error, :not_implemented}
+ @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("
#{body}
")
+
+ 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
+ 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"
+
+ [
+ adapter: Swoosh.Adapters.SMTP,
+ relay: host,
+ port: port,
+ ssl: ssl_mode == "ssl",
+ tls: if(ssl_mode == "tls", do: :always, else: :never),
+ auth: :always,
+ username: username,
+ password: password,
+ # OTP 26+ enforces verify_peer; allow self-signed / internal certs.
+ # tls_options: STARTTLS upgrade (port 587); sockopts: direct SSL connect (port 465).
+ tls_options: [verify: :verify_none],
+ sockopts: [verify: :verify_none]
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+ 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
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 3c75fa8..2662dd1 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -77,6 +77,18 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
+ |> 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_form()
@@ -137,21 +149,6 @@ defmodule MvWeb.GlobalSettingsLive do
- <%!-- Board approval (future feature) --%>
-
-
-
-
-
<%!-- Field list header + Add button (left-aligned) --%>
{gettext("Fields on the join form")}
@@ -269,6 +266,181 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- SMTP / E-Mail Section --%>
+ <.form_section title={gettext("SMTP / E-Mail")}>
+ <%= if @smtp_env_configured do %>
+
+ {gettext("Some values are set via environment variables. Those fields are read-only.")}
+
+ <% end %>
+
+ <%= if Mix.env() == :prod and not @smtp_configured do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
+
+ {gettext(
+ "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+ )}
+
+
+ {gettext(
+ "The sender email must be owned by or authorized for the SMTP user on most servers."
+ )}
+
+ <.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")}
+
+
+
+ <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Invalid email address. Please enter a valid recipient address.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
+ {gettext("SMTP is not configured. Please set at least the SMTP host.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext("Authentication failed. Please check the SMTP username and password.")}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Recipient address rejected by the server.")}
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+
+ {gettext(
+ "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+ )}
+
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
+ ~H"""
+
+ <.icon name="hero-x-circle" class="size-5 shrink-0" />
+ {gettext("Failed to send test email. Please check your SMTP configuration.")}
+
+ """
+ end
+
# ---- Join form helper functions ----
defp assign_join_form_state(socket, settings, custom_fields) do
diff --git a/mix.exs b/mix.exs
index 56e7dde..29dbc25 100644
--- a/mix.exs
+++ b/mix.exs
@@ -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"},
diff --git a/mix.lock b/mix.lock
index 8ac995a..b177796 100644
--- a/mix.lock
+++ b/mix.lock
@@ -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"},
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 055f36a..9c94d0e 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -461,6 +461,7 @@ msgstr "Sonderzeichen empfohlen"
msgid "Include both letters and numbers"
msgstr "Buchstaben und Zahlen verwenden"
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
@@ -3391,11 +3392,6 @@ msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu."
msgid "Remove field %{label}"
msgstr "Feld %{label} entfernen"
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format
-msgid "Board approval required (in development)"
-msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
@@ -3623,3 +3619,183 @@ msgstr "Offene Anträge"
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Geprüft von"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to send test email. Please check your SMTP configuration."
+msgstr "Test-E-Mail konnte nicht gesendet werden. Bitte prüfe deine SMTP-Konfiguration."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_HOST"
+msgstr "Von SMTP_HOST"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PASSWORD"
+msgstr "Von SMTP_PASSWORD"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PORT"
+msgstr "Von SMTP_PORT"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_SSL"
+msgstr "Von SMTP_SSL"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_USERNAME"
+msgstr "Von SMTP_USERNAME"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Host"
+msgstr "Host"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid email address. Please enter a valid recipient address."
+msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein."
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "Mila – Test email"
+msgstr "Mila – Test-E-Mail"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "None (port 25, insecure)"
+msgstr "Keines (Port 25, unsicher)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Port"
+msgstr "Port"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Recipient"
+msgstr "Empfänger*in"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP / E-Mail"
+msgstr "SMTP / E-Mail"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Please set at least the SMTP host."
+msgstr "SMTP ist nicht konfiguriert. Bitte setze mindestens den SMTP-Host."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+msgstr "SMTP ist nicht konfiguriert. Transaktions-E-Mails (Beitrittsbestätigung, Passwort-Reset usw.) werden möglicherweise nicht zuverlässig zugestellt."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SSL (port 465)"
+msgstr "SSL (Port 465)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Save SMTP Settings"
+msgstr "SMTP-Einstellungen speichern"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Send test email"
+msgstr "Test-E-Mail senden"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sending..."
+msgstr "Sende..."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS (port 587, recommended)"
+msgstr "TLS (Port 587, empfohlen)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS/SSL"
+msgstr "TLS/SSL"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Test email"
+msgstr "Test-E-Mail"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Test email sent successfully."
+msgstr "Test-E-Mail erfolgreich gesendet."
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
+msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Username"
+msgstr "Benutzername"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Authentication failed. Please check the SMTP username and password."
+msgstr "Authentifizierung fehlgeschlagen. Bitte Benutzername und Passwort prüfen."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_EMAIL"
+msgstr "Aus MAIL_FROM_EMAIL"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_NAME"
+msgstr "Aus MAIL_FROM_NAME"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Recipient address rejected by the server."
+msgstr "Empfängeradresse vom Server abgelehnt."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP error:"
+msgstr "SMTP-Fehler:"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+msgstr "Absenderadresse abgelehnt. Die \"Absender-E-Mail\" muss dem SMTP-Nutzer gehören oder für ihn erlaubt sein."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender email (From)"
+msgstr "Absender-E-Mail (Von)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender name (From)"
+msgstr "Absendername (Von)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Server unreachable. Check host and port."
+msgstr "Server nicht erreichbar. Host und Port prüfen."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+msgstr "TLS-Verbindung fehlgeschlagen. TLS/SSL-Einstellung und Port prüfen (587 für TLS, 465 für SSL)."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
+msgstr "Die Absender-E-Mail muss auf den meisten SMTP-Servern dem SMTP-Nutzer gehören oder für ihn erlaubt sein."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index a1e0909..1379299 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -462,6 +462,7 @@ msgstr ""
msgid "Include both letters and numbers"
msgstr ""
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
@@ -3391,11 +3392,6 @@ msgstr ""
msgid "Remove field %{label}"
msgstr ""
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format
-msgid "Board approval required (in development)"
-msgstr ""
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
@@ -3623,3 +3619,183 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to send test email. Please check your SMTP configuration."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_HOST"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PASSWORD"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PORT"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_SSL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_USERNAME"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Host"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid email address. Please enter a valid recipient address."
+msgstr ""
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "Mila – Test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "None (port 25, insecure)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Port"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Recipient"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP / E-Mail"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Please set at least the SMTP host."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SSL (port 465)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Save SMTP Settings"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Send test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sending..."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS (port 587, recommended)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS/SSL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Test email sent successfully."
+msgstr ""
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Username"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Authentication failed. Please check the SMTP username and password."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_EMAIL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_NAME"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Recipient address rejected by the server."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP error:"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender email (From)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender name (From)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Server unreachable. Check host and port."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index eccae34..a83ef1f 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -462,6 +462,7 @@ msgstr ""
msgid "Include both letters and numbers"
msgstr ""
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
@@ -3391,11 +3392,6 @@ msgstr ""
msgid "Remove field %{label}"
msgstr ""
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format
-msgid "Board approval required (in development)"
-msgstr "Board approval required (in development)"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
@@ -3623,3 +3619,183 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Review by"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to send test email. Please check your SMTP configuration."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_HOST"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PASSWORD"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_PORT"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_SSL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From SMTP_USERNAME"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Host"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid email address. Please enter a valid recipient address."
+msgstr ""
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "Mila – Test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "None (port 25, insecure)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Port"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Recipient"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP / E-Mail"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Please set at least the SMTP host."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SSL (port 465)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Save SMTP Settings"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Send test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Sending..."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS (port 587, recommended)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS/SSL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Test email"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Test email sent successfully."
+msgstr ""
+
+#: lib/mv/mailer.ex
+#, elixir-autogen, elixir-format
+msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Username"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Authentication failed. Please check the SMTP username and password."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_EMAIL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "From MAIL_FROM_NAME"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Recipient address rejected by the server."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "SMTP error:"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender email (From)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Sender name (From)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Server unreachable. Check host and port."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "The sender email must be owned by or authorized for the SMTP user on most servers."
+msgstr ""
diff --git a/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs b/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
new file mode 100644
index 0000000..2439035
--- /dev/null
+++ b/priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
@@ -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
diff --git a/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs b/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs
new file mode 100644
index 0000000..c680763
--- /dev/null
+++ b/priv/repo/migrations/20260311140000_add_mail_from_to_settings.exs
@@ -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
diff --git a/priv/resource_snapshots/repo/join_requests/20260311082353.json b/priv/resource_snapshots/repo/join_requests/20260311082353.json
new file mode 100644
index 0000000..26b6310
--- /dev/null
+++ b/priv/resource_snapshots/repo/join_requests/20260311082353.json
@@ -0,0 +1,243 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "\"pending_confirmation\"",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "status",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "first_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "last_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "form_data",
+ "type": "map"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "schema_version",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "confirmation_token_hash",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "confirmation_token_expires_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "confirmation_sent_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "submitted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "approved_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "rejected_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "join_requests_reviewed_by_user_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "users"
+ },
+ "scale": null,
+ "size": null,
+ "source": "reviewed_by_user_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "source",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "F01A57710F9E6C9CF0E006B3B956AE5930D2C12FC502BF31683BEB3A75094BD8",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "join_requests"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/members/20260311082354.json b/priv/resource_snapshots/repo/members/20260311082354.json
new file mode 100644
index 0000000..8795bdc
--- /dev/null
+++ b/priv/resource_snapshots/repo/members/20260311082354.json
@@ -0,0 +1,246 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v7()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "first_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "last_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "exit_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "notes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "city",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "street",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "house_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "postal_code",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "country",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "search_vector",
+ "type": "tsvector"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "membership_fee_start_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "vereinfacht_contact_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "members_membership_fee_type_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "membership_fee_types"
+ },
+ "scale": null,
+ "size": null,
+ "source": "membership_fee_type_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "F704B80F108D01A7DF0C3B973FC94DBD778BD5555219BADB3C84EF1C91D9A3EF",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "members_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "members"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/settings/20260311082355.json b/priv/resource_snapshots/repo/settings/20260311082355.json
new file mode 100644
index 0000000..099c8ef
--- /dev/null
+++ b/priv/resource_snapshots/repo/settings/20260311082355.json
@@ -0,0 +1,347 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "club_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "member_field_visibility",
+ "type": "map"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "member_field_required",
+ "type": "map"
+ },
+ {
+ "allow_nil?": false,
+ "default": "true",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "include_joining_cycle",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "default_membership_fee_type_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "vereinfacht_api_url",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "vereinfacht_api_key",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "vereinfacht_club_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "vereinfacht_app_url",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_client_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_base_url",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_redirect_uri",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_client_secret",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_admin_group_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_groups_claim",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_only",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "smtp_host",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "smtp_port",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "smtp_username",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "smtp_password",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "smtp_ssl",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_form_enabled",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "[]",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_form_field_ids",
+ "type": [
+ "array",
+ "text"
+ ]
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_form_field_required",
+ "type": "map"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "DDF99732D268EDCACB5F61CAA53B24F1EAA8EE2F54F4A31A2FB3FEF8DDC8BFAF",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "settings"
+}
\ No newline at end of file
diff --git a/test/membership/setting_smtp_test.exs b/test/membership/setting_smtp_test.exs
index ea4a954..b4c4e70 100644
--- a/test/membership/setting_smtp_test.exs
+++ b/test/membership/setting_smtp_test.exs
@@ -8,7 +8,6 @@ defmodule Mv.Membership.SettingSmtpTest do
"""
use Mv.DataCase, async: false
- alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
diff --git a/test/mv/mailer_test.exs b/test/mv/mailer_test.exs
index 22cc49f..b5db447 100644
--- a/test/mv/mailer_test.exs
+++ b/test/mv/mailer_test.exs
@@ -37,9 +37,10 @@ defmodule Mv.MailerTest do
assert {:ok, _} = Mailer.send_test_email(to_email)
assert_email_sent(fn email ->
- {_name, from_email} = Mailer.mail_from()
- from_addresses = Enum.map(email.from, &elem(&1, 1))
- from_email in from_addresses
+ {_name, expected_from} = Mailer.mail_from()
+ # email.from is a single {name, address} tuple in Swoosh, not a list
+ {_name, actual_from} = email.from
+ actual_from == expected_from
end)
end
end
diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs
index 0cb4ead..e48c44b 100644
--- a/test/mv_web/live/global_settings_live_test.exs
+++ b/test/mv_web/live/global_settings_live_test.exs
@@ -89,19 +89,16 @@ defmodule MvWeb.GlobalSettingsLiveTest do
test "send test email with valid address shows success or error result", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
- # If test email UI exists: fill recipient, click button, assert result area updates
- # Uses data-testid or button text "Send test email" / "Test email"
+
if has_element?(view, "[data-testid='smtp-test-email-form']") do
+ # Submit the test-email form (phx-submit) with a valid recipient address
view
- |> element("[data-testid='smtp-test-email-input']")
- |> render_change(%{"to_email" => "test@example.com"})
- view
- |> element("[data-testid='smtp-send-test-email']")
- |> render_click()
- # Result is either success or error message
+ |> form("[data-testid='smtp-test-email-form']", %{"to_email" => "test@example.com"})
+ |> render_submit()
+
+ # Result area must appear regardless of success or error
assert has_element?(view, "[data-testid='smtp-test-result']")
else
- # Section not yet implemented: just ensure page still renders
assert render(view) =~ "Settings"
end
end
@@ -109,7 +106,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do
test "shows warning when SMTP is not configured in production", %{conn: conn} do
# Concept: in prod, show warning "SMTP is not configured. Transactional emails..."
# In test we only check that the section exists; warning visibility is env-dependent
- {:ok, view, html} = live(conn, ~p"/settings")
+ {:ok, _view, html} = live(conn, ~p"/settings")
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
end
end
From 942f2afd9ec765c02e752ac09b71a2272d82694c Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 12 Mar 2026 15:29:54 +0100
Subject: [PATCH 03/26] refactor: adress review
---
CODE_GUIDELINES.md | 5 ++-
config/config.exs | 8 ++++
config/runtime.exs | 18 +++++++--
docs/smtp-configuration-concept.md | 14 ++++++-
lib/membership/setting.ex | 54 +++++++++----------------
lib/mv/config.ex | 38 ++++++++++++++---
lib/mv/mailer.ex | 13 ++++--
lib/mv_web/live/global_settings_live.ex | 20 ++++-----
8 files changed, 108 insertions(+), 62 deletions(-)
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 7dfa3ef..0cb8d65 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -1274,15 +1274,16 @@ mix hex.outdated
**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()`.
- 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:** `tls_options: [verify: :verify_none]` (STARTTLS/587) and `sockopts: [verify: :verify_none]` (SSL/465) are set to allow self-signed / internal certs.
+- **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.
+- **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:**
diff --git a/config/config.exs b/config/config.exs
index ab55f2a..35e4160 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -51,6 +51,10 @@ config :mv,
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 +93,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"}
diff --git a/config/runtime.exs b/config/runtime.exs
index b522426..1c55f64 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -227,6 +227,10 @@ if config_env() == :prod do
# 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.Config.smtp_*() helpers.
+ #
+ # TLS/SSL options (tls_options, sockopts) are duplicated here and in Mv.Mailer.smtp_config/0
+ # because boot config must be set in this file; the Mailer uses the same logic for
+ # Settings-only config. Keep verify behaviour in sync (see SMTP_VERIFY_PEER below).
smtp_host_env = System.get_env("SMTP_HOST")
if smtp_host_env && String.trim(smtp_host_env) != "" do
@@ -250,6 +254,15 @@ if config_env() == :prod do
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 =
[
adapter: Swoosh.Adapters.SMTP,
@@ -260,10 +273,9 @@ if config_env() == :prod do
ssl: smtp_ssl_mode == "ssl",
tls: if(smtp_ssl_mode == "tls", do: :always, else: :never),
auth: :always,
- # Allow self-signed or internal SMTP server certs (OTP 26+ enforces verify_peer with cacerts).
# tls_options: STARTTLS (587); sockopts: direct SSL (465).
- tls_options: [verify: :verify_none],
- sockopts: [verify: :verify_none]
+ tls_options: [verify: verify_mode],
+ sockopts: [verify: verify_mode]
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index 75e3e85..30fd7de 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -92,9 +92,12 @@ Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
-Both `tls_options: [verify: :verify_none]` (for STARTTLS, port 587) and `sockopts: [verify: :verify_none]` (for direct SSL, port 465) are set in `Mv.Mailer.smtp_config/0` to allow such 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:
-For ENV-based boot config, the same options are set in `config/runtime.exs`.
+- **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.
+
+Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) use the same verify mode. The logic is duplicated in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync.
---
@@ -112,3 +115,10 @@ For ENV-based boot config, the same options are set in `config/runtime.exs`.
- [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.
+
+---
+
+## 12. 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.
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 827e194..ce63589 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -56,6 +56,9 @@ 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,
@@ -65,6 +68,8 @@ defmodule Mv.Membership.Setting do
@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
@@ -74,48 +79,25 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
- # All public attributes except smtp_password, used to exclude it from default reads.
- # This list is used in the read prepare to prevent the sensitive password from being
- # returned in standard reads (it can still be read via explicit select in Config).
- @public_attributes [
- :id,
- :club_name,
- :member_field_visibility,
- :member_field_required,
- :include_joining_cycle,
- :default_membership_fee_type_id,
- :vereinfacht_api_url,
- :vereinfacht_api_key,
- :vereinfacht_club_id,
- :vereinfacht_app_url,
- :oidc_client_id,
- :oidc_base_url,
- :oidc_redirect_uri,
- :oidc_client_secret,
- :oidc_admin_group_name,
- :oidc_groups_claim,
- :oidc_only,
- :smtp_host,
- :smtp_port,
- :smtp_username,
- :smtp_ssl,
- :smtp_from_name,
- :smtp_from_email,
- :join_form_enabled,
- :join_form_field_ids,
- :join_form_field_required,
- :inserted_at,
- :updated_at
- ]
+ # Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
+ # read only via explicit select when needed; never loaded into default get_settings().
+ @excluded_from_read [:smtp_password, :oidc_client_secret]
actions do
read :read do
primary? true
- # smtp_password is excluded from the default select to prevent it from being returned
- # in plaintext via standard reads. Config reads it via an explicit select internally.
+ # 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 ->
- Ash.Query.select(query, @public_attributes)
+ select_attrs =
+ __MODULE__
+ |> ResourceInfo.attribute_names()
+ |> MapSet.to_list()
+ |> Kernel.--(@excluded_from_read)
+
+ Ash.Query.select(query, select_attrs)
end
end
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index b824c1d..3494937 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -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.
@@ -638,4 +653,17 @@ defmodule Mv.Config do
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
diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex
index 8fca77b..e5ac4e9 100644
--- a/lib/mv/mailer.ex
+++ b/lib/mv/mailer.ex
@@ -33,6 +33,7 @@ defmodule Mv.Mailer do
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 """
@@ -105,6 +106,11 @@ defmodule Mv.Mailer do
password = Mv.Config.smtp_password()
ssl_mode = Mv.Config.smtp_ssl() || "tls"
+ verify_mode =
+ if Application.get_env(:mv, :smtp_verify_peer, false),
+ do: :verify_peer,
+ else: :verify_none
+
[
adapter: Swoosh.Adapters.SMTP,
relay: host,
@@ -114,10 +120,9 @@ defmodule Mv.Mailer do
auth: :always,
username: username,
password: password,
- # OTP 26+ enforces verify_peer; allow self-signed / internal certs.
- # tls_options: STARTTLS upgrade (port 587); sockopts: direct SSL connect (port 465).
- tls_options: [verify: :verify_none],
- sockopts: [verify: :verify_none]
+ # tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER).
+ tls_options: [verify: verify_mode],
+ sockopts: [verify: verify_mode]
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
else
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 60c486d..ce3351a 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -54,11 +54,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"))
|> 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?())
@@ -76,7 +79,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> 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_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(: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?())
@@ -274,7 +277,7 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %>
- <%= if Mix.env() == :prod and not @smtp_configured do %>
+ <%= if @environment == :prod and not @smtp_configured do %>
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
@@ -688,13 +691,10 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
- # phx-change can fire with only _target (e.g. when focusing a field); avoid FunctionClauseError
- def handle_event("validate", params, socket) when is_map(params) do
- setting_params =
- params["setting"] || Map.get(socket.assigns.form.params || %{}, "setting") || %{}
-
- {:noreply,
- assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
+ # 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
@@ -777,7 +777,7 @@ defmodule MvWeb.GlobalSettingsLive do
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(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
From a5ce7cb9211f5e36691ee6b9cc139965f73a6a0c Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 12 Mar 2026 15:46:52 +0100
Subject: [PATCH 04/26] fix group performance test
---
lib/mv_web/components/layouts.ex | 23 ++++++++++++++---------
test/mv_web/live/group_live/show_test.exs | 10 ++++------
2 files changed, 18 insertions(+), 15 deletions(-)
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index a6d75ba..2979eb4 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -43,11 +43,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)
@@ -129,12 +129,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
diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs
index 1f0f1c2..4d64739 100644
--- a/test/mv_web/live/group_live/show_test.exs
+++ b/test/mv_web/live/group_live/show_test.exs
@@ -251,12 +251,10 @@ defmodule MvWeb.GroupLive.ShowTest do
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
end)
- # Verify query count is reasonable (should avoid N+1 queries)
- # Expected: 1 query for group lookup + 1 query for members (with preload) + member_count aggregate
- # Allow overhead for authorization, LiveView setup, and other initialization queries
- # Note: member_count aggregate and authorization checks may add additional queries
- assert final_count <= 20,
- "Expected max 20 queries (group + members preload + member_count aggregate + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
+ # Verify query count is reasonable (should avoid N+1 queries).
+ # Baseline: group + members preload + member_count aggregate + 1 layout get_settings + auth/role/join-count.
+ assert final_count <= 22,
+ "Expected max 22 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem."
end
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
From a7481f6ab1a306f0ccb9255f0714b7ce5d488338 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 12 Mar 2026 16:15:57 +0100
Subject: [PATCH 05/26] feat: improve field order for approvals and add seeds
---
docs/onboarding-join-concept.md | 3 +-
lib/mv_web/live/global_settings_live.ex | 38 ++++++++
lib/mv_web/live/join_request_live/show.ex | 104 ++++++++++++----------
priv/gettext/de/LC_MESSAGES/default.po | 55 ++++++++----
priv/gettext/default.pot | 55 ++++++++----
priv/gettext/en/LC_MESSAGES/default.po | 55 ++++++++----
priv/repo/seeds_bootstrap.exs | 19 +++-
priv/repo/seeds_dev.exs | 25 ++++--
8 files changed, 245 insertions(+), 109 deletions(-)
diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md
index 8083a7b..487256e 100644
--- a/docs/onboarding-join-concept.md
+++ b/docs/onboarding-join-concept.md
@@ -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)
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index ce3351a..84cf738 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -93,6 +93,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> 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}
@@ -153,6 +154,33 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- Copyable join page link (below checkbox, above field list) --%>
+
+
+ {gettext("Link to the public join page (share this with applicants):")}
+
-
- <%= if map_size(@join_request.form_data || %{}) > 0 do %>
-
-
{gettext("Additional form data")}
-
- <%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
- <.field_row label={key} value={to_string(value)} />
- <% end %>
-
-
- <% end %>
-
- <%= if @join_request.status in [:approved, :rejected] do %>
-
-
{gettext("Review information")}
-
+ <%= if @join_request.status in [:approved, :rejected] do %>
<%= if @join_request.approved_at do %>
<.field_row
label={gettext("Approved at")}
@@ -189,9 +172,9 @@ defmodule MvWeb.JoinRequestLive.Show do
value={JoinRequestHelpers.reviewer_display(@join_request)}
empty_text="-"
/>
-
+ <% end %>
- <% end %>
+
<%= if @join_request.status == :submitted do %>
@@ -240,40 +223,71 @@ 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)),
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index e99aa0d..1b163d4 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -110,11 +110,6 @@ msgstr "Feld hinzufügen"
msgid "Add members"
msgstr "Mitglieder hinzufügen"
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Additional form data"
-msgstr "Weitere Formulardaten"
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@@ -1121,7 +1116,6 @@ msgstr "Rolle bearbeiten"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -1374,7 +1368,6 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@@ -1792,7 +1785,6 @@ msgid "Last Name"
msgstr "Nachname"
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -2178,6 +2170,7 @@ msgstr "Neuer Betrag"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -2681,11 +2674,6 @@ msgstr "Mitglied aus Gruppe entfernen"
msgid "Reorder"
msgstr "Umordnen"
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Request data"
-msgstr "Antragsdaten"
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
@@ -2711,11 +2699,6 @@ msgstr "Passwort zurücksetzen"
msgid "Review by"
msgstr "Geprüft von"
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Review information"
-msgstr "Bearbeitungsinformationen"
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
@@ -3575,6 +3558,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -3776,3 +3760,38 @@ msgstr "aktualisiert"
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr "ohne %{name}"
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Applicant data"
+msgstr "Angaben des Antragstellers"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy"
+msgstr "Kopieren"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy join page URL"
+msgstr "URL der Beitrittsseite kopieren"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL"
+msgstr "URL der Beitrittsseite"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL copied to clipboard."
+msgstr "URL der Beitrittsseite in die Zwischenablage kopiert."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Link to the public join page (share this with applicants):"
+msgstr "Link zur öffentlichen Beitrittsseite (diesen Link mit Interessent*innen teilen):"
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Status and review"
+msgstr "Status und Prüfung"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 1679228..60e77c1 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -111,11 +111,6 @@ msgstr ""
msgid "Add members"
msgstr ""
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Additional form data"
-msgstr ""
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@@ -1122,7 +1117,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -1375,7 +1369,6 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@@ -1793,7 +1786,6 @@ msgid "Last Name"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -2179,6 +2171,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -2682,11 +2675,6 @@ msgstr ""
msgid "Reorder"
msgstr ""
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Request data"
-msgstr ""
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
@@ -2712,11 +2700,6 @@ msgstr ""
msgid "Review by"
msgstr ""
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Review information"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
@@ -3575,6 +3558,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -3776,3 +3760,38 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr ""
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Applicant data"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy join page URL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL copied to clipboard."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Link to the public join page (share this with applicants):"
+msgstr ""
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Status and review"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 8a016ed..4e4f87b 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -111,11 +111,6 @@ msgstr ""
msgid "Add members"
msgstr ""
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Additional form data"
-msgstr "Additional form data"
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Address"
@@ -1122,7 +1117,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -1375,7 +1369,6 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
@@ -1793,7 +1786,6 @@ msgid "Last Name"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
-#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
@@ -2179,6 +2171,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -2682,11 +2675,6 @@ msgstr ""
msgid "Reorder"
msgstr "Reorder"
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Request data"
-msgstr "Request data"
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex
@@ -2712,11 +2700,6 @@ msgstr "Reset your password"
msgid "Review by"
msgstr "Review by"
-#: lib/mv_web/live/join_request_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Review information"
-msgstr "Review information"
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
@@ -3575,6 +3558,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
@@ -3776,3 +3760,38 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr "without %{name}"
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Applicant data"
+msgstr "Applicant data"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy"
+msgstr "Copy"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Copy join page URL"
+msgstr "Copy join page URL"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL"
+msgstr "Join page URL"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Join page URL copied to clipboard."
+msgstr "Join page URL copied to clipboard."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Link to the public join page (share this with applicants):"
+msgstr "Link to the public join page (share this with applicants):"
+
+#: lib/mv_web/live/join_request_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Status and review"
+msgstr "Status and review"
diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs
index 7aafaac..9947704 100644
--- a/priv/repo/seeds_bootstrap.exs
+++ b/priv/repo/seeds_bootstrap.exs
@@ -263,6 +263,21 @@ default_hidden_in_overview = %{
"membership_fee_start_date" => false
}
+# Default join form field selection (email + name + address + join_date); join form stays disabled.
+default_join_form_field_ids = [
+ "email",
+ "first_name",
+ "last_name",
+ "street",
+ "house_number",
+ "postal_code",
+ "city",
+ "country",
+ "join_date"
+]
+
+default_join_form_field_required = %{"email" => true}
+
case Membership.get_settings() do
{:ok, existing_settings} ->
updates =
@@ -304,7 +319,9 @@ case Membership.get_settings() do
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: default_hidden_in_overview,
- default_membership_fee_type_id: default_fee_type.id
+ default_membership_fee_type_id: default_fee_type.id,
+ join_form_field_ids: default_join_form_field_ids,
+ join_form_field_required: default_join_form_field_required
})
|> Ash.create!()
end
diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs
index 436507f..5b3de9f 100644
--- a/priv/repo/seeds_dev.exs
+++ b/priv/repo/seeds_dev.exs
@@ -481,19 +481,28 @@ for {email, values} <- custom_value_assignments do
end
end
-# Join form: enable so membership application list is visible in dev
+# Join form: enable so membership application list is visible in dev; default field list includes address + join_date
+default_join_form_field_ids = [
+ "email",
+ "first_name",
+ "last_name",
+ "street",
+ "house_number",
+ "postal_code",
+ "city",
+ "country",
+ "join_date"
+]
+
+default_join_form_field_required = %{"email" => true}
+
case Membership.get_settings() do
{:ok, settings} ->
unless settings.join_form_enabled do
Membership.update_settings(settings, %{
join_form_enabled: true,
- join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"],
- join_form_field_required: settings.join_form_field_required || %{
- "email" => true,
- "first_name" => false,
- "last_name" => false,
- "city" => false
- }
+ join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
+ join_form_field_required: settings.join_form_field_required || default_join_form_field_required
})
end
_ ->
From 40a4461d2367b714b73c0ac7d21657f0d4fed490 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 09:34:56 +0100
Subject: [PATCH 06/26] fix: join confirmation mail configuration
---
CODE_GUIDELINES.md | 4 ++
docs/development-progress-log.md | 2 +-
docs/smtp-configuration-concept.md | 15 ++++--
lib/membership/membership.ex | 6 +--
lib/mv_web/emails/join_confirmation_email.ex | 22 ++++----
lib/mv_web/live/join_live.ex | 18 ++++++-
priv/gettext/de/LC_MESSAGES/default.po | 21 +++++---
priv/gettext/default.pot | 5 ++
priv/gettext/en/LC_MESSAGES/default.po | 5 ++
...join_request_submit_email_failure_test.exs | 33 ++++++++++++
.../live/join_live_email_failure_test.exs | 54 +++++++++++++++++++
test/support/failing_mail_adapter.ex | 10 ++++
12 files changed, 167 insertions(+), 28 deletions(-)
create mode 100644 test/membership/join_request_submit_email_failure_test.exs
create mode 100644 test/mv_web/live/join_live_email_failure_test.exs
create mode 100644 test/support/failing_mail_adapter.ex
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 0cb8d65..898fdd2 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -1290,6 +1290,10 @@ mix hex.outdated
- `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:**
+
+- `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
+
**Unified layout (transactional emails):**
- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).
diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md
index a6297ba..6d8e523 100644
--- a/docs/development-progress-log.md
+++ b/docs/development-progress-log.md
@@ -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):**
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index 30fd7de..c60a0e2 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -82,13 +82,19 @@ Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
---
-## 9. AshAuthentication Senders
+## 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`.
---
-## 10. TLS / SSL in OTP 27
+## 11. TLS / SSL in OTP 27
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
@@ -101,7 +107,7 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us
---
-## 11. Summary Checklist
+## 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.
@@ -112,13 +118,14 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us
- [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts).
- [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.
---
-## 12. Follow-up / Future Work
+## 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.
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 2f18f90..24bf27b 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -364,7 +364,8 @@ 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
+ - `{: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
@@ -390,8 +391,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 ->
diff --git a/lib/mv_web/emails/join_confirmation_email.ex b/lib/mv_web/emails/join_confirmation_email.ex
index 781a205..9bd3c5a 100644
--- a/lib/mv_web/emails/join_confirmation_email.ex
+++ b/lib/mv_web/emails/join_confirmation_email.ex
@@ -15,11 +15,11 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
@doc """
Sends the join confirmation email to the given address with the confirmation link.
+ 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).
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
confirm_url = url(~p"/confirm_join/#{token}")
@@ -32,12 +32,14 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
locale: Gettext.get_locale(MvWeb.Gettext)
}
- new()
- |> from(Mailer.mail_from())
- |> to(email_address)
- |> subject(subject)
- |> put_view(MvWeb.EmailsView)
- |> render_body("join_confirmation.html", assigns)
- |> Mailer.deliver()
+ email =
+ new()
+ |> from(Mailer.mail_from())
+ |> to(email_address)
+ |> subject(subject)
+ |> put_view(MvWeb.EmailsView)
+ |> render_body("join_confirmation.html", assigns)
+
+ Mailer.deliver(email, Mailer.smtp_config())
end
end
diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex
index 99a7df9..7489331 100644
--- a/lib/mv_web/live/join_live.ex
+++ b/lib/mv_web/live/join_live.ex
@@ -142,8 +142,22 @@ 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, _} ->
+ {:noreply, assign(socket, :submitted, true)}
+
+ {: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} ->
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 1b163d4..a0d73fb 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -1556,17 +1556,17 @@ msgstr "Hausnummer"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not create an account, you can ignore this email."
-msgstr "Wenn Sie kein Konto angelegt haben, können Sie diese E-Mail ignorieren."
+msgstr "Wenn du kein Konto angelegt hast, kannst du diese E-Mail ignorieren."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged."
-msgstr "Wenn Sie das nicht angefordert haben, können Sie diese E-Mail ignorieren. Ihr Passwort bleibt unverändert."
+msgstr "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren. Dein Passwort bleibt unverändert."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not submit this request, you can ignore this email."
-msgstr "Wenn Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren."
+msgstr "Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/import_live.ex
@@ -2542,7 +2542,7 @@ msgstr "Bitte bestätige zuerst die Betragsänderung"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Please confirm your email address by clicking the link below."
-msgstr "Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken."
+msgstr "Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Link klickst."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
@@ -3200,7 +3200,7 @@ msgstr "Textfeld"
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
-msgstr "Vielen Dank, wir haben Ihre Anfrage erhalten."
+msgstr "Vielen Dank, wir haben deine Anfrage erhalten."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
@@ -3273,7 +3273,7 @@ msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktio
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
-msgstr "Dieser Link ist abgelaufen. Bitte senden Sie das Formular erneut ab."
+msgstr "Dieser Link ist abgelaufen. Bitte sende das Formular erneut ab."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
@@ -3517,7 +3517,7 @@ msgstr "Keine Internetverbindung gefunden"
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your membership request. To complete it, please click the link below."
-msgstr "Wir haben Ihre Mitgliedschaftsanfrage erhalten. Bitte klicken Sie zur Bestätigung auf den folgenden Link."
+msgstr "Wir haben deine Mitgliedschaftsanfrage erhalten. Bitte klicke zur Bestätigung auf den folgenden Link."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
@@ -3635,7 +3635,7 @@ msgstr "Du hast dich bereits auf andere Weise angemeldet, aber dein Konto noch n
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "You requested a password reset. Click the link below to set a new password."
-msgstr "Sie haben die Zurücksetzung Ihres Passworts angefordert. Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen."
+msgstr "Du hast die Zurücksetzung deines Passworts angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
@@ -3795,3 +3795,8 @@ msgstr "Link zur öffentlichen Beitrittsseite (diesen Link mit Interessent*innen
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr "Status und Prüfung"
+
+#: lib/mv_web/live/join_live.ex
+#, elixir-autogen, elixir-format
+msgid "We could not send the confirmation email. Please try again later or contact support."
+msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 60e77c1..d20a604 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3795,3 +3795,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr ""
+
+#: lib/mv_web/live/join_live.ex
+#, elixir-autogen, elixir-format
+msgid "We could not send the confirmation email. Please try again later or contact support."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 4e4f87b..7a42e63 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3795,3 +3795,8 @@ msgstr "Link to the public join page (share this with applicants):"
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr "Status and review"
+
+#: lib/mv_web/live/join_live.ex
+#, elixir-autogen, elixir-format
+msgid "We could not send the confirmation email. Please try again later or contact support."
+msgstr ""
diff --git a/test/membership/join_request_submit_email_failure_test.exs b/test/membership/join_request_submit_email_failure_test.exs
new file mode 100644
index 0000000..2587628
--- /dev/null
+++ b/test/membership/join_request_submit_email_failure_test.exs
@@ -0,0 +1,33 @@
+defmodule Mv.Membership.JoinRequestSubmitEmailFailureTest do
+ @moduledoc """
+ Tests that when join confirmation email delivery fails, the domain returns
+ {:error, :email_delivery_failed} (and the LiveView shows an error). Uses
+ FailingMailAdapter to simulate delivery failure; async: false to avoid config races.
+ """
+ use Mv.DataCase, async: false
+
+ alias Mv.Membership
+
+ @valid_submit_attrs %{
+ email: "fail#{System.unique_integer([:positive])}@example.com"
+ }
+
+ test "submit_join_request returns {:error, :email_delivery_failed} when mail delivery fails" do
+ saved = Application.get_env(:mv, Mv.Mailer)
+
+ Application.put_env(
+ :mv,
+ Mv.Mailer,
+ Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter)
+ )
+
+ on_exit(fn ->
+ Application.put_env(:mv, Mv.Mailer, saved)
+ end)
+
+ token = "fail-token-#{System.unique_integer([:positive])}"
+ attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
+
+ assert {:error, :email_delivery_failed} = Membership.submit_join_request(attrs, actor: nil)
+ end
+end
diff --git a/test/mv_web/live/join_live_email_failure_test.exs b/test/mv_web/live/join_live_email_failure_test.exs
new file mode 100644
index 0000000..cc4e756
--- /dev/null
+++ b/test/mv_web/live/join_live_email_failure_test.exs
@@ -0,0 +1,54 @@
+defmodule MvWeb.JoinLiveEmailFailureTest do
+ @moduledoc """
+ When join confirmation email delivery fails, the user sees an error message
+ and no success copy. Uses FailingMailAdapter; async: false to avoid config races.
+ """
+ use MvWeb.ConnCase, async: false
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership
+
+ @tag role: :unauthenticated
+ test "when confirmation email fails, user sees error flash and no success message", %{
+ conn: conn
+ } do
+ enable_join_form_for_test()
+
+ saved = Application.get_env(:mv, Mv.Mailer)
+
+ Application.put_env(
+ :mv,
+ Mv.Mailer,
+ Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter)
+ )
+
+ on_exit(fn ->
+ Application.put_env(:mv, Mv.Mailer, saved)
+ end)
+
+ {:ok, view, _html} = live(conn, "/join")
+
+ view
+ |> form("#join-form", %{
+ "email" => "fail#{System.unique_integer([:positive])}@example.com",
+ "first_name" => "Jane",
+ "last_name" => "Doe",
+ "website" => ""
+ })
+ |> render_submit()
+
+ html = render(view)
+ assert html =~ "could not send" or html =~ "confirmation email"
+ refute view |> element("[data-testid='join-success-message']") |> has_element?()
+ end
+
+ defp enable_join_form_for_test do
+ {:ok, settings} = Membership.get_settings()
+
+ Membership.update_settings(settings, %{
+ join_form_enabled: true,
+ join_form_field_ids: ["email", "first_name", "last_name"],
+ join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
+ })
+ end
+end
diff --git a/test/support/failing_mail_adapter.ex b/test/support/failing_mail_adapter.ex
new file mode 100644
index 0000000..59bb4c0
--- /dev/null
+++ b/test/support/failing_mail_adapter.ex
@@ -0,0 +1,10 @@
+defmodule Mv.TestSupport.FailingMailAdapter do
+ @moduledoc """
+ Swoosh adapter that always returns delivery failure. Used in tests to assert
+ that join confirmation email failure is handled (error shown to user, no success UI).
+ """
+ use Swoosh.Adapter
+
+ @impl true
+ def deliver(_email, _config), do: {:error, :forced}
+end
From 086ecdcb1bc27dc5b8a5f1f0d729a57a7863826f Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 11:18:34 +0100
Subject: [PATCH 07/26] feat: prevent join requests with equal mail
---
docs/onboarding-join-concept.md | 2 +-
lib/membership/join_request.ex | 16 +++
.../join_request/changes/approve_request.ex | 2 +
.../join_request/changes/helpers.ex | 20 +++
.../changes/regenerate_confirmation_token.ex | 30 +++++
.../join_request/changes/reject_request.ex | 2 +
lib/membership/membership.ex | 126 +++++++++++++++++-
.../emails/join_already_member_email.ex | 42 ++++++
.../emails/join_already_pending_email.ex | 43 ++++++
lib/mv_web/emails/join_confirmation_email.ex | 13 +-
lib/mv_web/live/join_request_live/helpers.ex | 19 ++-
lib/mv_web/live/join_request_live/show.ex | 9 +-
.../emails/join_already_member.html.heex | 10 ++
.../emails/join_already_pending.html.heex | 10 ++
.../emails/join_confirmation.html.heex | 5 +
priv/gettext/de/LC_MESSAGES/default.po | 31 +++++
priv/gettext/default.pot | 31 +++++
priv/gettext/en/LC_MESSAGES/default.po | 31 +++++
...d_reviewed_by_display_to_join_requests.exs | 30 +++++
.../join_request_approval_domain_test.exs | 12 ++
.../join_request_approval_policy_test.exs | 2 +
test/membership/join_request_test.exs | 59 ++++++++
22 files changed, 534 insertions(+), 11 deletions(-)
create mode 100644 lib/membership/join_request/changes/regenerate_confirmation_token.ex
create mode 100644 lib/mv_web/emails/join_already_member_email.ex
create mode 100644 lib/mv_web/emails/join_already_pending_email.ex
create mode 100644 lib/mv_web/templates/emails/join_already_member.html.heex
create mode 100644 lib/mv_web/templates/emails/join_already_pending.html.heex
create mode 100644 priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs
diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md
index 487256e..8e6c615 100644
--- a/docs/onboarding-join-concept.md
+++ b/docs/onboarding-join-concept.md
@@ -196,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 plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
-- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** 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**.
diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex
index 05a9e8d..94907e2 100644
--- a/lib/membership/join_request.ex
+++ b/lib/membership/join_request.ex
@@ -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
diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex
index 24716f6..b86ca5d 100644
--- a/lib/membership/join_request/changes/approve_request.ex
+++ b/lib/membership/join_request/changes/approve_request.ex
@@ -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,
diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex
index ee09b75..9bb0697 100644
--- a/lib/membership/join_request/changes/helpers.ex
+++ b/lib/membership/join_request/changes/helpers.ex
@@ -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
diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex
new file mode 100644
index 0000000..a3206a2
--- /dev/null
+++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex
@@ -0,0 +1,30 @@
+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
+ hash = JoinRequest.hash_confirmation_token(token)
+ expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
+
+ changeset
+ |> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
+ |> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
+ |> Ash.Changeset.force_change_attribute(:confirmation_sent_at, DateTime.utc_now())
+ else
+ changeset
+ end
+ end
+end
diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex
index 2c33a77..1b9fe1a 100644
--- a/lib/membership/join_request/changes/reject_request.ex
+++ b/lib/membership/join_request/changes/reject_request.ex
@@ -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,
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 24bf27b..8812d99 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -29,7 +29,11 @@ defmodule Mv.Membership do
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
+ alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest
+ alias Mv.Membership.Member
+ alias MvWeb.Emails.JoinAlreadyMemberEmail
+ alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
require Logger
@@ -365,15 +369,130 @@ defmodule Mv.Membership do
## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
+ - `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
+ - `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
- `{:error, error}` - Validation or authorization error
"""
def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor)
- 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 apply_anti_enumeration_delay do
+ Process.sleep(100 + :rand.uniform(200))
+ end
+
+ defp send_already_member_and_return(email) do
+ case JoinAlreadyMemberEmail.send(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
+ end
+
+ apply_anti_enumeration_delay()
+ {: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 JoinConfirmationEmail.send(email, new_token, resend: true) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
+ end
+
+ apply_anti_enumeration_delay()
+ {: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 JoinAlreadyPendingEmail.send(email) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
+ end
+
+ apply_anti_enumeration_delay()
+ {: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,
@@ -384,6 +503,7 @@ defmodule Mv.Membership do
{:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do
{:ok, _email} ->
+ apply_anti_enumeration_delay()
{:ok, request}
{:error, reason} ->
diff --git a/lib/mv_web/emails/join_already_member_email.ex b/lib/mv_web/emails/join_already_member_email.ex
new file mode 100644
index 0000000..fa309d8
--- /dev/null
+++ b/lib/mv_web/emails/join_already_member_email.ex
@@ -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
diff --git a/lib/mv_web/emails/join_already_pending_email.ex b/lib/mv_web/emails/join_already_pending_email.ex
new file mode 100644
index 0000000..17dc487
--- /dev/null
+++ b/lib/mv_web/emails/join_already_pending_email.ex
@@ -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
diff --git a/lib/mv_web/emails/join_confirmation_email.ex b/lib/mv_web/emails/join_confirmation_email.ex
index 9bd3c5a..08f4ad3 100644
--- a/lib/mv_web/emails/join_confirmation_email.ex
+++ b/lib/mv_web/emails/join_confirmation_email.ex
@@ -18,10 +18,16 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
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).
+ 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.
"""
- 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,7 +35,8 @@ 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 =
diff --git a/lib/mv_web/live/join_request_live/helpers.ex b/lib/mv_web/live/join_request_live/helpers.ex
index 5ec5105..58d5ccf 100644
--- a/lib/mv_web/live/join_request_live/helpers.ex
+++ b/lib/mv_web/live/join_request_live/helpers.ex
@@ -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
diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex
index d326f4f..14e2760 100644
--- a/lib/mv_web/live/join_request_live/show.ex
+++ b/lib/mv_web/live/join_request_live/show.ex
@@ -264,11 +264,16 @@ defmodule MvWeb.JoinRequestLive.Show do
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_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
@@ -283,8 +288,10 @@ defmodule MvWeb.JoinRequestLive.Show do
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)
diff --git a/lib/mv_web/templates/emails/join_already_member.html.heex b/lib/mv_web/templates/emails/join_already_member.html.heex
new file mode 100644
index 0000000..0791b97
--- /dev/null
+++ b/lib/mv_web/templates/emails/join_already_member.html.heex
@@ -0,0 +1,10 @@
+
+
+ {gettext(
+ "We have received your request. The email address you entered is already registered as a member."
+ )}
+
+
+ {gettext("If you have any questions, please contact us.")}
+
+
diff --git a/lib/mv_web/templates/emails/join_already_pending.html.heex b/lib/mv_web/templates/emails/join_already_pending.html.heex
new file mode 100644
index 0000000..1f3b608
--- /dev/null
+++ b/lib/mv_web/templates/emails/join_already_pending.html.heex
@@ -0,0 +1,10 @@
+
+
+ {gettext(
+ "We have received your request. You already have a membership application that is being reviewed."
+ )}
+
+
+ {gettext("If you have any questions, please contact us.")}
+
+ {gettext("You already had a pending request. Here is a new confirmation link.")}
+
+ <% end %>
{gettext(
"We have received your membership request. To complete it, please click the link below."
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index a0d73fb..4c824f0 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3800,3 +3800,34 @@ msgstr "Status und Prüfung"
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support."
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "If you have any questions, please contact us."
+msgstr "Bei Fragen kannst du dich gerne an uns wenden."
+
+#: lib/mv_web/emails/join_already_member_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already a member"
+msgstr "Mitgliedsantrag – bereits Mitglied"
+
+#: lib/mv_web/emails/join_already_pending_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already under review"
+msgstr "Mitgliedsantrag – wird bereits geprüft"
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. The email address you entered is already registered as a member."
+msgstr "Wir haben deine Anfrage erhalten. Die angegebene E-Mail-Adresse ist bereits als Mitglied registriert."
+
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. You already have a membership application that is being reviewed."
+msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag, der geprüft wird."
+
+#: lib/mv_web/templates/emails/join_confirmation.html.heex
+#, elixir-autogen, elixir-format
+msgid "You already had a pending request. Here is a new confirmation link."
+msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index d20a604..8796553 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3800,3 +3800,34 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr ""
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "If you have any questions, please contact us."
+msgstr ""
+
+#: lib/mv_web/emails/join_already_member_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already a member"
+msgstr ""
+
+#: lib/mv_web/emails/join_already_pending_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already under review"
+msgstr ""
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. The email address you entered is already registered as a member."
+msgstr ""
+
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. You already have a membership application that is being reviewed."
+msgstr ""
+
+#: lib/mv_web/templates/emails/join_confirmation.html.heex
+#, elixir-autogen, elixir-format
+msgid "You already had a pending request. Here is a new confirmation link."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 7a42e63..22c6363 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3800,3 +3800,34 @@ msgstr "Status and review"
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr ""
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "If you have any questions, please contact us."
+msgstr "If you have any questions, please contact us."
+
+#: lib/mv_web/emails/join_already_member_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already a member"
+msgstr "Membership application – already a member"
+
+#: lib/mv_web/emails/join_already_pending_email.ex
+#, elixir-autogen, elixir-format
+msgid "Membership application – already under review"
+msgstr "Membership application – already under review"
+
+#: lib/mv_web/templates/emails/join_already_member.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. The email address you entered is already registered as a member."
+msgstr "We have received your request. The email address you entered is already registered as a member."
+
+#: lib/mv_web/templates/emails/join_already_pending.html.heex
+#, elixir-autogen, elixir-format
+msgid "We have received your request. You already have a membership application that is being reviewed."
+msgstr "We have received your request. You already have a membership application that is being reviewed."
+
+#: lib/mv_web/templates/emails/join_confirmation.html.heex
+#, elixir-autogen, elixir-format
+msgid "You already had a pending request. Here is a new confirmation link."
+msgstr "You already had a pending request. Here is a new confirmation link."
diff --git a/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs b/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs
new file mode 100644
index 0000000..850953e
--- /dev/null
+++ b/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs
@@ -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
diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs
index 1f9b3c2..15f5636 100644
--- a/test/membership/join_request_approval_domain_test.exs
+++ b/test/membership/join_request_approval_domain_test.exs
@@ -67,6 +67,18 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
end
end
+ describe "reviewed_by_display" do
+ test "get_join_request returns reviewed_by_display so UI can show reviewer without loading User" do
+ request = Fixtures.submitted_join_request_fixture()
+ reviewer = Fixtures.user_with_role_fixture("normal_user")
+
+ assert {:ok, _} = Membership.approve_join_request(request.id, actor: reviewer)
+
+ assert {:ok, loaded} = Membership.get_join_request(request.id, actor: reviewer)
+ assert loaded.reviewed_by_display == to_string(reviewer.email)
+ end
+ end
+
describe "reject_join_request/2" do
test "reject does not create a member" do
request = Fixtures.submitted_join_request_fixture()
diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs
index 6c09526..fee355c 100644
--- a/test/membership/join_request_approval_policy_test.exs
+++ b/test/membership/join_request_approval_policy_test.exs
@@ -49,6 +49,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert approved.status == :approved
assert approved.approved_at != nil
assert approved.reviewed_by_user_id == user.id
+ assert approved.reviewed_by_display == to_string(user.email)
end
test "admin can approve a submitted join request", %{request: request} do
@@ -89,6 +90,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert rejected.status == :rejected
assert rejected.rejected_at != nil
assert rejected.reviewed_by_user_id == user.id
+ assert rejected.reviewed_by_display == to_string(user.email)
end
test "admin can reject a submitted join request", %{request: request} do
diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs
index 1992993..5f0ae83 100644
--- a/test/membership/join_request_test.exs
+++ b/test/membership/join_request_test.exs
@@ -12,7 +12,12 @@ defmodule Mv.Membership.JoinRequestTest do
"""
use Mv.DataCase, async: true
+ require Ash.Query
+ import Ash.Expr
+
+ alias Mv.Fixtures
alias Mv.Membership
+ alias Mv.Membership.JoinRequest
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
@valid_submit_attrs %{
@@ -136,6 +141,60 @@ defmodule Mv.Membership.JoinRequestTest do
end
end
+ describe "submit_join_request/2 anti-enumeration (already member / already pending)" do
+ test "returns {:ok, :notified_already_member} and creates no JoinRequest when email is already a member" do
+ member =
+ Fixtures.member_fixture(%{
+ email: "already_member#{System.unique_integer([:positive])}@example.com"
+ })
+
+ attrs = %{
+ email: member.email,
+ confirmation_token: "token-#{System.unique_integer([:positive])}"
+ }
+
+ assert {:ok, :notified_already_member} = Membership.submit_join_request(attrs, actor: nil)
+
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, requests} =
+ JoinRequest
+ |> Ash.Query.filter(expr(email == ^member.email))
+ |> Ash.read(actor: system_actor, domain: Mv.Membership)
+
+ assert requests == []
+ end
+
+ test "returns {:ok, :notified_already_pending} and does not create duplicate when same email submits again (resend)" do
+ email = "resend#{System.unique_integer([:positive])}@example.com"
+ token1 = "first-token-#{System.unique_integer([:positive])}"
+ attrs1 = %{email: email, confirmation_token: token1}
+
+ assert {:ok, request1} = Membership.submit_join_request(attrs1, actor: nil)
+ assert request1.status == :pending_confirmation
+
+ attrs2 = %{
+ email: email,
+ confirmation_token: "second-token-#{System.unique_integer([:positive])}"
+ }
+
+ assert {:ok, :notified_already_pending} = Membership.submit_join_request(attrs2, actor: nil)
+
+ system_actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, requests} =
+ JoinRequest
+ |> Ash.Query.filter(expr(email == ^email))
+ |> Ash.read(actor: system_actor, domain: Mv.Membership)
+
+ assert length(requests) == 1
+ assert hd(requests).id == request1.id
+
+ # Resend path updates the request (new token stored); confirmation_sent_at will have been set/updated
+ assert hd(requests).confirmation_sent_at != nil
+ end
+ end
+
describe "allowlist (server-side field filter)" do
test "submit with non-allowlisted form_data keys does not persist those keys" do
# Allowlist restricts which fields are accepted; extra keys must not be stored.
From 99a8d643449a93edbee81792356e5e5e8c81506a Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 14:11:54 +0100
Subject: [PATCH 08/26] fix: translation of login page
---
DESIGN_GUIDELINES.md | 15 ++
Justfile | 1 +
docs/feature-roadmap.md | 6 +-
lib/mv_web/auth_overrides.ex | 59 ++++---
lib/mv_web/components/layouts.ex | 63 ++++++-
lib/mv_web/components/layouts/sidebar.ex | 4 +-
.../controllers/join_confirm_controller.ex | 33 +++-
lib/mv_web/controllers/join_confirm_html.ex | 9 +
.../join_confirm_html/confirm.html.heex | 65 ++++++++
lib/mv_web/live/auth/sign_in_live.ex | 118 ++++++-------
lib/mv_web/live/join_live.ex | 156 +++++++++---------
priv/gettext/auth.pot | 6 +-
priv/gettext/de/LC_MESSAGES/auth.po | 8 +-
priv/gettext/de/LC_MESSAGES/default.po | 42 ++++-
priv/gettext/default.pot | 42 ++++-
priv/gettext/en/LC_MESSAGES/auth.po | 8 +-
priv/gettext/en/LC_MESSAGES/default.po | 42 ++++-
.../controllers/auth_controller_test.exs | 10 ++
18 files changed, 487 insertions(+), 200 deletions(-)
create mode 100644 lib/mv_web/controllers/join_confirm_html.ex
create mode 100644 lib/mv_web/controllers/join_confirm_html/confirm.html.heex
diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md
index 92f7a90..6e8ca40 100644
--- a/DESIGN_GUIDELINES.md
+++ b/DESIGN_GUIDELINES.md
@@ -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 (right)
+ - Main content slot, Flash group. No sidebar, no authenticated-layout logic.
+- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
+- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
+- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`.
+- **Implementation:**
+ - **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `` with the SignIn component inside a hero. Displays a locale-aware `
` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the library’s Banner is hidden via `show_banner: false`).
+ - **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `` with a hero for the form.
+ - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that repeats the same header markup and a hero block for the result (no component call from controller templates).
+
## 3) Typography (system)
Use these standard roles:
diff --git a/Justfile b/Justfile
index f3ad5a3..d2c51e5 100644
--- a/Justfile
+++ b/Justfile
@@ -10,6 +10,7 @@ install-dependencies:
mix deps.get
migrate-database:
+ mix compile
mix ash.setup
reset-database:
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 03f1cce..6383660 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -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)
diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex
index 5cab4d2..44b3408 100644
--- a/lib/mv_web/auth_overrides.ex
+++ b/lib/mv_web/auth_overrides.ex
@@ -3,52 +3,57 @@ 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.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
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 2979eb4..22408c7 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -13,6 +13,54 @@ defmodule MvWeb.Layouts do
embed_templates "layouts/*"
+ @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.
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ slot :inner_block, required: true
+
+ def public_page(assigns) do
+ club_name =
+ case Mv.Membership.get_settings() do
+ {:ok, s} -> s.club_name || "Mitgliederverwaltung"
+ _ -> "Mitgliederverwaltung"
+ end
+
+ assigns = assign(assigns, :club_name, club_name)
+
+ ~H"""
+
+
+
+ Mitgliederverwaltung
+
+
+ {@club_name}
+
+
+
+
+
+ {render_slot(@inner_block)}
+
+
+ <.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.
@@ -99,10 +147,13 @@ defmodule MvWeb.Layouts do
<% else %>
-
-
-
-
+
+
+
+
+ Mitgliederverwaltung
+
+
{@club_name}
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index 49d9cae..4a90543 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -260,8 +260,8 @@ 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")}
>
-
-
+
+
diff --git a/lib/mv_web/controllers/join_confirm_controller.ex b/lib/mv_web/controllers/join_confirm_controller.ex
index a1247f3..38a3263 100644
--- a/lib/mv_web/controllers/join_confirm_controller.ex
+++ b/lib/mv_web/controllers/join_confirm_controller.ex
@@ -2,8 +2,9 @@ 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
@@ -26,20 +27,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
+ club_name =
+ case Mv.Membership.get_settings() do
+ {:ok, settings} -> settings.club_name || "Mitgliederverwaltung"
+ _ -> "Mitgliederverwaltung"
+ end
+
+ conn
+ |> assign(:result, result)
+ |> assign(:club_name, club_name)
+ |> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
end
end
diff --git a/lib/mv_web/controllers/join_confirm_html.ex b/lib/mv_web/controllers/join_confirm_html.ex
new file mode 100644
index 0000000..052f197
--- /dev/null
+++ b/lib/mv_web/controllers/join_confirm_html.ex
@@ -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
diff --git a/lib/mv_web/controllers/join_confirm_html/confirm.html.heex b/lib/mv_web/controllers/join_confirm_html/confirm.html.heex
new file mode 100644
index 0000000..8789607
--- /dev/null
+++ b/lib/mv_web/controllers/join_confirm_html/confirm.html.heex
@@ -0,0 +1,65 @@
+<%!-- Public header (same structure as Layouts.app unauthenticated branch) --%>
+
+
+
+ {@club_name}
+
+
+
+
+
+
+
+
+
+ <%= case @result do %>
+ <% :success -> %>
+
+ {gettext("Thank you")}
+
+
+ {gettext("Thank you, we have received your request.")}
+
+
+ {gettext("You will receive an email once your application has been reviewed.")}
+
+
diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex
index 7ef330b..96bf62b 100644
--- a/lib/mv_web/live/auth/sign_in_live.ex
+++ b/lib/mv_web/live/auth/sign_in_live.ex
@@ -1,28 +1,42 @@
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 MvWeb.{AuthOverridesDE, 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: []
+ overrides = locale_overrides ++ base_overrides
socket =
socket
@@ -36,10 +50,9 @@ defmodule MvWeb.SignInLive do
|> 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)
@@ -54,50 +67,43 @@ defmodule MvWeb.SignInLive do
@impl true
def render(assigns) do
~H"""
-
-
+ <% end %>
- <%= for field <- @join_fields do %>
-
-
-
-
- <% end %>
+ <%= for field <- @join_fields do %>
+
+
+
+
+ <% end %>
- <%!--
+ <%!--
Honeypot (best practice): legit field name "website", type="text", no inline CSS,
hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off,
aria-hidden so screen readers skip. If filled → silent failure (same success UI).
--%>
-
-
-
+
+
+
+
+
+
+ {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."
+ )}
+
+
+
+ {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."
+ )}
+
+
+
+
+
+
+ <% end %>
-
-
- {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."
- )}
-
-
-
- {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."
- )}
-
-
-
-
-
-
- <% end %>
+
+
-
+
"""
end
diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot
index a81a82b..cd46c56 100644
--- a/priv/gettext/auth.pot
+++ b/priv/gettext/auth.pot
@@ -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 ""
diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po
index 2aa5e6a..07583be 100644
--- a/priv/gettext/de/LC_MESSAGES/auth.po
+++ b/priv/gettext/de/LC_MESSAGES/auth.po
@@ -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"
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 4c824f0..a96e6c9 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -1688,7 +1688,7 @@ msgstr "Ungültiges Datumsformat"
msgid "Invalid email address. Please enter a valid recipient address."
msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein."
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr "Ungültiger oder abgelaufener Link."
@@ -2897,6 +2897,7 @@ msgstr "Intervall auswählen"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
@@ -3197,7 +3198,7 @@ msgstr "Wird getestet..."
msgid "Text"
msgstr "Textfeld"
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr "Vielen Dank, wir haben deine Anfrage erhalten."
@@ -3270,7 +3271,7 @@ msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt."
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr "Dieser Link ist abgelaufen. Bitte sende das Formular erneut ab."
@@ -3831,3 +3832,38 @@ msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag,
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink."
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Back to join form"
+msgstr "Zurück zu den Mitgliedsanträgen"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Go to join form"
+msgstr "Zum Antragsformular"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Invalid or expired link"
+msgstr "Ungültiger oder abgelaufener Link."
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Link expired"
+msgstr "Link abgelaufen"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Submit new request"
+msgstr "Antrag absenden"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Thank you"
+msgstr "Vielen Dank"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "You will receive an email once your application has been reviewed."
+msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 8796553..6945957 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -1689,7 +1689,7 @@ msgstr ""
msgid "Invalid email address. Please enter a valid recipient address."
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr ""
@@ -2898,6 +2898,7 @@ msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
@@ -3198,7 +3199,7 @@ msgstr ""
msgid "Text"
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr ""
@@ -3271,7 +3272,7 @@ msgstr ""
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr ""
@@ -3831,3 +3832,38 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Back to join form"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Go to join form"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Invalid or expired link"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Link expired"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Submit new request"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Thank you"
+msgstr ""
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "You will receive an email once your application has been reviewed."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po
index 764ea1d..564e640 100644
--- a/priv/gettext/en/LC_MESSAGES/auth.po
+++ b/priv/gettext/en/LC_MESSAGES/auth.po
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 22c6363..827290b 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -1689,7 +1689,7 @@ msgstr ""
msgid "Invalid email address. Please enter a valid recipient address."
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr "Invalid or expired link."
@@ -2898,6 +2898,7 @@ msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
msgstr ""
@@ -3198,7 +3199,7 @@ msgstr ""
msgid "Text"
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr "Thank you, we have received your request."
@@ -3271,7 +3272,7 @@ msgstr ""
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr ""
-#: lib/mv_web/controllers/join_confirm_controller.ex
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr "This link has expired. Please submit the form again."
@@ -3831,3 +3832,38 @@ msgstr "We have received your request. You already have a membership application
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "You already had a pending request. Here is a new confirmation link."
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Back to join form"
+msgstr "Back to membership applications"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Go to join form"
+msgstr "Go to join form"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Invalid or expired link"
+msgstr "Invalid or expired link."
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Link expired"
+msgstr "Link expired"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Submit new request"
+msgstr "Submit new request"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "Thank you"
+msgstr "Thank you"
+
+#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+#, elixir-autogen, elixir-format
+msgid "You will receive an email once your application has been reviewed."
+msgstr "You will receive an email once your application has been reviewed."
diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs
index 0841e68..328a9f4 100644
--- a/test/mv_web/controllers/auth_controller_test.exs
+++ b/test/mv_web/controllers/auth_controller_test.exs
@@ -28,6 +28,16 @@ defmodule MvWeb.AuthControllerTest do
assert html_response(conn, 200) =~ "Sign in"
end
+ @tag role: :unauthenticated
+ test "GET /sign-in returns 200 and renders page (exercises AuthOverrides and layout)", %{
+ conn: conn
+ } do
+ {:ok, _view, html} = live(conn, ~p"/sign-in")
+ assert html =~ "Sign in"
+ # Public header (logo) from Layouts.app unauthenticated branch
+ assert html =~ "mila.svg" or html =~ "Mila Logo"
+ end
+
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out")
From 104faf70067bf4888c31c00b738dffe401b846b0 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 14:48:10 +0100
Subject: [PATCH 09/26] feat: add theme selector to unauthenticated pages
---
DESIGN_GUIDELINES.md | 12 +++---
assets/css/app.css | 8 ++++
lib/mv_web/components/core_components.ex | 35 +++++++++++++++
lib/mv_web/components/layouts.ex | 54 +++++++++++++-----------
lib/mv_web/components/layouts/sidebar.ex | 54 +++++++-----------------
lib/mv_web/live/join_live.ex | 4 +-
6 files changed, 98 insertions(+), 69 deletions(-)
diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md
index 6e8ca40..187864c 100644
--- a/DESIGN_GUIDELINES.md
+++ b/DESIGN_GUIDELINES.md
@@ -81,7 +81,7 @@ If the `<.header>` is outside the `<.form>`, the submit button must reference th
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 (right)
+ - **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.
@@ -98,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 de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `` as usual; no extra classes needed.
+
---
## 4) States: Loading, Empty, Error (mandatory consistency)
diff --git a/assets/css/app.css b/assets/css/app.css
index e3c6e83..e79b4b6 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -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
+ de‑emphasised vs body text but meet the minimum ratio. */
+[data-theme="light"] .label,
+[data-theme="dark"] .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
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 11a60ef..8c58c32 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -1295,6 +1295,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"""
+
+
+
+ """
+ end
+
@doc """
Renders a [Heroicon](https://heroicons.com).
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 22408c7..5258ab9 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -39,18 +39,21 @@ defmodule MvWeb.Layouts do
{@club_name}
-
+
+
+ <.theme_swap />
+
@@ -156,18 +159,21 @@ defmodule MvWeb.Layouts do
{@club_name}
-
+
+
+ <.theme_swap />
+
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index 4a90543..2a4ea98 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -251,21 +251,22 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_footer(assigns) do
~H"""
-
-
-
- <.theme_toggle />
+
+
+ <.theme_swap />
+
+
<%= 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"""
-
- """
- end
-
attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do
diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex
index 4716cf8..e83031c 100644
--- a/lib/mv_web/live/join_live.ex
+++ b/lib/mv_web/live/join_live.ex
@@ -100,13 +100,13 @@ defmodule MvWeb.JoinLive do
/>
-
+
{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."
)}
-
+
{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."
)}
From eb182096694797af8979207970fc246d55ffb366 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 15:56:02 +0100
Subject: [PATCH 10/26] feat: rearrange smtp settings
---
DESIGN_GUIDELINES.md | 5 +
docs/smtp-configuration-concept.md | 2 +
lib/mv_web/live/global_settings_live.ex | 1203 ++++++++++++-----------
priv/gettext/de/LC_MESSAGES/default.po | 2 +-
priv/gettext/default.pot | 2 +-
priv/gettext/en/LC_MESSAGES/default.po | 2 +-
6 files changed, 636 insertions(+), 580 deletions(-)
diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md
index 187864c..9a01f9d 100644
--- a/DESIGN_GUIDELINES.md
+++ b/DESIGN_GUIDELINES.md
@@ -221,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)
diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index c60a0e2..8832b5e 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -44,6 +44,8 @@ When an ENV variable is set, the corresponding Settings field is read-only in th
**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
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 84cf738..fadbc32 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -115,600 +115,649 @@ defmodule MvWeb.GlobalSettingsLive do
- <%!-- Club Settings Section --%>
- <.form_section title={gettext("Club Settings")}>
- <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
-
{gettext(
- "The sender email must be owned by or authorized for the SMTP user on most servers."
+ "Configure the public join form that allows new members to submit a join request."
)}
- <.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")}
-
-
- <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
-
+ {gettext(
+ "The sender email must be owned by or authorized for the SMTP user on most servers."
+ )}
+
+ <.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")}
+
+
+
+ <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
+
+ {gettext(
+ "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
+ )}
+
+
+
+ <.button
+ :if={
+ not (@oidc_client_id_env_set and @oidc_base_url_env_set and
+ @oidc_redirect_uri_env_set and @oidc_client_secret_env_set and
+ @oidc_admin_group_name_env_set and @oidc_groups_claim_env_set and
+ @oidc_only_env_set)
+ }
+ phx-disable-with={gettext("Saving...")}
+ variant="primary"
+ class="mt-2"
+ >
+ {gettext("Save OIDC Settings")}
+
+
+
+
"""
end
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index a96e6c9..c23799a 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3306,7 +3306,7 @@ msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
-#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 6945957..ff61365 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3307,7 +3307,7 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
-#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 827290b..82aed54 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3307,7 +3307,7 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
-#: lib/mv_web/components/layouts/sidebar.ex
+#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
From 09e4b64663c3cb027e3ea087d073ea0058c012db Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 16:40:39 +0100
Subject: [PATCH 11/26] feat: allow disabling registration
---
docs/settings-authentication-mockup.txt | 44 +++++++++++++++
lib/accounts/user.ex | 4 ++
.../user/validations/registration_enabled.ex | 27 +++++++++
lib/membership/setting.ex | 12 ++++
lib/mv_web/auth_overrides.ex | 13 +++++
lib/mv_web/live/auth/sign_in_live.ex | 17 +++++-
lib/mv_web/live/global_settings_live.ex | 47 +++++++++++++++-
lib/mv_web/plugs/registration_enabled.ex | 55 +++++++++++++++++++
lib/mv_web/router.ex | 1 +
priv/gettext/de/LC_MESSAGES/default.po | 30 ++++++++++
priv/gettext/default.pot | 30 ++++++++++
priv/gettext/en/LC_MESSAGES/default.po | 30 ++++++++++
...0_add_registration_enabled_to_settings.exs | 20 +++++++
.../controllers/auth_controller_test.exs | 19 +++++++
14 files changed, 344 insertions(+), 5 deletions(-)
create mode 100644 docs/settings-authentication-mockup.txt
create mode 100644 lib/accounts/user/validations/registration_enabled.ex
create mode 100644 lib/mv_web/plugs/registration_enabled.ex
create mode 100644 priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs
diff --git a/docs/settings-authentication-mockup.txt b/docs/settings-authentication-mockup.txt
new file mode 100644
index 0000000..00f64c4
--- /dev/null
+++ b/docs/settings-authentication-mockup.txt
@@ -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] |
++------------------------------------------------------------------+
diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex
index 6b9cd1e..29a2d4b 100644
--- a/lib/accounts/user.ex
+++ b/lib/accounts/user.ex
@@ -405,6 +405,10 @@ 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)]
+
# 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
diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex
new file mode 100644
index 0000000..71cc7b1
--- /dev/null
+++ b/lib/accounts/user/validations/registration_enabled.ex
@@ -0,0 +1,27 @@
+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:
+ "Registration is disabled. Please use the join form or contact an administrator."}
+ end
+ end
+end
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index ce63589..83c5c8b 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -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
@@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do
:smtp_ssl,
:smtp_from_name,
:smtp_from_email,
+ :registration_enabled,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do
:smtp_ssl,
:smtp_from_name,
:smtp_from_email,
+ :registration_enabled,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required
@@ -514,6 +517,15 @@ defmodule Mv.Membership.Setting do
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
diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex
index 44b3408..3aab0ed 100644
--- a/lib/mv_web/auth_overrides.ex
+++ b/lib/mv_web/auth_overrides.ex
@@ -35,6 +35,19 @@ defmodule MvWeb.AuthOverrides do
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.
diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex
index 96bf62b..45cf44a 100644
--- a/lib/mv_web/live/auth/sign_in_live.ex
+++ b/lib/mv_web/live/auth/sign_in_live.ex
@@ -19,7 +19,7 @@ defmodule MvWeb.SignInLive do
alias AshAuthentication.Phoenix.Components
alias Mv.Config
- alias MvWeb.{AuthOverridesDE, Layouts}
+ alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
@impl true
def mount(_params, session, socket) do
@@ -36,7 +36,18 @@ defmodule MvWeb.SignInLive do
# 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: []
- overrides = locale_overrides ++ base_overrides
+
+ 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"]
socket =
socket
@@ -44,7 +55,7 @@ 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"] || %{})
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index fadbc32..158b7fa 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -11,12 +11,14 @@ 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_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
@@ -80,6 +82,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> 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?())
@@ -607,8 +610,29 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %>
- <%!-- OIDC Section --%>
- <.form_section title={gettext("OIDC (Single Sign-On)")}>
+ <%!-- Authentication: Direct registration + OIDC --%>
+ <.form_section title={gettext("Authentication")}>
+
{gettext("Direct registration")}
+
+ {gettext(
+ "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+ )}
+
+
+
+
+
+
+
{gettext("OIDC (Single Sign-On)")}
<%= if @oidc_env_configured do %>
{gettext("Some values are set via environment variables. Those fields are read-only.")}
@@ -853,6 +877,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket =
socket
|> assign(:settings, fresh_settings)
+ |> assign(:registration_enabled, fresh_settings.registration_enabled != false)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
@@ -889,6 +914,24 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, persist_join_form_settings(socket)}
end
+ @impl true
+ def handle_event("toggle_registration_enabled", _params, socket) do
+ 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
+
@impl true
def handle_event("toggle_add_field_dropdown", _params, socket) do
{:noreply,
diff --git a/lib/mv_web/plugs/registration_enabled.ex b/lib/mv_web/plugs/registration_enabled.ex
new file mode 100644
index 0000000..a8405bb
--- /dev/null
+++ b/lib/mv_web/plugs/registration_enabled.ex
@@ -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
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 945e22c..c7df3fd 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -16,6 +16,7 @@ defmodule MvWeb.Router do
plug :set_locale
plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
+ plug MvWeb.Plugs.RegistrationEnabled
end
pipeline :api do
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index c23799a..9396bab 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3867,3 +3867,33 @@ msgstr "Vielen Dank"
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Allow direct registration (/register)"
+msgstr "Direkte Registrierung erlauben (/register)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Authentication"
+msgstr "Anmeldung"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Direct registration"
+msgstr "Direkte Registrierung"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update setting."
+msgstr "Einstellung konnte nicht gespeichert werden."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
+
+#: lib/mv_web/plugs/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled."
+msgstr "Die Registrierung ist deaktiviert."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index ff61365..1d01d9e 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3867,3 +3867,33 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Allow direct registration (/register)"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Authentication"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Direct registration"
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update setting."
+msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+msgstr ""
+
+#: lib/mv_web/plugs/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 82aed54..1ed8cee 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3867,3 +3867,33 @@ msgstr "Thank you"
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr "You will receive an email once your application has been reviewed."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Allow direct registration (/register)"
+msgstr "Allow direct registration (/register)"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Authentication"
+msgstr "Authentication"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Direct registration"
+msgstr "Direct registration"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update setting."
+msgstr "Failed to update setting."
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+
+#: lib/mv_web/plugs/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled."
+msgstr "Registration is disabled."
diff --git a/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs b/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs
new file mode 100644
index 0000000..facd3e2
--- /dev/null
+++ b/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs
@@ -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
diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs
index 328a9f4..e449284 100644
--- a/test/mv_web/controllers/auth_controller_test.exs
+++ b/test/mv_web/controllers/auth_controller_test.exs
@@ -4,6 +4,8 @@ defmodule MvWeb.AuthControllerTest do
import Phoenix.ConnTest
import ExUnit.CaptureLog
+ alias Mv.Membership
+
# Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access
@@ -169,6 +171,23 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "length must be greater than or equal to 8"
end
+ test "when registration is disabled, sign-in page does not show Need an account? toggle", %{
+ conn: authenticated_conn
+ } do
+ {:ok, settings} = Membership.get_settings()
+ original = Map.get(settings, :registration_enabled, true)
+ {:ok, _} = Membership.update_settings(settings, %{registration_enabled: false})
+
+ try do
+ conn = build_unauthenticated_conn(authenticated_conn)
+ {:ok, _view, html} = live(conn, ~p"/sign-in")
+ refute html =~ "Need an account?"
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{registration_enabled: original})
+ end
+ end
+
# Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn
From 5e39fffce25742ebb5917e4dedee6d080b610d3a Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 16:47:16 +0100
Subject: [PATCH 12/26] i18n: update gettext
---
priv/gettext/de/LC_MESSAGES/default.po | 5 -----
priv/gettext/default.pot | 5 -----
priv/gettext/en/LC_MESSAGES/default.po | 5 -----
3 files changed, 15 deletions(-)
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 9396bab..d5d3c33 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3892,8 +3892,3 @@ msgstr "Einstellung konnte nicht gespeichert werden."
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
-
-#: lib/mv_web/plugs/registration_enabled.ex
-#, elixir-autogen, elixir-format
-msgid "Registration is disabled."
-msgstr "Die Registrierung ist deaktiviert."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 1d01d9e..53acf03 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3892,8 +3892,3 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr ""
-
-#: lib/mv_web/plugs/registration_enabled.ex
-#, elixir-autogen, elixir-format
-msgid "Registration is disabled."
-msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 1ed8cee..eed38d4 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3892,8 +3892,3 @@ msgstr "Failed to update setting."
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
-
-#: lib/mv_web/plugs/registration_enabled.ex
-#, elixir-autogen, elixir-format
-msgid "Registration is disabled."
-msgstr "Registration is disabled."
From d54393d80b30a3f0f64557ee78a6d3cad13b7f80 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 16:54:03 +0100
Subject: [PATCH 13/26] docs: update changelog
---
CHANGELOG.md | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c23c01..08284ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.0] - 2026-03-13
+
+### Added
+- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
+- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
+- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
+- **Duplicate-email handling for join form** – If an applicant’s email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration).
+- **Reviewed-by display for join requests** – Approval UI shows who reviewed a request via a dedicated display field, without loading the User record.
+- **Improved field order and seeds for join request approval** – Approval screen field order improved; seed data updated for join-form and approval flows.
+- **Tests for SMTP mailer configuration** – Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView).
+
+### Changed
+- **SMTP settings layout** – SMTP options reordered and grouped in global settings for clearer configuration.
+- **Join confirmation mail** – Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message.
+- **i18n** – Gettext catalogs updated for new and changed strings.
+
+### Fixed
+- **Login page translation** – Corrected translation/locale handling on the sign-in page.
+
+---
+
+## [1.0.0] and earlier
+
### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
From f12da8a3590bb4afa62fd023251c8077b46c14fd Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 17:07:25 +0100
Subject: [PATCH 14/26] test: fix tests
---
test/mv_web/member_live/index_groups_filter_test.exs | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs
index 782ab33..d32b17f 100644
--- a/test/mv_web/member_live/index_groups_filter_test.exs
+++ b/test/mv_web/member_live/index_groups_filter_test.exs
@@ -70,7 +70,9 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view)
- assert_patch(view)
+ # Wait for patch; return path so callers can assert URL contains expected filter param
+ path = assert_patch(view)
+ {view, path}
end
test "filter All (default) shows all members", %{
@@ -96,7 +98,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- open_filter_and_set_group(view, group1.id, "in")
+ {view, path} = open_filter_and_set_group(view, group1.id, "in")
+ assert path =~ "group_#{group1.id}=in", "expected URL to contain group filter param"
html = render(view)
assert html =~ m1.first_name
@@ -114,7 +117,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- open_filter_and_set_group(view, group1.id, "not_in")
+ {view, _path} = open_filter_and_set_group(view, group1.id, "not_in")
html = render(view)
refute html =~ m1.first_name
@@ -132,7 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- open_filter_and_set_group(view, group1.id, "in")
+ {view, _path} = open_filter_and_set_group(view, group1.id, "in")
html = render(view)
assert html =~ m1.first_name
From 349cee0ce634891927f19c910db84c553d600ebe Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 17:55:17 +0100
Subject: [PATCH 15/26] refactor: review remarks
---
CODE_GUIDELINES.md | 6 +-
DESIGN_GUIDELINES.md | 2 +-
assets/css/app.css | 6 +-
config/config.exs | 3 +
.../user/validations/registration_enabled.ex | 6 +-
lib/membership/join_notifier.ex | 13 +++
.../changes/regenerate_confirmation_token.ex | 11 +-
lib/membership/membership.ex | 104 ++++++++++++------
lib/membership/settings_cache.ex | 85 ++++++++++++++
lib/mv/application.ex | 37 ++++---
lib/mv_web/components/layouts.ex | 16 ++-
.../controllers/join_confirm_controller.ex | 9 +-
.../join_confirm_html/confirm.html.heex | 24 +---
lib/mv_web/join_notifier_impl.ex | 25 +++++
lib/mv_web/live/join_live.ex | 29 ++++-
priv/gettext/de/LC_MESSAGES/default.po | 6 +-
priv/gettext/default.pot | 6 +-
priv/gettext/en/LC_MESSAGES/default.po | 6 +-
test/mv_web/live/join_live_test.exs | 6 +-
19 files changed, 300 insertions(+), 100 deletions(-)
create mode 100644 lib/membership/join_notifier.ex
create mode 100644 lib/membership/settings_cache.ex
create mode 100644 lib/mv_web/join_notifier_impl.ex
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 898fdd2..8d53484 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -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
@@ -1275,6 +1277,8 @@ mix hex.outdated
- 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).
@@ -1292,7 +1296,7 @@ mix hex.outdated
**Join confirmation email:**
-- `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.
+- 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):**
diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md
index 9a01f9d..0ad562e 100644
--- a/DESIGN_GUIDELINES.md
+++ b/DESIGN_GUIDELINES.md
@@ -89,7 +89,7 @@ Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_jo
- **Implementation:**
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `` with the SignIn component inside a hero. Displays a locale-aware `
` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the library’s Banner is hidden via `show_banner: false`).
- **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `` with a hero for the form.
- - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that repeats the same header markup and a hero block for the result (no component call from controller templates).
+ - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `` 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)
diff --git a/assets/css/app.css b/assets/css/app.css
index e79b4b6..d7f873c 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -156,9 +156,9 @@
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
which fails contrast. Override to 85% of base-content so labels stay slightly
- de‑emphasised vs body text but meet the minimum ratio. */
-[data-theme="light"] .label,
-[data-theme="dark"] .label {
+ de‑emphasised vs body text but meet the minimum ratio. Match .label directly
+ so the override applies even when data-theme is not yet set (e.g. initial load). */
+.label {
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
diff --git a/config/config.exs b/config/config.exs
index 35e4160..037fd49 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -104,6 +104,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",
diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex
index 71cc7b1..f2342b7 100644
--- a/lib/accounts/user/validations/registration_enabled.ex
+++ b/lib/accounts/user/validations/registration_enabled.ex
@@ -21,7 +21,11 @@ defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
{:error,
field: :base,
message:
- "Registration is disabled. Please use the join form or contact an administrator."}
+ Gettext.dgettext(
+ MvWeb.Gettext,
+ "default",
+ "Registration is disabled. Please use the join form or contact an administrator."
+ )}
end
end
end
diff --git a/lib/membership/join_notifier.ex b/lib/membership/join_notifier.ex
new file mode 100644
index 0000000..daec4c1
--- /dev/null
+++ b/lib/membership/join_notifier.ex
@@ -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
diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex
index a3206a2..c8055d2 100644
--- a/lib/membership/join_request/changes/regenerate_confirmation_token.ex
+++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex
@@ -16,13 +16,16 @@ defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
- hash = JoinRequest.hash_confirmation_token(token)
- expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
+ now = DateTime.utc_now()
+ expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
changeset
- |> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
+ |> 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, DateTime.utc_now())
+ |> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
else
changeset
end
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 8812d99..7fa35dc 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -32,9 +32,7 @@ defmodule Mv.Membership do
alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest
alias Mv.Membership.Member
- alias MvWeb.Emails.JoinAlreadyMemberEmail
- alias MvWeb.Emails.JoinAlreadyPendingEmail
- alias MvWeb.Emails.JoinConfirmationEmail
+ alias Mv.Membership.SettingsCache
require Logger
admin do
@@ -118,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
@@ -162,9 +166,16 @@ defmodule Mv.Membership do
"""
def update_settings(settings, attrs) do
- settings
- |> Ash.Changeset.for_update(:update, attrs)
- |> Ash.update(domain: __MODULE__)
+ case settings
+ |> Ash.Changeset.for_update(:update, attrs)
+ |> Ash.update(domain: __MODULE__) do
+ {:ok, _updated} = result ->
+ SettingsCache.invalidate()
+ result
+
+ error ->
+ error
+ end
end
@doc """
@@ -228,11 +239,18 @@ defmodule Mv.Membership do
"""
def update_member_field_visibility(settings, visibility_config) do
- settings
- |> Ash.Changeset.for_update(:update_member_field_visibility, %{
- member_field_visibility: visibility_config
- })
- |> Ash.update(domain: __MODULE__)
+ case settings
+ |> Ash.Changeset.for_update(:update_member_field_visibility, %{
+ member_field_visibility: visibility_config
+ })
+ |> Ash.update(domain: __MODULE__) do
+ {:ok, _} = result ->
+ SettingsCache.invalidate()
+ result
+
+ error ->
+ error
+ end
end
@doc """
@@ -265,12 +283,19 @@ defmodule Mv.Membership do
field: field,
show_in_overview: show_in_overview
) do
- settings
- |> Ash.Changeset.new()
- |> Ash.Changeset.set_argument(:field, field)
- |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
- |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
- |> Ash.update(domain: __MODULE__)
+ case settings
+ |> Ash.Changeset.new()
+ |> Ash.Changeset.set_argument(:field, field)
+ |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
+ |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
+ |> Ash.update(domain: __MODULE__) do
+ {:ok, _} = result ->
+ SettingsCache.invalidate()
+ result
+
+ error ->
+ error
+ end
end
@doc """
@@ -304,13 +329,20 @@ defmodule Mv.Membership do
show_in_overview: show_in_overview,
required: required
) do
- 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__)
+ case settings
+ |> Ash.Changeset.new()
+ |> Ash.Changeset.set_argument(:field, field)
+ |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
+ |> Ash.Changeset.set_argument(:required, required)
+ |> Ash.Changeset.for_update(:update_single_member_field, %{})
+ |> Ash.update(domain: __MODULE__) do
+ {:ok, _} = result ->
+ SettingsCache.invalidate()
+ result
+
+ error ->
+ error
+ end
end
@doc """
@@ -427,12 +459,12 @@ defmodule Mv.Membership do
defp pending_join_request_with_email(_), do: nil
- defp apply_anti_enumeration_delay do
- Process.sleep(100 + :rand.uniform(200))
+ defp join_notifier do
+ Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
end
defp send_already_member_and_return(email) do
- case JoinAlreadyMemberEmail.send(email) do
+ case join_notifier().send_already_member(email) do
{:ok, _} ->
:ok
@@ -440,7 +472,7 @@ defmodule Mv.Membership do
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end
- apply_anti_enumeration_delay()
+ # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_member}
end
@@ -461,7 +493,7 @@ defmodule Mv.Membership do
})
|> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} ->
- case JoinConfirmationEmail.send(email, new_token, resend: true) do
+ case join_notifier().send_confirmation(email, new_token, resend: true) do
{:ok, _} ->
:ok
@@ -469,7 +501,7 @@ defmodule Mv.Membership do
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end
- apply_anti_enumeration_delay()
+ # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
{:error, _} ->
@@ -479,7 +511,7 @@ defmodule Mv.Membership do
end
defp send_already_pending_and_return(email) do
- case JoinAlreadyPendingEmail.send(email) do
+ case join_notifier().send_already_pending(email) do
{:ok, _} ->
:ok
@@ -487,7 +519,7 @@ defmodule Mv.Membership do
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end
- apply_anti_enumeration_delay()
+ # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
end
@@ -501,9 +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} ->
- apply_anti_enumeration_delay()
+ # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, request}
{:error, reason} ->
diff --git a/lib/membership/settings_cache.ex b/lib/membership/settings_cache.ex
new file mode 100644
index 0000000..d8581d6
--- /dev/null
+++ b/lib/membership/settings_cache.ex
@@ -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
diff --git a/lib/mv/application.ex b/lib/mv/application.ex
index 6b4a10b..1b6014e 100644
--- a/lib/mv/application.ex
+++ b/lib/mv/application.ex
@@ -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,20 +17,28 @@ defmodule Mv.Application do
def start(_type, _args) do
SyncFlash.create_table!()
- children = [
- Telemetry,
- Repo,
- {JoinRateLimit, [clean_period: :timer.minutes(1)]},
- {Task.Supervisor, name: Mv.TaskSupervisor},
- {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
- {Phoenix.PubSub, name: Mv.PubSub},
- {AshAuthentication.Supervisor, otp_app: :my},
- SystemActor,
- # Start a worker by calling: Mv.Worker.start_link(arg)
- # {Mv.Worker, arg},
- # Start to serve requests, typically the last entry
- Endpoint
- ]
+ # SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
+ cache_children =
+ if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
+
+ children =
+ [
+ Telemetry,
+ Repo
+ ] ++
+ cache_children ++
+ [
+ {JoinRateLimit, [clean_period: :timer.minutes(1)]},
+ {Task.Supervisor, name: Mv.TaskSupervisor},
+ {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
+ {Phoenix.PubSub, name: Mv.PubSub},
+ {AshAuthentication.Supervisor, otp_app: :my},
+ SystemActor,
+ # Start a worker by calling: Mv.Worker.start_link(arg)
+ # {Mv.Worker, arg},
+ # Start to serve requests, typically the last entry
+ Endpoint
+ ]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 5258ab9..29f5b8e 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -17,16 +17,24 @@ defmodule MvWeb.Layouts do
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 =
- case Mv.Membership.get_settings() do
- {:ok, s} -> s.club_name || "Mitgliederverwaltung"
- _ -> "Mitgliederverwaltung"
- end
+ assigns[:club_name] ||
+ case Mv.Membership.get_settings() do
+ {:ok, s} -> s.club_name || "Mitgliederverwaltung"
+ _ -> "Mitgliederverwaltung"
+ end
assigns = assign(assigns, :club_name, club_name)
diff --git a/lib/mv_web/controllers/join_confirm_controller.ex b/lib/mv_web/controllers/join_confirm_controller.ex
index 38a3263..b304b0c 100644
--- a/lib/mv_web/controllers/join_confirm_controller.ex
+++ b/lib/mv_web/controllers/join_confirm_controller.ex
@@ -48,15 +48,8 @@ defmodule MvWeb.JoinConfirmController do
end
defp assign_confirm_assigns(conn, result) do
- club_name =
- case Mv.Membership.get_settings() do
- {:ok, settings} -> settings.club_name || "Mitgliederverwaltung"
- _ -> "Mitgliederverwaltung"
- end
-
conn
|> assign(:result, result)
- |> assign(:club_name, club_name)
- |> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
+ |> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
end
end
diff --git a/lib/mv_web/controllers/join_confirm_html/confirm.html.heex b/lib/mv_web/controllers/join_confirm_html/confirm.html.heex
index 8789607..68fb6d3 100644
--- a/lib/mv_web/controllers/join_confirm_html/confirm.html.heex
+++ b/lib/mv_web/controllers/join_confirm_html/confirm.html.heex
@@ -1,24 +1,4 @@
-<%!-- Public header (same structure as Layouts.app unauthenticated branch) --%>
-
-
-
- {@club_name}
-
-
-
-
-
+
@@ -62,4 +42,4 @@
-
+
diff --git a/lib/mv_web/join_notifier_impl.ex b/lib/mv_web/join_notifier_impl.ex
new file mode 100644
index 0000000..2c29147
--- /dev/null
+++ b/lib/mv_web/join_notifier_impl.ex
@@ -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
diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex
index e83031c..ed0e6e6 100644
--- a/lib/mv_web/live/join_live.ex
+++ b/lib/mv_web/live/join_live.ex
@@ -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,7 @@ defmodule MvWeb.JoinLive do
|> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field)
+ |> assign(:club_name, club_name)
|> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket}
@@ -33,7 +44,7 @@ defmodule MvWeb.JoinLive do
@impl true
def render(assigns) do
~H"""
-
+
@@ -149,7 +160,11 @@ defmodule MvWeb.JoinLive do
{:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} ->
- {:noreply, assign(socket, :submitted, true)}
+ 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,
@@ -181,6 +196,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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index d5d3c33..79bd2dc 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -2897,7 +2897,6 @@ msgstr "Intervall auswählen"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
@@ -3892,3 +3891,8 @@ msgstr "Einstellung konnte nicht gespeichert werden."
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
+
+#: lib/accounts/user/validations/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled. Please use the join form or contact an administrator."
+msgstr "Die Registrierung ist deaktiviert. Bitte nutze das Beitrittsformular oder wende dich an eine*n Administrator*in."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 53acf03..a27bdbe 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -2898,7 +2898,6 @@ msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
@@ -3892,3 +3891,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr ""
+
+#: lib/accounts/user/validations/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled. Please use the join form or contact an administrator."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index eed38d4..69062c2 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -2898,7 +2898,6 @@ msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
msgstr ""
@@ -3892,3 +3891,8 @@ msgstr "Failed to update setting."
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
+
+#: lib/accounts/user/validations/registration_enabled.ex
+#, elixir-autogen, elixir-format
+msgid "Registration is disabled. Please use the join form or contact an administrator."
+msgstr ""
diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs
index 1458973..4b6c24a 100644
--- a/test/mv_web/live/join_live_test.exs
+++ b/test/mv_web/live/join_live_test.exs
@@ -9,7 +9,8 @@ defmodule MvWeb.JoinLiveTest do
Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot").
Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text".
"""
- use MvWeb.ConnCase, async: true
+ # async: false so LiveView and test share sandbox (submit creates JoinRequest in LiveView process).
+ use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Ecto.Query
@@ -53,6 +54,9 @@ defmodule MvWeb.JoinLiveTest do
})
|> render_submit()
+ # Anti-enumeration delay is applied in LiveView via send_after (100–300 ms); wait for success UI.
+ Process.sleep(400)
+
assert count_join_requests() == count_before + 1
assert view |> element("[data-testid='join-success-message']") |> has_element?()
assert render(view) =~ "saved your details"
From e8ec620d57ffb40cfec9b94e7ee6e9efd1ffe352 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 13 Mar 2026 18:22:12 +0100
Subject: [PATCH 16/26] feat: add timezone handling
---
CHANGELOG.md | 1 +
assets/js/app.js | 13 ++++-
config/config.exs | 3 +
lib/mv_web/helpers/date_formatter.ex | 30 ++++++++--
lib/mv_web/live/join_request_live/index.ex | 8 +--
lib/mv_web/live/join_request_live/show.ex | 10 +++-
lib/mv_web/live_helpers.ex | 9 +++
mix.exs | 3 +-
mix.lock | 1 +
priv/gettext/de/LC_MESSAGES/default.po | 5 --
priv/gettext/default.pot | 5 --
priv/gettext/en/LC_MESSAGES/default.po | 5 --
test/mv_web/helpers/date_formatter_test.exs | 63 +++++++++++++++++++++
13 files changed, 128 insertions(+), 28 deletions(-)
create mode 100644 test/mv_web/helpers/date_formatter_test.exs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08284ec..681169f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.1.0] - 2026-03-13
### Added
+- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone.
- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
diff --git a/assets/js/app.js b/assets/js/app.js
index ee423eb..87f2c25 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -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 = {}
@@ -312,7 +320,10 @@ Hooks.SidebarState = {
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
- params: {_csrf_token: csrfToken},
+ params: {
+ _csrf_token: csrfToken,
+ timezone: getBrowserTimezone()
+ },
hooks: Hooks
})
diff --git a/config/config.exs b/config/config.exs
index 037fd49..7bb4f61 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -46,6 +46,9 @@ 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],
diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex
index 8674e21..5e11777 100644
--- a/lib/mv_web/helpers/date_formatter.ex
+++ b/lib/mv_web/helpers/date_formatter.ex
@@ -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
- Calendar.strftime(dt, "%d.%m.%Y %H:%M")
+ 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), do: ""
+ def format_datetime(nil, _timezone), do: ""
- def format_datetime(_), do: "Invalid datetime"
+ def format_datetime(_, _timezone), do: "Invalid datetime"
+
+ defp format_datetime_utc(%DateTime{} = dt) do
+ Calendar.strftime(dt, "%d.%m.%Y %H:%M")
+ end
end
diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex
index 8d85837..a552b52 100644
--- a/lib/mv_web/live/join_request_live/index.ex
+++ b/lib/mv_web/live/join_request_live/index.ex
@@ -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
<:col :let={req} label={gettext("Reviewed at")}>
- {review_date(req)}
+ {review_date(req, @browser_timezone)}
<:col :let={req} label={gettext("Review by")}>
{JoinRequestHelpers.reviewer_display(req) || ""}
@@ -162,7 +162,7 @@ defmodule MvWeb.JoinRequestLive.Index do
assign(socket, :page_title, 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
diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex
index 14e2760..a606e46 100644
--- a/lib/mv_web/live/join_request_live/show.ex
+++ b/lib/mv_web/live/join_request_live/show.ex
@@ -144,7 +144,7 @@ defmodule MvWeb.JoinRequestLive.Show do