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." + )} + +
+ <% end %> + + <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:smtp_host]} + type="text" + label={gettext("Host")} + disabled={@smtp_host_env_set} + placeholder={ + if(@smtp_host_env_set, + do: gettext("From SMTP_HOST"), + else: "smtp.example.com" + ) + } + /> + <.input + field={@form[:smtp_port]} + type="number" + label={gettext("Port")} + disabled={@smtp_port_env_set} + placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} + /> + <.input + field={@form[:smtp_username]} + type="text" + label={gettext("Username")} + disabled={@smtp_username_env_set} + placeholder={ + if(@smtp_username_env_set, + do: gettext("From SMTP_USERNAME"), + else: "user@example.com" + ) + } + /> +
+ + <.input + field={@form[:smtp_password]} + type="password" + label="" + disabled={@smtp_password_env_set} + placeholder={ + if(@smtp_password_env_set, + do: gettext("From SMTP_PASSWORD"), + else: + if(@smtp_password_set, + do: gettext("Leave blank to keep current"), + else: nil + ) + ) + } + /> +
+ <.input + field={@form[:smtp_ssl]} + type="select" + label={gettext("TLS/SSL")} + disabled={@smtp_ssl_env_set} + options={[ + {gettext("TLS (port 587, recommended)"), "tls"}, + {gettext("SSL (port 465)"), "ssl"}, + {gettext("None (port 25, insecure)"), "none"} + ]} + placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)} + /> + <.input + field={@form[:smtp_from_email]} + type="email" + label={gettext("Sender email (From)")} + disabled={@smtp_from_email_env_set} + placeholder={ + if(@smtp_from_email_env_set, + do: gettext("From MAIL_FROM_EMAIL"), + else: "noreply@example.com" + ) + } + /> + <.input + field={@form[:smtp_from_name]} + type="text" + label={gettext("Sender name (From)")} + disabled={@smtp_from_name_env_set} + placeholder={ + if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") + } + /> +
+

+ {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("Test email")}

+ <.form + for={%{}} + id="smtp-test-email-form" + data-testid="smtp-test-email-form" + phx-submit="send_smtp_test_email" + class="space-y-3" + > +
+
+ + +
+ <.button + type="submit" + variant="outline" + data-testid="smtp-send-test-email" + phx-disable-with={gettext("Sending...")} + > + {gettext("Send test email")} + +
+ + <%= if @smtp_test_result do %> +
+ <.smtp_test_result result={@smtp_test_result} /> +
+ <% end %> +
+ + <%!-- Vereinfacht Integration Section --%> <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %> @@ -516,6 +688,30 @@ 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))} + end + + @impl true + def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do + {:noreply, assign(socket, :smtp_test_to_email, email)} + end + + @impl true + def handle_event("send_smtp_test_email", params, socket) do + to_email = + (params["to_email"] || socket.assigns.smtp_test_to_email || "") + |> String.trim() + + result = Mv.Mailer.send_test_email(to_email) + {:noreply, assign(socket, :smtp_test_result, result)} + end + @impl true def handle_event("test_vereinfacht_connection", _params, socket) do result = Mv.Vereinfacht.test_connection() @@ -560,11 +756,13 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) - # Never send blank API key / client secret so we do not overwrite stored secrets + + # Never send blank API key / client secret / smtp password so we do not overwrite stored secrets setting_params_clean = setting_params |> drop_blank_vereinfacht_api_key() |> drop_blank_oidc_client_secret() + |> drop_blank_smtp_password() saves_vereinfacht = vereinfacht_params?(setting_params_clean) @@ -581,6 +779,10 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) + |> assign(:smtp_configured, Mv.Config.smtp_configured?()) + |> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) + |> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?()) + |> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?()) |> assign(:vereinfacht_test_result, test_result) |> put_flash(:success, gettext("Settings updated successfully")) |> assign_form() @@ -760,17 +962,29 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_blank_smtp_password(params) when is_map(params) do + case params do + %{"smtp_password" => v} when v in [nil, ""] -> + Map.delete(params, "smtp_password") + + _ -> + params + end + end + defp assign_form(%{assigns: %{settings: settings}} = socket) do - # Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret + # Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form settings_display = settings |> merge_vereinfacht_env_values() |> merge_oidc_env_values() + |> merge_smtp_env_values() settings_for_form = %{ settings_display | vereinfacht_api_key: nil, - oidc_client_secret: nil + oidc_client_secret: nil, + smtp_password: nil } form = @@ -845,6 +1059,28 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp merge_smtp_env_values(s) do + s + |> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host()) + |> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port()) + |> put_if_env_set( + :smtp_username, + Mv.Config.smtp_username_env_set?(), + Mv.Config.smtp_username() + ) + |> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl()) + |> put_if_env_set( + :smtp_from_email, + Mv.Config.mail_from_email_env_set?(), + Mv.Config.mail_from_email() + ) + |> put_if_env_set( + :smtp_from_name, + Mv.Config.mail_from_name_env_set?(), + Mv.Config.mail_from_name() + ) + end + defp enrich_sync_errors([]), do: [] defp enrich_sync_errors(errors) when is_list(errors) do @@ -1018,6 +1254,115 @@ defmodule MvWeb.GlobalSettingsLive do """ end + # ---- SMTP test result component ---- + + attr :result, :any, required: true + + defp smtp_test_result(%{result: {:ok, _}} = assigns) do + ~H""" +
+ <.icon name="hero-check-circle" class="size-5 shrink-0" /> + {gettext("Test email sent successfully.")} +
+ """ + 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""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("Server unreachable. Check host and port.")} + +
+ """ + end + + defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns) + when is_binary(message) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("SMTP error:")} {@result |> elem(1) |> elem(1)} + +
+ """ + 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):")} +

+
+ + <.button + variant="secondary" + size="sm" + id="copy-join-url-btn" + phx-hook="CopyToClipboard" + phx-click="copy_join_url" + aria-label={gettext("Copy join page URL")} + > + <.icon name="hero-clipboard-document" class="size-4" /> + {gettext("Copy")} + +
+
+ <%!-- Field list header + Add button (left-aligned) --%>

{gettext("Fields on the join form")}

@@ -796,6 +824,16 @@ defmodule MvWeb.GlobalSettingsLive do # ---- Join form event handlers ---- + @impl true + def handle_event("copy_join_url", _params, socket) do + socket = + socket + |> push_event("copy_to_clipboard", %{text: socket.assigns.join_url}) + |> put_flash(:success, gettext("Join page URL copied to clipboard.")) + + {:noreply, socket} + end + @impl true def handle_event("toggle_join_form_enabled", _params, socket) do socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled) diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index 138b433..d326f4f 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -128,20 +128,20 @@ defmodule MvWeb.JoinRequestLive.Show do <%= if @join_request do %>
+ <%!-- Single block: all applicant-provided data in join form order --%>
-

{gettext("Request data")}

+

{gettext("Applicant data")}

+
+ <%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %> + <.field_row label={label} value={value} empty_text={gettext("Not specified")} /> + <% end %> +
+
+ + <%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%> +
+

{gettext("Status and review")}

- <.field_row label={gettext("Email")} value={@join_request.email} /> - <.field_row - label={gettext("First name")} - value={@join_request.first_name} - empty_text={gettext("Not specified")} - /> - <.field_row - label={gettext("Last name")} - value={@join_request.last_name} - empty_text={gettext("Not specified")} - /> <.field_row label={gettext("Submitted at")} value={DateFormatter.format_datetime(@join_request.submitted_at)} @@ -154,24 +154,7 @@ defmodule MvWeb.JoinRequestLive.Show do
-
-
- - <%= 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.")} +

+
diff --git a/lib/mv_web/templates/emails/join_confirmation.html.heex b/lib/mv_web/templates/emails/join_confirmation.html.heex index b8344eb..0cd6ebc 100644 --- a/lib/mv_web/templates/emails/join_confirmation.html.heex +++ b/lib/mv_web/templates/emails/join_confirmation.html.heex @@ -1,4 +1,9 @@
+ <%= if @resend do %> +

+ {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""" +
+
+ Mila Logo + 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 %> - -
- Mila Logo - + +
+
+ Mila Logo + Mitgliederverwaltung +
+ {@club_name}
@@ -113,8 +164,8 @@ defmodule MvWeb.Layouts do class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" aria-label={gettext("Select language")} > - - + +
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) --%> +
+ Mila Logo + + {@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.")} +

+ + {gettext("Back to join form")} + + <% :expired -> %> +

+ {gettext("Link expired")} +

+

+ {gettext("This link has expired. Please submit the form again.")} +

+ + {gettext("Submit new request")} + + <% :invalid -> %> +

+ {gettext("Invalid or expired link")} +

+

+ {gettext("Invalid or expired link.")} +

+ + {gettext("Go to join form")} + + <% end %> +
+
+
+
+
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""" -
-

{dgettext("auth", "Sign in")}

- <%!-- Language selector --%> - - - <.live_component - module={Components.SignIn} - otp_app={@otp_app} - live_action={@live_action} - path={@path} - auth_routes_prefix={@auth_routes_prefix} - resources={@resources} - reset_path={@reset_path} - register_path={@register_path} - id={@sign_in_id} - overrides={@overrides} - current_tenant={@current_tenant} - context={@context} - gettext_fn={@gettext_fn} - /> -
+ +
+
+
+
+

+ {if @live_action == :register, + do: dgettext("auth", "Register"), + else: dgettext("auth", "Sign in")} +

+ <.live_component + module={Components.SignIn} + otp_app={@otp_app} + live_action={@live_action} + path={@path} + auth_routes_prefix={@auth_routes_prefix} + resources={@resources} + reset_path={@reset_path} + register_path={@register_path} + id={@sign_in_id} + overrides={@overrides} + current_tenant={@current_tenant} + context={@context} + gettext_fn={@gettext_fn} + /> +
+
+
+
+
""" end end diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index 7489331..4716cf8 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -33,91 +33,97 @@ defmodule MvWeb.JoinLive do @impl true def render(assigns) do ~H""" - -
- <.header> - {gettext("Become a member")} - + +
+
+
+
+ <.header> + {gettext("Become a member")} + -

- {gettext("Please enter your details for the membership application here.")} -

+

+ {gettext("Please enter your details for the membership application here.")} +

- <%= if @submitted do %> -
-

- {gettext( - "We have saved your details. To complete your request, please click the link we sent to your email." - )} -

-
- <% else %> - <.form - for={@form} - id="join-form" - phx-submit="submit" - class="space-y-4" - > - <%= if @rate_limit_error do %> -
- {@rate_limit_error} -
- <% end %> + <%= if @submitted do %> +
+

+ {gettext( + "We have saved your details. To complete your request, please click the link we sent to your email." + )} +

+
+ <% else %> + <.form + for={@form} + id="join-form" + phx-submit="submit" + class="space-y-4" + > + <%= if @rate_limit_error do %> +
+ {@rate_limit_error} +
+ <% 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 %> +
+
- + """ 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"> -

- <.input - field={@form[:club_name]} - type="text" - label={gettext("Association Name")} - required - /> -
- - <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Name")} - - - - <%!-- Join Form Section (Beitrittsformular) --%> - <.form_section title={gettext("Join Form")}> -

- {gettext("Configure the public join form that allows new members to submit a join request.")} -

- - <%!-- Enable/disable --%> -
- - -
- -
- <%!-- Copyable join page link (below checkbox, above field list) --%> -
-

- {gettext("Link to the public join page (share this with applicants):")} -

-
- - <.button - variant="secondary" - size="sm" - id="copy-join-url-btn" - phx-hook="CopyToClipboard" - phx-click="copy_join_url" - aria-label={gettext("Copy join page URL")} - > - <.icon name="hero-clipboard-document" class="size-4" /> - {gettext("Copy")} - -
-
- - <%!-- Field list header + Add button (left-aligned) --%> -

{gettext("Fields on the join form")}

-
- <.button - type="button" - variant="primary" - phx-click="toggle_add_field_dropdown" - disabled={ - Enum.empty?(@available_join_form_member_fields) and - Enum.empty?(@available_join_form_custom_fields) - } - aria-haspopup="listbox" - aria-expanded={to_string(@show_add_field_dropdown)} - > - <.icon name="hero-plus" class="size-4" /> - {gettext("Add field")} - - - <%!-- Available fields dropdown (sections: Personal data, Custom fields) --%> -
-
-
- {gettext("Personal data")} -
-
- {field.label} -
-
-
-
- {gettext("Individual fields")} -
-
- {field.label} -
-
-
-
- - <%!-- Empty state --%> -

- {gettext("No fields selected. Add at least the email field.")} -

- - <%!-- Fields table (compact width, reorderable) --%> -
- <.sortable_table - id="join-form-fields-table" - rows={@join_form_fields} - row_id={fn field -> "join-field-#{field.id}" end} - reorder_event="reorder_join_form_field" - > - <:col :let={field} label={gettext("Field")} class="min-w-[14rem]"> - {field.label} - - <:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center"> - - - <:action :let={field}> - <.tooltip content={gettext("Remove")} position="left"> - <.button - type="button" - variant="danger" - size="sm" - disabled={not field.can_remove} - class={if(not field.can_remove, do: "opacity-50 cursor-not-allowed", else: "")} - phx-click="remove_join_form_field" - phx-value-field_id={field.id} - aria-label={gettext("Remove field %{label}", label: field.label)} - > - <.icon name="hero-trash" class="size-4" /> - - - - -

- {gettext("The order of rows determines the field order in the join form.")} -

-
-
- - <%!-- 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 @environment == :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." - )} - -
- <% end %> - - <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save"> -
- <.input - field={@form[:smtp_host]} - type="text" - label={gettext("Host")} - disabled={@smtp_host_env_set} - placeholder={ - if(@smtp_host_env_set, - do: gettext("From SMTP_HOST"), - else: "smtp.example.com" - ) - } - /> - <.input - field={@form[:smtp_port]} - type="number" - label={gettext("Port")} - disabled={@smtp_port_env_set} - placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} - /> - <.input - field={@form[:smtp_username]} - type="text" - label={gettext("Username")} - disabled={@smtp_username_env_set} - placeholder={ - if(@smtp_username_env_set, - do: gettext("From SMTP_USERNAME"), - else: "user@example.com" - ) - } - /> -
- +
+ <%!-- Club Settings Section --%> + <.form_section title={gettext("Club Settings")}> + <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> +
<.input - field={@form[:smtp_password]} - type="password" - label="" - disabled={@smtp_password_env_set} - placeholder={ - if(@smtp_password_env_set, - do: gettext("From SMTP_PASSWORD"), - else: - if(@smtp_password_set, - do: gettext("Leave blank to keep current"), - else: nil - ) - ) - } + field={@form[:club_name]} + type="text" + label={gettext("Association Name")} + required />
- <.input - field={@form[:smtp_ssl]} - type="select" - label={gettext("TLS/SSL")} - disabled={@smtp_ssl_env_set} - options={[ - {gettext("TLS (port 587, recommended)"), "tls"}, - {gettext("SSL (port 465)"), "ssl"}, - {gettext("None (port 25, insecure)"), "none"} - ]} - placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)} - /> - <.input - field={@form[:smtp_from_email]} - type="email" - label={gettext("Sender email (From)")} - disabled={@smtp_from_email_env_set} - placeholder={ - if(@smtp_from_email_env_set, - do: gettext("From MAIL_FROM_EMAIL"), - else: "noreply@example.com" - ) - } - /> - <.input - field={@form[:smtp_from_name]} - type="text" - label={gettext("Sender name (From)")} - disabled={@smtp_from_name_env_set} - placeholder={ - if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") - } - /> -
-

+ + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Name")} + + + + <%!-- Join Form Section (Beitrittsformular) --%> + <.form_section title={gettext("Join Form")}> +

{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("Test email")}

- <.form - for={%{}} - id="smtp-test-email-form" - data-testid="smtp-test-email-form" - phx-submit="send_smtp_test_email" - class="space-y-3" - > -
-
- + <%!-- Enable/disable --%> +
+ + +
+ +
+ <%!-- Copyable join page link (below checkbox, above field list) --%> +
+

+ {gettext("Link to the public join page (share this with applicants):")} +

+
+ <.button + variant="secondary" + size="sm" + id="copy-join-url-btn" + phx-hook="CopyToClipboard" + phx-click="copy_join_url" + aria-label={gettext("Copy join page URL")} + > + <.icon name="hero-clipboard-document" class="size-4" /> + {gettext("Copy")} +
- <.button - type="submit" - variant="outline" - data-testid="smtp-send-test-email" - phx-disable-with={gettext("Sending...")} - > - {gettext("Send test email")} -
- - <%= if @smtp_test_result do %> -
- <.smtp_test_result result={@smtp_test_result} /> -
- <% end %> -
- - <%!-- Vereinfacht Integration Section --%> - <.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}> - <%= if @vereinfacht_env_configured do %> -

- {gettext("Some values are set via environment variables. Those fields are read-only.")} -

- <% end %> - <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> -
- <.input - field={@form[:vereinfacht_api_url]} - type="text" - label={gettext("API URL")} - disabled={@vereinfacht_api_url_env_set} - placeholder={ - if(@vereinfacht_api_url_env_set, - do: gettext("From VEREINFACHT_API_URL"), - else: "https://api.verein.visuel.dev/api/v1" - ) - } - /> -
- - <.input - field={@form[:vereinfacht_api_key]} - type="password" - label="" - disabled={@vereinfacht_api_key_env_set} - placeholder={ - if(@vereinfacht_api_key_env_set, - do: gettext("From VEREINFACHT_API_KEY"), - else: - if(@vereinfacht_api_key_set, - do: gettext("Leave blank to keep current"), - else: nil - ) - ) + <%!-- Field list header + Add button (left-aligned) --%> +

{gettext("Fields on the join form")}

+
+ <.button + type="button" + variant="primary" + phx-click="toggle_add_field_dropdown" + disabled={ + Enum.empty?(@available_join_form_member_fields) and + Enum.empty?(@available_join_form_custom_fields) } - /> + aria-haspopup="listbox" + aria-expanded={to_string(@show_add_field_dropdown)} + > + <.icon name="hero-plus" class="size-4" /> + {gettext("Add field")} + + + <%!-- Available fields dropdown (sections: Personal data, Custom fields) --%> +
+
+
+ {gettext("Personal data")} +
+
+ {field.label} +
+
+
+
+ {gettext("Individual fields")} +
+
+ {field.label} +
+
+
- <.input - field={@form[:vereinfacht_club_id]} - type="text" - label={gettext("Club ID")} - disabled={@vereinfacht_club_id_env_set} - placeholder={ - if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") - } - /> - <.input - field={@form[:vereinfacht_app_url]} - type="text" - label={gettext("App URL (contact view link)")} - disabled={@vereinfacht_app_url_env_set} - placeholder={ - if(@vereinfacht_app_url_env_set, - do: gettext("From VEREINFACHT_APP_URL"), - else: "https://app.verein.visuel.dev" - ) - } - /> -
- <.button - :if={ - not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and - @vereinfacht_club_id_env_set) - } - phx-disable-with={gettext("Saving...")} - variant="primary" - class="mt-2" - > - {gettext("Save Vereinfacht Settings")} - -
- <.button - :if={Mv.Config.vereinfacht_configured?()} - type="button" - variant="outline" - phx-click="test_vereinfacht_connection" - phx-disable-with={gettext("Testing...")} - > - {gettext("Test Integration")} - - <.button - :if={Mv.Config.vereinfacht_configured?()} - type="button" - variant="outline" - phx-click="sync_vereinfacht_contacts" - phx-disable-with={gettext("Syncing...")} - > - {gettext("Sync all members without Vereinfacht contact")} - -
- <%= if @vereinfacht_test_result do %> - <.vereinfacht_test_result result={@vereinfacht_test_result} /> - <% end %> - <%= if @last_vereinfacht_sync_result do %> - <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> - <% end %> - - - <%!-- OIDC Section --%> - <.form_section title={gettext("OIDC (Single Sign-On)")}> - <%= if @oidc_env_configured do %> -

- {gettext("Some values are set via environment variables. Those fields are read-only.")} -

- <% end %> - <.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save"> -
- <.input - field={@form[:oidc_client_id]} - type="text" - label={gettext("Client ID")} - disabled={@oidc_client_id_env_set} - placeholder={ - if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv") - } - /> - <.input - field={@form[:oidc_base_url]} - type="text" - label={gettext("Base URL")} - disabled={@oidc_base_url_env_set} - placeholder={ - if(@oidc_base_url_env_set, - do: gettext("From OIDC_BASE_URL"), - else: "http://localhost:8080/auth/v1" - ) - } - /> - <.input - field={@form[:oidc_redirect_uri]} - type="text" - label={gettext("Redirect URI")} - disabled={@oidc_redirect_uri_env_set} - placeholder={ - if(@oidc_redirect_uri_env_set, - do: gettext("From OIDC_REDIRECT_URI"), - else: "http://localhost:4000/auth/user/oidc/callback" - ) - } - /> -
- - <.input - field={@form[:oidc_client_secret]} - type="password" - label="" - disabled={@oidc_client_secret_env_set} - placeholder={ - if(@oidc_client_secret_env_set, - do: gettext("From OIDC_CLIENT_SECRET"), - else: - if(@oidc_client_secret_set, - do: gettext("Leave blank to keep current"), - else: nil - ) - ) - } - /> -
- <.input - field={@form[:oidc_admin_group_name]} - type="text" - label={gettext("Admin group name")} - disabled={@oidc_admin_group_name_env_set} - placeholder={ - if(@oidc_admin_group_name_env_set, - do: gettext("From OIDC_ADMIN_GROUP_NAME"), - else: gettext("e.g. admin") - ) - } - /> - <.input - field={@form[:oidc_groups_claim]} - type="text" - label={gettext("Groups claim")} - disabled={@oidc_groups_claim_env_set} - placeholder={ - if(@oidc_groups_claim_env_set, - do: gettext("From OIDC_GROUPS_CLAIM"), - else: "groups" - ) - } - /> -
- <.input - field={@form[:oidc_only]} - type="checkbox" - class="checkbox checkbox-sm" - disabled={@oidc_only_env_set or not @oidc_configured} - label={ - if @oidc_only_env_set do - gettext("Only OIDC sign-in (hide password login)") <> - " (" <> gettext("From OIDC_ONLY") <> ")" - else - gettext("Only OIDC sign-in (hide password login)") - end - } - /> -

- {gettext( - "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." - )} + + <%!-- Empty state --%> +

+ {gettext("No fields selected. Add at least the email field.")} +

+ + <%!-- Fields table (compact width, reorderable) --%> +
+ <.sortable_table + id="join-form-fields-table" + rows={@join_form_fields} + row_id={fn field -> "join-field-#{field.id}" end} + reorder_event="reorder_join_form_field" + > + <:col :let={field} label={gettext("Field")} class="min-w-[14rem]"> + {field.label} + + <:col + :let={field} + label={gettext("Required")} + class="w-24 max-w-[9.375rem] text-center" + > + + + <:action :let={field}> + <.tooltip content={gettext("Remove")} position="left"> + <.button + type="button" + variant="danger" + size="sm" + disabled={not field.can_remove} + class={if(not field.can_remove, do: "opacity-50 cursor-not-allowed", else: "")} + phx-click="remove_join_form_field" + phx-value-field_id={field.id} + aria-label={gettext("Remove field %{label}", label: field.label)} + > + <.icon name="hero-trash" class="size-4" /> + + + + +

+ {gettext("The order of rows determines the field order in the join form.")}

- <.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")} - - - + + <%!-- 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 @environment == :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." + )} + +
+ <% end %> + + <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save"> +
+
+ <.input + field={@form[:smtp_host]} + type="text" + label={gettext("Host")} + disabled={@smtp_host_env_set} + placeholder={ + if(@smtp_host_env_set, + do: gettext("From SMTP_HOST"), + else: "smtp.example.com" + ) + } + /> + <.input + field={@form[:smtp_port]} + type="number" + label={gettext("Port")} + disabled={@smtp_port_env_set} + placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} + /> + <.input + field={@form[:smtp_ssl]} + type="select" + label={gettext("TLS/SSL")} + disabled={@smtp_ssl_env_set} + options={[ + {gettext("TLS (port 587, recommended)"), "tls"}, + {gettext("SSL (port 465)"), "ssl"}, + {gettext("None (port 25, insecure)"), "none"} + ]} + placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)} + /> +
+ +
+ <.input + field={@form[:smtp_username]} + type="text" + label={gettext("Username")} + disabled={@smtp_username_env_set} + placeholder={ + if(@smtp_username_env_set, + do: gettext("From SMTP_USERNAME"), + else: "user@example.com" + ) + } + /> + <.input + field={@form[:smtp_password]} + type="password" + label={gettext("Password")} + disabled={@smtp_password_env_set} + placeholder={ + if(@smtp_password_env_set, + do: gettext("From SMTP_PASSWORD"), + else: + if(@smtp_password_set, + do: gettext("Leave blank to keep current"), + else: nil + ) + ) + } + /> +
+ +
+ <.input + field={@form[:smtp_from_email]} + type="email" + label={gettext("Sender email (From)")} + disabled={@smtp_from_email_env_set} + placeholder={ + if(@smtp_from_email_env_set, + do: gettext("From MAIL_FROM_EMAIL"), + else: "noreply@example.com" + ) + } + /> + <.input + field={@form[:smtp_from_name]} + type="text" + label={gettext("Sender name (From)")} + disabled={@smtp_from_name_env_set} + placeholder={ + if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") + } + /> +
+
+

+ {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("Test email")}

+ <.form + for={%{}} + id="smtp-test-email-form" + data-testid="smtp-test-email-form" + phx-submit="send_smtp_test_email" + class="space-y-3" + > +
+
+ +
+ <.button + type="submit" + variant="secondary" + class="mb-1" + data-testid="smtp-send-test-email" + phx-disable-with={gettext("Sending...")} + > + {gettext("Send test email")} + +
+ + <%= if @smtp_test_result do %> +
+ <.smtp_test_result result={@smtp_test_result} /> +
+ <% end %> +
+ + + <%!-- Vereinfacht Integration Section --%> + <.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}> + <%= if @vereinfacht_env_configured do %> +

+ {gettext("Some values are set via environment variables. Those fields are read-only.")} +

+ <% end %> + <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:vereinfacht_api_url]} + type="text" + label={gettext("API URL")} + disabled={@vereinfacht_api_url_env_set} + placeholder={ + if(@vereinfacht_api_url_env_set, + do: gettext("From VEREINFACHT_API_URL"), + else: "https://api.verein.visuel.dev/api/v1" + ) + } + /> +
+ + <%= for msg <- ( + if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) do + Enum.map(@form[:vereinfacht_api_key].errors, &MvWeb.CoreComponents.translate_error/1) + else + [] + end + ) do %> +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {msg} +

+ <% end %> +
+ <.input + field={@form[:vereinfacht_club_id]} + type="text" + label={gettext("Club ID")} + disabled={@vereinfacht_club_id_env_set} + placeholder={ + if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + } + /> + <.input + field={@form[:vereinfacht_app_url]} + type="text" + label={gettext("App URL (contact view link)")} + disabled={@vereinfacht_app_url_env_set} + placeholder={ + if(@vereinfacht_app_url_env_set, + do: gettext("From VEREINFACHT_APP_URL"), + else: "https://app.verein.visuel.dev" + ) + } + /> +
+ <.button + :if={ + not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and + @vereinfacht_club_id_env_set) + } + phx-disable-with={gettext("Saving...")} + variant="primary" + class="mt-2" + > + {gettext("Save Vereinfacht Settings")} + +
+ <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + variant="secondary" + phx-click="test_vereinfacht_connection" + phx-disable-with={gettext("Testing...")} + > + {gettext("Test Integration")} + + <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + variant="secondary" + phx-click="sync_vereinfacht_contacts" + phx-disable-with={gettext("Syncing...")} + > + {gettext("Sync all members without Vereinfacht contact")} + +
+ <%= if @vereinfacht_test_result do %> + <.vereinfacht_test_result result={@vereinfacht_test_result} /> + <% end %> + <%= if @last_vereinfacht_sync_result do %> + <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> + <% end %> + + + <%!-- OIDC Section --%> + <.form_section title={gettext("OIDC (Single Sign-On)")}> + <%= if @oidc_env_configured do %> +

+ {gettext("Some values are set via environment variables. Those fields are read-only.")} +

+ <% end %> + <.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:oidc_client_id]} + type="text" + label={gettext("Client ID")} + disabled={@oidc_client_id_env_set} + placeholder={ + if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv") + } + /> + <.input + field={@form[:oidc_base_url]} + type="text" + label={gettext("Base URL")} + disabled={@oidc_base_url_env_set} + placeholder={ + if(@oidc_base_url_env_set, + do: gettext("From OIDC_BASE_URL"), + else: "http://localhost:8080/auth/v1" + ) + } + /> + <.input + field={@form[:oidc_redirect_uri]} + type="text" + label={gettext("Redirect URI")} + disabled={@oidc_redirect_uri_env_set} + placeholder={ + if(@oidc_redirect_uri_env_set, + do: gettext("From OIDC_REDIRECT_URI"), + else: "http://localhost:4000/auth/user/oidc/callback" + ) + } + /> +
+ + <%= for msg <- ( + if Phoenix.Component.used_input?(@form[:oidc_client_secret]) do + Enum.map(@form[:oidc_client_secret].errors, &MvWeb.CoreComponents.translate_error/1) + else + [] + end + ) do %> +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {msg} +

+ <% end %> +
+ <.input + field={@form[:oidc_admin_group_name]} + type="text" + label={gettext("Admin group name")} + disabled={@oidc_admin_group_name_env_set} + placeholder={ + if(@oidc_admin_group_name_env_set, + do: gettext("From OIDC_ADMIN_GROUP_NAME"), + else: gettext("e.g. admin") + ) + } + /> + <.input + field={@form[:oidc_groups_claim]} + type="text" + label={gettext("Groups claim")} + disabled={@oidc_groups_claim_env_set} + placeholder={ + if(@oidc_groups_claim_env_set, + do: gettext("From OIDC_GROUPS_CLAIM"), + else: "groups" + ) + } + /> +
+ <.input + field={@form[:oidc_only]} + type="checkbox" + class="checkbox checkbox-sm" + disabled={@oidc_only_env_set or not @oidc_configured} + label={ + if @oidc_only_env_set do + gettext("Only OIDC sign-in (hide password login)") <> + " (" <> gettext("From OIDC_ONLY") <> ")" + else + gettext("Only OIDC sign-in (hide password login)") + end + } + /> +

+ {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) --%> -
- Mila Logo - - {@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
<.field_row label={gettext("Submitted at")} - value={DateFormatter.format_datetime(@join_request.submitted_at)} + value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)} />
{gettext("Status")}: @@ -158,13 +158,17 @@ defmodule MvWeb.JoinRequestLive.Show do <%= if @join_request.approved_at do %> <.field_row label={gettext("Approved at")} - value={DateFormatter.format_datetime(@join_request.approved_at)} + value={ + DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone) + } /> <% end %> <%= if @join_request.rejected_at do %> <.field_row label={gettext("Rejected at")} - value={DateFormatter.format_datetime(@join_request.rejected_at)} + value={ + DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone) + } /> <% end %> <.field_row diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex index dae8325..5cbd6f0 100644 --- a/lib/mv_web/live_helpers.ex +++ b/lib/mv_web/live_helpers.ex @@ -22,6 +22,15 @@ defmodule MvWeb.LiveHelpers do def on_mount(:default, _params, session, socket) do locale = session["locale"] || "de" Gettext.put_locale(locale) + + # Browser timezone from LiveSocket connect params (set in app.js via Intl API) + connect_params = socket.private[:connect_params] || %{} + timezone = connect_params["timezone"] || connect_params[:timezone] + + socket = + socket + |> assign(:browser_timezone, timezone) + {:cont, socket} end diff --git a/mix.exs b/mix.exs index 29dbc25..a8d0467 100644 --- a/mix.exs +++ b/mix.exs @@ -85,7 +85,8 @@ defmodule Mv.MixProject do {:slugify, "~> 1.3"}, {:nimble_csv, "~> 1.0"}, {:imprintor, "~> 0.5.0"}, - {:hammer, "~> 7.0"} + {:hammer, "~> 7.0"}, + {:tz, "~> 0.28"} ] end diff --git a/mix.lock b/mix.lock index b177796..6f120c8 100644 --- a/mix.lock +++ b/mix.lock @@ -96,6 +96,7 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, + "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 79bd2dc..47fe18d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3891,8 +3891,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/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 a27bdbe..274ac12 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3891,8 +3891,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/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 69062c2..406449b 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3891,8 +3891,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/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/helpers/date_formatter_test.exs b/test/mv_web/helpers/date_formatter_test.exs new file mode 100644 index 0000000..8a07ab0 --- /dev/null +++ b/test/mv_web/helpers/date_formatter_test.exs @@ -0,0 +1,63 @@ +defmodule MvWeb.Helpers.DateFormatterTest do + @moduledoc """ + Tests for DateFormatter: date/datetime formatting and timezone conversion for display. + """ + use ExUnit.Case, async: true + + alias MvWeb.Helpers.DateFormatter + + describe "format_date/1" do + test "formats Date to European format (dd.mm.yyyy)" do + assert DateFormatter.format_date(~D[2024-03-15]) == "15.03.2024" + end + + test "returns empty string for nil" do + assert DateFormatter.format_date(nil) == "" + end + + test "returns 'Invalid date' for non-Date" do + assert DateFormatter.format_date("2024-03-15") == "Invalid date" + end + end + + describe "format_datetime/1 and format_datetime/2" do + test "formats UTC DateTime without timezone (European format)" do + dt = ~U[2024-03-15 10:30:00Z] + assert DateFormatter.format_datetime(dt) == "15.03.2024 10:30" + end + + test "format_datetime with nil timezone same as no timezone (UTC)" do + dt = ~U[2024-03-15 10:30:00Z] + assert DateFormatter.format_datetime(dt, nil) == "15.03.2024 10:30" + end + + test "formats DateTime in Europe/Berlin (CET/CEST)" do + # Winter: 10:30 UTC = 11:30 CET (UTC+1) + dt = ~U[2024-01-15 10:30:00Z] + assert DateFormatter.format_datetime(dt, "Europe/Berlin") == "15.01.2024 11:30" + + # Summer: 10:30 UTC = 12:30 CEST (UTC+2) + dt_summer = ~U[2024-07-15 10:30:00Z] + assert DateFormatter.format_datetime(dt_summer, "Europe/Berlin") == "15.07.2024 12:30" + end + + test "empty string timezone falls back to UTC" do + dt = ~U[2024-03-15 10:30:00Z] + assert DateFormatter.format_datetime(dt, "") == "15.03.2024 10:30" + end + + test "invalid timezone falls back to UTC" do + dt = ~U[2024-03-15 10:30:00Z] + assert DateFormatter.format_datetime(dt, "Invalid/Zone") == "15.03.2024 10:30" + end + + test "returns empty string for nil datetime" do + assert DateFormatter.format_datetime(nil) == "" + assert DateFormatter.format_datetime(nil, "Europe/Berlin") == "" + end + + test "returns 'Invalid datetime' for non-DateTime" do + assert DateFormatter.format_datetime("2024-03-15 10:30") == "Invalid datetime" + end + end +end From c9331449205bd71659f28f2cea6e5c42588ecb5d Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Mar 2026 19:01:50 +0100 Subject: [PATCH 17/26] feat: unify page titles --- lib/mv_web/components/layouts.ex | 33 +++++++ lib/mv_web/components/layouts/root.html.heex | 4 +- .../controllers/join_confirm_controller.ex | 9 ++ lib/mv_web/controllers/page_controller.ex | 6 +- lib/mv_web/live/auth/sign_in_live.ex | 10 ++ lib/mv_web/live/datafields_live.ex | 4 +- lib/mv_web/live/global_settings_live.ex | 4 +- lib/mv_web/live/group_live/form.ex | 6 +- lib/mv_web/live/group_live/index.ex | 4 +- lib/mv_web/live/group_live/show.ex | 6 +- lib/mv_web/live/import_live.ex | 4 +- lib/mv_web/live/join_live.ex | 1 + lib/mv_web/live/join_request_live/index.ex | 4 +- lib/mv_web/live/join_request_live/show.ex | 6 +- lib/mv_web/live/member_live/form.ex | 4 +- lib/mv_web/live/member_live/index.ex | 2 +- lib/mv_web/live/member_live/index.html.heex | 2 +- lib/mv_web/live/member_live/show.ex | 10 +- .../live/membership_fee_settings_live.ex | 4 +- .../live/membership_fee_type_live/form.ex | 6 +- .../live/membership_fee_type_live/index.ex | 4 +- lib/mv_web/live/role_live/form.ex | 12 +-- lib/mv_web/live/role_live/index.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 2 +- lib/mv_web/live/role_live/show.ex | 6 +- lib/mv_web/live/statistics_live.ex | 4 +- lib/mv_web/live/user_live/form.ex | 9 +- lib/mv_web/live/user_live/index.ex | 2 +- lib/mv_web/live/user_live/index.html.heex | 2 +- lib/mv_web/live/user_live/show.ex | 6 +- lib/mv_web/live_helpers.ex | 9 ++ lib/mv_web/plugs/assign_club_name.ex | 22 +++++ lib/mv_web/router.ex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 98 ++++++++++--------- priv/gettext/default.pot | 98 ++++++++++--------- priv/gettext/en/LC_MESSAGES/default.po | 98 ++++++++++--------- .../live/join_live_email_failure_test.exs | 5 +- 37 files changed, 309 insertions(+), 200 deletions(-) create mode 100644 lib/mv_web/plugs/assign_club_name.ex diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 29f5b8e..5a96001 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -13,6 +13,39 @@ defmodule MvWeb.Layouts do embed_templates "layouts/*" + @doc """ + Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club". + Order is always: Mila · page title · club name. + Uses assigns[:club_name] and the short page label from assigns[:content_title] or + assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar) + and then assign page_title to the result of this function so the client receives + the full title. + """ + def page_title_string(assigns) do + club = assigns[:club_name] + page = assigns[:content_title] || assigns[:page_title] + + parts = + [page, club] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + + if parts == [] do + "Mila" + else + "Mila · " <> Enum.join(parts, " · ") + end + end + + @doc """ + Assigns content_title (short label for heading; same gettext as sidebar) and + page_title (full browser tab title). Call from LiveView mount after club_name + is set (e.g. from on_mount). Returns the socket. + """ + def assign_page_title(socket, content_title) do + socket = assign(socket, :content_title, content_title) + assign(socket, :page_title, page_title_string(socket.assigns)) + end + @doc """ Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left, club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index e107d5b..5419b73 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -7,8 +7,8 @@ - <.live_title default="Mv" suffix=" · Phoenix Framework"> - {assigns[:page_title]} + <.live_title default="Mila"> + {page_title_string(assigns)}