From a25263b7219104dec2b01e434969ccf69a62df82 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 17 Feb 2026 19:29:49 +0100 Subject: [PATCH 001/237] fix: adds user friendly flas message --- lib/mv_web/auth_overrides.ex | 7 ++ lib/mv_web/components/layouts/root.html.heex | 1 + lib/mv_web/controllers/auth_controller.ex | 93 ++++++++++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 10 ++ priv/gettext/default.pot | 10 ++ priv/gettext/en/LC_MESSAGES/default.po | 10 ++ .../controllers/auth_controller_test.exs | 44 +++++++++ 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 1367150..b121c4e 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do Gettext.gettext(MvWeb.Gettext, "or") end) end + + # Hide AshAuthentication's Flash component since we use flash_group in root layout + # This prevents duplicate flash messages + override AshAuthentication.Phoenix.Components.Flash do + set :message_class_info, "hidden" + set :message_class_error, "hidden" + end end diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 35e73ab..f6c3014 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -33,6 +33,7 @@ + <.flash_group flash={@flash} /> {@inner_content} diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 20a8b20..8249a10 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -57,7 +57,9 @@ defmodule MvWeb.AuthController do handle_authentication_failed(conn, caused_by) _ -> - redirect_with_error(conn, gettext("Incorrect email or password")) + conn + |> put_flash(:error, gettext("Incorrect email or password")) + |> redirect(to: ~p"/sign-in") end end @@ -74,14 +76,39 @@ defmodule MvWeb.AuthController do handle_oidc_email_collision(conn, errors) _ -> - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end end + # Handle Assent server unreachable errors (network/connectivity issues) + defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = err) do + # Use warning level: server unreachable is often transient, not a critical system error + Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) + + conn + |> put_flash(:error, gettext("The authentication server is currently unavailable. Please try again later.")) + |> redirect(to: ~p"/sign-in") + end + + # Handle Assent invalid response errors (configuration or malformed responses) + defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = err) do + # Use warning level: configuration errors are operational issues, not critical failures + Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) + + conn + |> put_flash(:error, gettext("Authentication configuration error. Please contact the administrator.")) + |> redirect(to: ~p"/sign-in") + end + # Catch-all clause for any other error types defp handle_rauthy_failure(conn, reason) do Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle generic AuthenticationFailed errors @@ -93,14 +120,20 @@ defmodule MvWeb.AuthController do You can confirm your account using the link we sent to you, or by resetting your password. """) - redirect_with_error(conn, message) + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") else - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end end defp handle_authentication_failed(conn, _other) do - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle OIDC email collision - user needs to verify password to link accounts @@ -112,7 +145,10 @@ defmodule MvWeb.AuthController do nil -> # Check if it's a "different OIDC account" error or email uniqueness error error_message = extract_meaningful_error_message(errors) - redirect_with_error(conn, error_message) + + conn + |> put_flash(:error, error_message) + |> redirect(to: ~p"/sign-in") end end @@ -177,13 +213,46 @@ defmodule MvWeb.AuthController do |> redirect(to: ~p"/auth/link-oidc-account") end - # Generic error redirect helper - defp redirect_with_error(conn, message) do - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + # Extract safe metadata from Assent errors for logging + # Never logs sensitive data: no tokens, secrets, or full request URLs + # Returns keyword list for Logger.warning/2 + defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do + [ + request_url: redact_url(url), + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end + defp safe_assent_meta(%{response: %{status_code: status_code}} = err) do + [ + status_code: status_code, + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + defp safe_assent_meta(err) do + # Only extract safe, simple fields + [ + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + # Redact URL to only show scheme and host, hiding path, query, and fragments + defp redact_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) -> + "#{scheme}://#{host}" + + _ -> + "[redacted]" + end + end + + defp redact_url(_), do: "[redacted]" + def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0187da6..527a849 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -582,6 +582,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa msgid "Unable to authenticate with OIDC. Please try again." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut." + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator." + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a05597c..8247f31 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -583,6 +583,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 261cbe4..bff719d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -583,6 +583,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 444571b..c177c96 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -248,4 +248,48 @@ defmodule MvWeb.AuthControllerTest do assert to =~ "/auth/user/password/sign_in_with_token" end + + # OIDC/Rauthy error handling tests + describe "handle_rauthy_failure/2" do + test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.ServerUnreachableError struct + error = %Assent.ServerUnreachableError{request_url: "https://auth.example.com/callback?token=secret123"} + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "The authentication server is currently unavailable. Please try again later." + end + + test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.InvalidResponseError struct + error = %Assent.InvalidResponseError{ + response: %{status_code: 400, body: "invalid_request"}, + request_url: "https://auth.example.com/callback" + } + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "Authentication configuration error. Please contact the administrator." + end + + test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + unknown_reason = :oops + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "Unable to authenticate with OIDC. Please try again." + end + end end From 002d723d0ea82831b2c84e1cd507f6bc18745b3d Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 12:53:25 +0100 Subject: [PATCH 002/237] fix: tests and flash layout --- lib/mv_web/components/layouts/root.html.heex | 39 ++++++++++++++++++- lib/mv_web/controllers/auth_controller.ex | 17 +++++--- priv/gettext/de/LC_MESSAGES/default.po | 3 ++ priv/gettext/default.pot | 3 ++ priv/gettext/en/LC_MESSAGES/default.po | 3 ++ .../controllers/auth_controller_test.exs | 35 ++++++++++++----- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index f6c3014..3ba099d 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -33,7 +33,44 @@ - <.flash_group flash={@flash} /> +
+ <.flash id="flash-success-root" kind={:success} flash={@flash} /> + <.flash id="flash-warning-root" kind={:warning} flash={@flash} /> + <.flash id="flash-info-root" kind={:info} flash={@flash} /> + <.flash id="flash-error-root" kind={:error} flash={@flash} /> + + <.flash + id="client-error-root" + kind={:error} + title={gettext("We can't find the internet")} + phx-disconnected={ + show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error-root" + kind={:error} + title={gettext("Something went wrong!")} + phx-disconnected={ + show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + +
{@inner_content} diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 8249a10..24d1078 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -88,7 +88,10 @@ defmodule MvWeb.AuthController do Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) conn - |> put_flash(:error, gettext("The authentication server is currently unavailable. Please try again later.")) + |> put_flash( + :error, + gettext("The authentication server is currently unavailable. Please try again later.") + ) |> redirect(to: ~p"/sign-in") end @@ -98,7 +101,10 @@ defmodule MvWeb.AuthController do Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) conn - |> put_flash(:error, gettext("Authentication configuration error. Please contact the administrator.")) + |> put_flash( + :error, + gettext("Authentication configuration error. Please contact the administrator.") + ) |> redirect(to: ~p"/sign-in") end @@ -224,10 +230,11 @@ defmodule MvWeb.AuthController do |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end - defp safe_assent_meta(%{response: %{status_code: status_code}} = err) do + # Handle InvalidResponseError which has :response field (HTTPResponse struct) + defp safe_assent_meta(%{response: %{status: status} = response} = err) do [ - status_code: status_code, - http_adapter: Map.get(err, :http_adapter) + status: status, + http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter) ] |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 527a849..9968f16 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -27,6 +27,7 @@ msgid "Are you sure?" msgstr "Bist du sicher?" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" @@ -115,11 +116,13 @@ msgid "Show" msgstr "Anzeigen" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 8247f31..a4bf53a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index bff719d..5ed6eb1 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index c177c96..5f53e0f 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -6,7 +6,10 @@ defmodule MvWeb.AuthControllerTest do # 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 - new_conn = build_conn() + new_conn = + build_conn() + |> init_test_session(%{}) + |> fetch_flash() # Copy sandbox metadata from authenticated conn if authenticated_conn.private[:ecto_sandbox] do @@ -255,29 +258,41 @@ defmodule MvWeb.AuthControllerTest do conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) - # Create a mock Assent.ServerUnreachableError struct - error = %Assent.ServerUnreachableError{request_url: "https://auth.example.com/callback?token=secret123"} + # Create a mock Assent.ServerUnreachableError struct with required fields + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123", + reason: %Mint.TransportError{reason: :econnrefused} + } conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "The authentication server is currently unavailable. Please try again later." + + assert get_flash(conn, :error) == + "The authentication server is currently unavailable. Please try again later." end test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) - # Create a mock Assent.InvalidResponseError struct + # Create a mock Assent.InvalidResponseError struct with required field + # InvalidResponseError only has :response field (HTTPResponse struct) error = %Assent.InvalidResponseError{ - response: %{status_code: 400, body: "invalid_request"}, - request_url: "https://auth.example.com/callback" + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } } conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "Authentication configuration error. Please contact the administrator." + + assert get_flash(conn, :error) == + "Authentication configuration error. Please contact the administrator." end test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ @@ -289,7 +304,9 @@ defmodule MvWeb.AuthControllerTest do conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "Unable to authenticate with OIDC. Please try again." + + assert get_flash(conn, :error) == + "Unable to authenticate with OIDC. Please try again." end end end From a40b430ed02f611952beeb32610470a47ffa71a6 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 18 Feb 2026 14:19:58 +0100 Subject: [PATCH 003/237] chore: try timeouts in drone tasks --- .drone.yml | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/.drone.yml b/.drone.yml index 2c8d504..f7e084f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -37,24 +37,25 @@ steps: - name: lint image: docker.io/library/elixir:1.18.3-otp-27 commands: + - set -euo pipefail # Install hex package manager - - mix local.hex --force + - timeout --signal=KILL 3m mix local.hex --force # Fetch dependencies - - mix deps.get + - timeout --signal=KILL 10m mix deps.get # Check for compilation errors & warnings - - mix compile --warnings-as-errors + - timeout --signal=KILL 10m mix compile --warnings-as-errors # Check formatting - - mix format --check-formatted + - timeout --signal=KILL 3m mix format --check-formatted # Security checks - - mix sobelow --config + - timeout --signal=KILL 10m mix sobelow --config # Check dependencies for known vulnerabilities - - mix deps.audit + - timeout --signal=KILL 10m mix deps.audit # Check for dependencies that are not maintained anymore - - mix hex.audit + - timeout --signal=KILL 10m mix hex.audit # Provide hints for improving code quality - - mix credo + - timeout --signal=KILL 15m mix credo # Check that translations are up to date - - mix gettext.extract --check-up-to-date + - timeout --signal=KILL 5m mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:18.1 @@ -79,12 +80,13 @@ steps: TEST_POSTGRES_HOST: postgres TEST_POSTGRES_PORT: 5432 commands: + - set -euo pipefail # Install hex package manager - mix local.hex --force # Fetch dependencies - - mix deps.get + - timeout --signal=KILL 10m mix deps.get # Run fast tests (excludes slow/performance and UI tests) - - mix test --exclude slow --exclude ui + - timeout --signal=KILL 20m mix test --exclude slow --exclude ui - name: rebuild-cache image: drillster/drone-volume-cache @@ -144,24 +146,25 @@ steps: - name: lint image: docker.io/library/elixir:1.18.3-otp-27 commands: + - set -euo pipefail # Install hex package manager - - mix local.hex --force + - timeout --signal=KILL 3m mix local.hex --force # Fetch dependencies - - mix deps.get + - timeout --signal=KILL 10m mix deps.get # Check for compilation errors & warnings - - mix compile --warnings-as-errors + - timeout --signal=KILL 10m mix compile --warnings-as-errors # Check formatting - - mix format --check-formatted + - timeout --signal=KILL 3m mix format --check-formatted # Security checks - - mix sobelow --config + - timeout --signal=KILL 10m mix sobelow --config # Check dependencies for known vulnerabilities - - mix deps.audit + - timeout --signal=KILL 10m mix deps.audit # Check for dependencies that are not maintained anymore - - mix hex.audit + - timeout --signal=KILL 10m mix hex.audit # Provide hints for improving code quality - - mix credo + - timeout --signal=KILL 15m mix credo # Check that translations are up to date - - mix gettext.extract --check-up-to-date + - timeout --signal=KILL 5m mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:18.1 @@ -186,12 +189,13 @@ steps: TEST_POSTGRES_HOST: postgres TEST_POSTGRES_PORT: 5432 commands: + - set -euo pipefail # Install hex package manager - mix local.hex --force # Fetch dependencies - - mix deps.get + - timeout --signal=KILL 10m mix deps.get # Run all tests (including slow/performance and UI tests) - - mix test + - timeout --signal=KILL 30m mix test - name: rebuild-cache image: drillster/drone-volume-cache From b952a30d3706831a569fa3c2e16a02400c97fe01 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 18 Feb 2026 14:36:42 +0100 Subject: [PATCH 004/237] chore: adjust to sh --- .drone.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.drone.yml b/.drone.yml index f7e084f..bb5ec1c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -37,7 +37,7 @@ steps: - name: lint image: docker.io/library/elixir:1.18.3-otp-27 commands: - - set -euo pipefail + - set -eu # Install hex package manager - timeout --signal=KILL 3m mix local.hex --force # Fetch dependencies @@ -80,7 +80,7 @@ steps: TEST_POSTGRES_HOST: postgres TEST_POSTGRES_PORT: 5432 commands: - - set -euo pipefail + - set -eu # Install hex package manager - mix local.hex --force # Fetch dependencies @@ -146,7 +146,7 @@ steps: - name: lint image: docker.io/library/elixir:1.18.3-otp-27 commands: - - set -euo pipefail + - set -eu # Install hex package manager - timeout --signal=KILL 3m mix local.hex --force # Fetch dependencies @@ -189,7 +189,7 @@ steps: TEST_POSTGRES_HOST: postgres TEST_POSTGRES_PORT: 5432 commands: - - set -euo pipefail + - set -eu # Install hex package manager - mix local.hex --force # Fetch dependencies From b5fc03e94f524c7e6c0759d87324f13bb34cde51 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:10:46 +0100 Subject: [PATCH 005/237] refactor --- lib/mv_web/controllers/auth_controller.ex | 78 +++++++++++-- .../controllers/auth_controller_test.exs | 104 +++++++++++++++++- 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 24d1078..fb760e6 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do - Generic authentication failures """ def failure(conn, activity, reason) do - Logger.warning( - "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" - ) + log_failure_safely(activity, reason) case {activity, reason} do {{:rauthy, _action}, reason} -> @@ -63,6 +61,63 @@ defmodule MvWeb.AuthController do end end + # Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities + defp log_failure_safely({:rauthy, _action} = activity, reason) do + # For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params + case reason do + %Assent.ServerUnreachableError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + %Assent.InvalidResponseError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + _ -> + # For other rauthy errors, log only error type, not full details + error_type = get_error_type(reason) + + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}" + ) + end + end + + defp log_failure_safely(activity, reason) do + # For non-rauthy activities, safe to log full reason + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" + ) + end + + # Extract safe error type identifier without sensitive data + defp get_error_type(%struct{}), do: "#{struct}" + defp get_error_type(atom) when is_atom(atom), do: inspect(atom) + defp get_error_type(_other), do: "[redacted]" + + # Format safe log message with metadata included in the message string + defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do + activity_str = "Activity: #{inspect(activity)}" + meta_str = format_meta_string(meta) + "#{base_message} - #{activity_str}#{meta_str}" + end + + defp format_meta_string([]), do: "" + defp format_meta_string(meta) when is_list(meta) do + parts = + Enum.map(meta, fn + {:request_url, url} -> "Request URL: #{url}" + {:status, status} -> "Status: #{status}" + {:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}" + _ -> nil + end) + |> Enum.filter(&(&1 != nil)) + + if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ") + end + # Handle all Rauthy (OIDC) authentication failures defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do handle_oidc_email_collision(conn, errors) @@ -83,9 +138,9 @@ defmodule MvWeb.AuthController do end # Handle Assent server unreachable errors (network/connectivity issues) - defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = err) do - # Use warning level: server unreachable is often transient, not a critical system error - Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) + defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash( @@ -96,9 +151,9 @@ defmodule MvWeb.AuthController do end # Handle Assent invalid response errors (configuration or malformed responses) - defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = err) do - # Use warning level: configuration errors are operational issues, not critical failures - Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) + defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash( @@ -109,8 +164,9 @@ defmodule MvWeb.AuthController do end # Catch-all clause for any other error types - defp handle_rauthy_failure(conn, reason) do - Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") + defp handle_rauthy_failure(conn, _reason) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 5f53e0f..bac46c8 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -2,6 +2,7 @@ defmodule MvWeb.AuthControllerTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest import Phoenix.ConnTest + import ExUnit.CaptureLog # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do @@ -269,7 +270,7 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "The authentication server is currently unavailable. Please try again later." end @@ -291,7 +292,7 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Authentication configuration error. Please contact the administrator." end @@ -305,8 +306,105 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to authenticate with OIDC. Please try again." end end + + # Logging security tests - ensure no sensitive data is logged + describe "failure/3 logging security" do + test "does not log full URL with query params for Assent.ServerUnreachableError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123&code=abc456", + reason: %Mint.TransportError{reason: :econnrefused} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log redacted URL (only scheme and host) + assert log =~ "https://auth.example.com" + # Should NOT log query parameters or tokens + refute log =~ "token=secret123" + refute log =~ "code=abc456" + refute log =~ "callback?token" + end + + test "does not log sensitive data for Assent.InvalidResponseError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.InvalidResponseError{ + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should not log full error struct with inspect + refute log =~ "Assent.InvalidResponseError" + end + + test "does not log full reason for unknown rauthy errors", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Simulate an error that might contain sensitive data + error_with_sensitive_data = %{ + token: "secret_token_123", + url: "https://example.com/callback?access_token=abc123", + error: :something_went_wrong + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should NOT log sensitive data + refute log =~ "secret_token_123" + refute log =~ "access_token=abc123" + refute log =~ "callback?access_token" + end + + test "logs full reason for non-rauthy activities (password auth)", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + reason = %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Forbidden{errors: []} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason) + end) + + # For non-rauthy activities, full reason is safe to log + assert log =~ "Authentication failure" + assert log =~ "password" + assert log =~ "AuthenticationFailed" + end + end end From d1fefcca7ddf10e4929c5c8b1b528eeed702555c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:18:26 +0100 Subject: [PATCH 006/237] formatting --- lib/mv_web/controllers/auth_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index fb760e6..d9690df 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -105,6 +105,7 @@ defmodule MvWeb.AuthController do end defp format_meta_string([]), do: "" + defp format_meta_string(meta) when is_list(meta) do parts = Enum.map(meta, fn From e47e266570d29f9f481e993b046d42309236f21c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:42:54 +0100 Subject: [PATCH 007/237] feat: type not editable --- lib/membership/custom_field.ex | 20 +++- .../live/custom_field_live/form_component.ex | 61 +++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 5 + priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 5 + .../custom_field_validation_test.exs | 98 +++++++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ab4ad60..a1f564e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) + - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters + - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update] + defaults [:read] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -68,6 +69,21 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end + update :update do + accept [:name, :description, :required, :show_in_overview] + require_atomic? false + + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :value_type) do + {:error, + field: :value_type, + message: "cannot be changed after creation"} + else + :ok + end + end + end + destroy :destroy_with_values do primary? true end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index b809a1a..9f61ba3 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types + - Select value type from supported types (only on create; immutable after creation) - Set required flag - Real-time validation @@ -44,15 +44,36 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> + <%= if @custom_field do %> + <%!-- Show value_type as read-only text when editing --%> +
+ +
+ {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} +
+ +
+ <% else %> + <%!-- Show value_type as select when creating --%> + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ + :one_of + ] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> + <% end %> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -85,8 +106,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} end @impl true @@ -94,7 +123,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6dbb732..0d661cf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,6 +2604,11 @@ msgstr "PDF" msgid "Import" msgstr "Import" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index df282f3..0aef1b3 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,3 +2604,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 56f897d..371a028 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,6 +2605,11 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d0711ad..e642d82 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields + - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end + + describe "value_type immutability" do + test "rejects attempt to change value_type after creation", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Attempt to update value_type to :integer + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + value_type: :integer + }) + |> Ash.update(actor: actor) + + # Verify error message contains expected text + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :string + end + + test "allows updating other fields while value_type remains unchanged", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: "Original description" + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Update other fields (name, description) without touching value_type + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + description: "Updated description" + }) + |> Ash.update(actor: actor) + + # Verify value_type remained unchanged + assert updated_custom_field.value_type == original_value_type + assert updated_custom_field.value_type == :string + # Verify other fields were updated + assert updated_custom_field.name == "updated_name" + assert updated_custom_field.description == "Updated description" + end + + test "rejects value_type change even when other fields are updated", %{actor: actor} do + # Create custom field with value_type :boolean + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :boolean + + # Attempt to update both name and value_type + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + value_type: :date + }) + |> Ash.update(actor: actor) + + # Verify error message + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged, but name was not updated either + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :boolean + assert reloaded.name == "test_field" + end + end end From 9b1aad884ee86cb50c32fda118786c88d65fd947 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 17:01:43 +0100 Subject: [PATCH 008/237] style: use same disabled field as for memberfield --- lib/membership/custom_field.ex | 4 +- .../live/custom_field_live/form_component.ex | 40 +++++++++++++------ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index a1f564e..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -75,9 +75,7 @@ defmodule Mv.Membership.CustomField do validate fn changeset, _context -> if Ash.Changeset.changing_attribute?(changeset, :value_type) do - {:error, - field: :value_type, - message: "cannot be changed after creation"} + {:error, field: :value_type, message: "cannot be changed after creation"} else :ok end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 9f61ba3..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -45,19 +45,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do <.input field={@form[:name]} type="text" label={gettext("Name")} /> <%= if @custom_field do %> - <%!-- Show value_type as read-only text when editing --%> -
- -
- {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} -
- + <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> +
+
+ +
<% else %> <%!-- Show value_type as select when creating --%> From 0333f9e722c37d42552798436201682f5a301a45 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 08:55:55 +0100 Subject: [PATCH 009/237] fix: tests failing in ci --- .../live/components/sort_header_component.ex | 7 -- lib/mv_web/live/member_live/index.ex | 81 ++++++++++--------- .../components/sort_header_component_test.exs | 4 +- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} - phx-target={@myself} data-testid={@field} > {@label} @@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - send(self(), {:sort, field_str}) - {:noreply, socket} - end - # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 59ee8f9..b370e9a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -163,6 +163,7 @@ defmodule MvWeb.MemberLive.Index do - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members + - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("delete", %{"id" => id}, socket) do @@ -305,6 +306,46 @@ defmodule MvWeb.MemberLive.Index do end end + @impl true + def handle_event("sort", %{"field" => field_str}, socket) do + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + + {new_field, new_order} = determine_new_sort(field, socket) + old_field = socket.assigns.sort_field + + socket = + socket + |> assign(:sort_field, new_field) + |> assign(:sort_order, new_order) + |> update_sort_components(old_field, new_field, new_order) + |> load_members() + |> update_selection_assigns() + + # URL sync - push_patch happens synchronously in the event handler + query_params = + build_query_params( + socket.assigns.query, + export_sort_field(socket.assigns.sort_field), + export_sort_order(socket.assigns.sort_order), + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + end + # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = @@ -329,50 +370,10 @@ defmodule MvWeb.MemberLive.Index do Handles messages from child components. ## Supported messages: - - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ - @impl true - def handle_info({:sort, field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - - {new_field, new_order} = determine_new_sort(field, socket) - old_field = socket.assigns.sort_field - - socket = - socket - |> assign(:sort_field, new_field) - |> assign(:sort_order, new_order) - |> update_sort_components(old_field, new_field, new_order) - |> load_members() - |> update_selection_assigns() - - # URL sync - query_params = - build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} - end @impl true def handle_info({:search_changed, q}, socket) do diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 6d23ab4..bdde4ae 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking sends sort message to parent", %{conn: conn} do + test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component should send a message to the parent LiveView + # The component triggers a "sort" event on the parent LiveView # This is tested indirectly through the URL change in integration tests end From 0fd1b7e142e4948bd445126e235e22b1fff08164 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 09:40:02 +0100 Subject: [PATCH 010/237] fix testsand load performance --- lib/mv_web/live/member_live/index.ex | 11 ++++------- test/mv_web/member_live/index_test.exs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b370e9a..d391cd2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) - custom_fields_visible = - Mv.Membership.CustomField - |> Ash.Query.filter(expr(show_in_overview == true)) - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: actor) - - # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + custom_fields_visible = + all_custom_fields + |> Enum.filter(& &1.show_in_overview) + # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 4f36795..53a2815 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query From 3491b4b1ba357c8aaa7cde76398744c00959c5c8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:14 +0100 Subject: [PATCH 011/237] chore: set max cases testing for drone lower --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 2c8d504..9eb78f0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -84,7 +84,7 @@ steps: # Fetch dependencies - mix deps.get # Run fast tests (excludes slow/performance and UI tests) - - mix test --exclude slow --exclude ui + - mix test --exclude slow --exclude ui --max-cases 2 - name: rebuild-cache image: drillster/drone-volume-cache From cbed65de669ac670ebcf5a714fd3798325356708 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:29 +0100 Subject: [PATCH 012/237] feat: fix light dark mode issue --- assets/css/app.css | 2 +- lib/mv_web/components/layouts/root.html.heex | 60 +++++++++++++++---- lib/mv_web/components/layouts/sidebar.ex | 17 ++++-- .../components/layouts/sidebar_test.exs | 12 ++-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index b754a08..0219e1e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -24,7 +24,7 @@ @plugin "../vendor/daisyui-theme" { name: "dark"; default: false; - prefersdark: true; + prefersdark: false; color-scheme: "dark"; --color-base-100: oklch(30.33% 0.016 252.42); --color-base-200: oklch(25.26% 0.014 253.1); diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 35e73ab..c05d2c0 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -15,20 +15,56 @@ diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index 8ed7f03..7d4cce6 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -248,12 +248,17 @@ defmodule MvWeb.Layouts.Sidebar do aria-label={gettext("Toggle dark mode")} > <.icon name="hero-sun" class="size-5" aria-hidden="true" /> - +
+ +
+ <.icon name="hero-moon" class="size-5" aria-hidden="true" /> """ diff --git a/test/mv_web/components/layouts/sidebar_test.exs b/test/mv_web/components/layouts/sidebar_test.exs index ff81f24..325f19e 100644 --- a/test/mv_web/components/layouts/sidebar_test.exs +++ b/test/mv_web/components/layouts/sidebar_test.exs @@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do refute html =~ ~s(role="menuitem") # Footer section should not be rendered - refute html =~ "theme-controller" + refute html =~ "data-theme-toggle" refute html =~ "locale-select" end @@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do # Check for language selector form assert html =~ ~s(action="/set_locale") - # Check for theme toggle - assert has_class?(html, "theme-controller") + # Check for theme toggle (using data attribute instead of class) + assert html =~ "data-theme-toggle" # Check for user menu/avatar assert has_class?(html, "avatar") @@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do assert html =~ ~s(role="group") # Footer section - assert html =~ "theme-controller" + assert html =~ "data-theme-toggle" assert html =~ ~s(action="/set_locale") # Check that critical navigation exists (at least /members) @@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do test "renders theme toggle" do html = render_sidebar(authenticated_assigns()) - # Toggle is always visible - assert has_class?(html, "theme-controller") + # Toggle is always visible (using data attribute instead of class) + assert html =~ "data-theme-toggle" assert html =~ "hero-sun" assert html =~ "hero-moon" end From 01f62297fce5197230744c661f8f36f9b1334b41 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 14:36:35 +0100 Subject: [PATCH 013/237] feat: add groups to export --- lib/mv/membership/member_export.ex | 8 +++- lib/mv/membership/member_export/build.ex | 39 ++++++++++++++++++- lib/mv/membership/members_csv.ex | 14 +++++++ .../controllers/member_export_controller.ex | 33 +++++++++++++++- lib/mv_web/live/member_live/index.ex | 9 ++++- lib/mv_web/translations/member_fields.ex | 1 + 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e243d40..b4272b0 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["membership_fee_status"] + ["membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do |> Enum.filter(&(&1 in @domain_member_field_strings)) |> order_member_fields_like_table() - # final member_fields list (used for column specs order): table order + computed inserted + # Separate groups from other fields (groups is handled as a special field, not a member field) + groups_field = if "groups" in member_fields, do: ["groups"], else: [] + + # final member_fields list (used for column specs order): table order + computed inserted + groups ordered_member_fields = selectable_member_fields |> insert_computed_fields_like_table(computed_fields) + |> then(fn fields -> fields ++ groups_field end) %{ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ce1e98c..323681d 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do parsed.computed_fields != [] or "membership_fee_status" in parsed.member_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(custom_field_ids_union) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -294,6 +297,13 @@ defmodule Mv.Membership.MemberExport.Build do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do @@ -343,6 +353,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + key: :groups, + kind: :groups, + label: label_fn.(:groups) + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -361,7 +384,7 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -391,6 +414,11 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -424,6 +452,15 @@ defmodule Mv.Membership.MemberExport.Build do defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end + defp build_meta(members) do %{ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a0fd463..a47af8d 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 009a985..911e6d9 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do alias MvWeb.MemberLive.Index.MembershipFeeStatus use Gettext, backend: MvWeb.Gettext - @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + # "groups" is neither a domain field nor a computed field, it's handled separately {selectable, computed} end @@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do need_cycles = parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + # Adds computed field values to members (e.g. membership_fee_status) defp add_computed_fields(members, computed_fields, show_current_cycle) do if "membership_fee_status" in computed_fields do @@ -441,6 +453,19 @@ defmodule MvWeb.MemberExportController do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + header: groups_field_header(conn), + kind: :groups, + key: :groups + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -459,7 +484,7 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -499,6 +524,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp groups_field_header(_conn) do + MemberFields.label(:groups) + end + defp humanize_field(str) do str |> String.replace("_", " ") diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index d391cd2..eef871f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1620,6 +1620,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) + # Groups is always included in export if it's visible in the table + # (groups column is always shown in the table, so we always include it in export) + member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = socket.assigns.all_custom_fields @@ -1628,7 +1632,10 @@ defmodule MvWeb.MemberLive.Index do %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), - member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1), + member_fields: Enum.map(member_fields_with_groups, fn + f when is_atom(f) -> Atom.to_string(f) + f when is_binary(f) -> f + end), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, column_order: diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 83ab139..c9b8cad 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") + def label(:groups), do: gettext("Groups") # Fallback for unknown fields def label(field) do From dbdac5870a0717710c8bdba35b9ced8bb898d157 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:21 +0100 Subject: [PATCH 014/237] fix: adds shoe/hide for group column --- lib/mv_web/live/member_live/index.ex | 79 ++++++++++++++----- lib/mv_web/live/member_live/index.html.heex | 1 + .../member_live/index/field_visibility.ex | 5 +- priv/gettext/de/LC_MESSAGES/default.po | 1 + priv/gettext/default.pot | 1 + priv/gettext/en/LC_MESSAGES/default.po | 1 + 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index eef871f..526f858 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -682,6 +682,18 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # Update sort components after rendering + socket = + if socket.assigns[:sort_needs_update] do + old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket + |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) + |> assign(:sort_needs_update, false) + |> assign(:previous_sort_field, nil) + else + socket + end + {:noreply, socket} end @@ -940,9 +952,10 @@ defmodule MvWeb.MemberLive.Index do ) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) + # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status members = if sort_after_load and - socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do + socket.assigns.sort_field != :membership_fee_status do sort_members_in_memory( members, socket.assigns.sort_field, @@ -1044,7 +1057,9 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) do - if computed_field?(field) do + # :groups is in computed_member_fields() but can be sorted in-memory + # Only :membership_fee_status should be blocked from sorting + if field == :membership_fee_status or field == "membership_fee_status" do {query, false} else apply_sort_to_query(query, field, order) @@ -1086,13 +1101,19 @@ defmodule MvWeb.MemberLive.Index do end defp valid_sort_field?(field) when is_atom(field) do - if field in FieldVisibility.computed_member_fields(), - do: false, - else: valid_sort_field_db_or_custom?(field) + # :groups is in computed_member_fields() but can be sorted + # Only :membership_fee_status should be blocked + if field == :membership_fee_status do + false + else + valid_sort_field_db_or_custom?(field) + end end defp valid_sort_field?(field) when is_binary(field) do - if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do + # "groups" is in computed_member_fields() but can be sorted + # Only "membership_fee_status" should be blocked + if field == "membership_fee_status" do false else valid_sort_field_db_or_custom?(field) @@ -1249,10 +1270,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) + old_field = socket.assigns.sort_field socket |> assign(:sort_field, field) |> assign(:sort_order, order) + |> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order) + |> assign(:previous_sort_field, old_field) end defp maybe_update_sort(socket, _), do: socket @@ -1261,17 +1285,27 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, nil), do: default defp determine_field(default, sf) when is_binary(sf) do - computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) + # Handle "groups" specially - it's in computed_member_fields() but can be sorted + if sf == "groups" do + :groups + else + computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) - if sf in computed_strings, - do: default, - else: determine_field_after_computed_check(default, sf) + if sf in computed_strings, + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, sf) when is_atom(sf) do - if sf in FieldVisibility.computed_member_fields(), - do: default, - else: determine_field_after_computed_check(default, sf) + # Handle :groups specially - it's in computed_member_fields() but can be sorted + if sf == :groups do + :groups + else + if sf in FieldVisibility.computed_member_fields(), + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, _), do: default @@ -1620,9 +1654,13 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) - # Groups is always included in export if it's visible in the table - # (groups column is always shown in the table, so we always include it in export) - member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # Include groups in export only if it's visible in the table + member_fields_with_groups = + if :groups in socket.assigns[:member_fields_visible] do + ordered_member_fields_db ++ ["groups"] + else + ordered_member_fields_db + end # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = @@ -1632,10 +1670,11 @@ defmodule MvWeb.MemberLive.Index do %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), - member_fields: Enum.map(member_fields_with_groups, fn - f when is_atom(f) -> Atom.to_string(f) - f when is_binary(f) -> f - end), + member_fields: + Enum.map(member_fields_with_groups, fn + f when is_atom(f) -> Atom.to_string(f) + f when is_binary(f) -> f + end), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, column_order: diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index f8be88d..fa0f43a 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -331,6 +331,7 @@ <:col :let={member} + :if={:groups in @member_fields_visible} label={ ~H""" <.live_component diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 0b0cb67..6427d4c 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do alias Mv.Membership.Helpers.VisibilityConfig # Single UI key for "Membership Fee Status"; only this appears in the dropdown. - @pseudo_member_fields [:membership_fee_status] + # Groups is also a pseudo field (not a DB attribute, but displayed in the table). + @pseudo_member_fields [:membership_fee_status, :groups] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do """ @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do - computed_set = MapSet.new(@pseudo_member_fields) + computed_set = MapSet.new([:membership_fee_status]) field_selection |> Enum.filter(fn {field_string, visible} -> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d661cf..c7e438a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2205,6 +2205,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0aef1b3..0ad9170 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 371a028..bfa6bcc 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" From cb932ad6ef56f77513872f1e26603fc27732060e Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:55 +0100 Subject: [PATCH 015/237] feat: respects sorting groups for export --- lib/mv/membership/member_export/build.ex | 68 ++++++++++---- .../controllers/member_export_controller.ex | 93 ++++++++++++------- lib/mv_web/live/member_live/index.ex | 7 -- 3 files changed, 111 insertions(+), 57 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 323681d..ff8cf76 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -244,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -263,21 +269,45 @@ defmodule Mv.Membership.MemberExport.Build do do: [] defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + if field == "groups" do + sort_members_by_groups_export(members, order) + else + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - if is_nil(custom_field), do: members + if is_nil(custom_field), do: members - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + end + + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) end members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) end defp find_cfv(member, custom_field) do diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 911e6d9..4ed8f2d 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -341,17 +341,23 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - # Custom field sort → in-memory nach dem Read (wie Tabelle) - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + # Custom field sort → in-memory nach dem Read (wie Tabelle) + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -370,35 +376,60 @@ defmodule MvWeb.MemberExportController do defp sort_members_by_custom_field_export(members, field, order, custom_fields) when is_binary(field) do order = order || "asc" - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field) do - members + if field == "groups" do + sort_members_by_groups_export(members, order) else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + id_str = String.trim_leading(field, @custom_field_prefix) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values ++ without_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) + + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values + end end end + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) + end + defp has_non_empty_custom_field_value?(member, custom_field) do case find_cfv(member, custom_field) do nil -> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 526f858..9d93b08 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1066,13 +1066,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp computed_field?(field) do - computed_atoms = FieldVisibility.computed_member_fields() - computed_strings = Enum.map(computed_atoms, &Atom.to_string/1) - - (is_atom(field) and field in computed_atoms) or - (is_binary(field) and field in computed_strings) - end defp apply_sort_to_query(query, field, order) do cond do From 397f7a7975bc1ffd8d2bad816a92d43343be2dc3 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 09:16:38 +0100 Subject: [PATCH 016/237] fix linting --- lib/mv/membership/member_export/build.ex | 40 ++++++++++------ .../controllers/member_export_controller.ex | 48 ++++++++++--------- lib/mv_web/live/member_live/index.ex | 2 +- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ff8cf76..9e0cc7b 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -272,24 +272,34 @@ defmodule Mv.Membership.MemberExport.Build do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field), do: members - - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) - end - - members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + sort_by_custom_field_value(members, field, order, custom_fields) end end + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + + if is_nil(custom_field) do + members + else + sort_members_with_custom_field(members, custom_field, order) + end + end + + defp sort_members_with_custom_field(members, custom_field, order) do + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + defp sort_members_by_groups_export(members, order) do # Members with groups first, then by first group name alphabetically (min = first by sort order) # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 4ed8f2d..08bcba7 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -380,33 +380,37 @@ defmodule MvWeb.MemberExportController do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) + sort_by_custom_field_value(members, field, order, custom_fields) + end + end - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) - if is_nil(custom_field) do - members - else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) - sorted_with_values ++ without_values - end + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values end end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 9d93b08..218fa6f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -686,6 +686,7 @@ defmodule MvWeb.MemberLive.Index do socket = if socket.assigns[:sort_needs_update] do old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) |> assign(:sort_needs_update, false) @@ -1066,7 +1067,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp apply_sort_to_query(query, field, order) do cond do # Groups sort -> after load (in memory) From f4554b8a4bd2b8587f0a0b5211220c128e8a1069 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 14:01:14 +0100 Subject: [PATCH 017/237] docs: update Code Guidelines with issues from meta review analysis --- CODE_GUIDELINES.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index cc58ca9..70e1596 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)} ### 2.3 Error Handling +**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging. + **Use Tagged Tuples:** ```elixir @@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do end ``` +**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle. + +**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes. + **Component Design:** ```elixir @@ -1267,6 +1273,9 @@ gettext("Welcome to Mila") # With interpolation gettext("Hello, %{name}!", name: user.name) +# Plural: always pass count binding when message uses %{count} +ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) + # Domain-specific translations dgettext("auth", "Sign in with email") ``` @@ -1507,6 +1516,8 @@ defmodule MvWeb.MemberLive.IndexTest do end ``` +**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing). + #### 4.3.5 Component Tests Test function components: @@ -1876,6 +1887,8 @@ policies do end ``` +**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth. + **Actor Handling in LiveViews:** Always use the `current_actor/1` helper for consistent actor access: @@ -2707,7 +2720,9 @@ Building accessible applications ensures that all users, including those with di ### 8.2 ARIA Labels and Roles -**Use ARIA Attributes When Necessary:** +**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs. + +**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide. ```heex @@ -2931,11 +2946,11 @@ end **Announce Dynamic Content:** ```heex - +
<%= if @searched do %> - <%= ngettext("Found %{count} member", "Found %{count} members", @count) %> + <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %> <% end %>
From ec814a8c94fc245de6de21a93c8135596bbb8b9f Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:09:37 +0100 Subject: [PATCH 018/237] refactor: remove db read on focus for groups view --- lib/mv_web/live/group_live/show.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0251fb6..d9a5d98 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -431,11 +431,7 @@ defmodule MvWeb.GroupLive.Show do # Add Member Events @impl true def handle_event("show_add_member_input", _params, socket) do - # Reload group to ensure we have the latest members list - actor = current_actor(socket) - group = socket.assigns.group - socket = reload_group(socket, group.slug, actor) - + # Use existing @group from assigns; no DB read on focus. Reload only on commit (add/remove). {:noreply, socket |> assign(:show_add_member_input, true) From 83b104ecf38fbf15e8ebf3846d9140d6f409b130 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:56:12 +0100 Subject: [PATCH 019/237] refactor: when adding group members, search in-memory on typing --- CODE_GUIDELINES.md | 2 + lib/mv_web/live/group_live/show.ex | 120 ++++++++++++++++--------- priv/gettext/de/LC_MESSAGES/default.po | 23 +---- priv/gettext/default.pot | 10 +-- priv/gettext/en/LC_MESSAGES/default.po | 23 +---- 5 files changed, 93 insertions(+), 85 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 70e1596..439eee8 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1264,6 +1264,8 @@ end ### 3.12 Internationalization: Gettext +**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”. + **Define Translations:** ```elixir diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index d9a5d98..dc0a922 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do require Logger + import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Membership + alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers @impl true def mount(_params, _session, socket) do @@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) + |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) @@ -94,12 +97,12 @@ defmodule MvWeb.GroupLive.Show do
- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> {gettext("Edit")} <% end %> - <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> + <%= if can?(@current_user, :destroy, @group) do %> <.button class="btn-error" phx-click="open_delete_modal"> {gettext("Delete")} @@ -132,7 +135,7 @@ defmodule MvWeb.GroupLive.Show do )}

- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %>
<%= if assigns[:show_add_member_input] do %>
@@ -263,7 +266,7 @@ defmodule MvWeb.GroupLive.Show do {gettext("Name")} {gettext("Email")} - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> {gettext("Actions")} <% end %> @@ -291,7 +294,7 @@ defmodule MvWeb.GroupLive.Show do <% end %> - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> - - +
+ + + +
<% end %> <%= if @can_destroy_cycle do %>
- <%!-- Examples Card --%> + <%!-- Examples Card (collapsible) --%>
-

- <.icon name="hero-light-bulb" class="size-5" /> - {gettext("Examples")} -

+
+ + <.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" /> + <.icon name="hero-light-bulb" class="size-5" /> + {gettext("Examples")} + - <.example_section - title={gettext("Yearly Interval - Joining Cycle Included")} - joining_date="15.03.2023" - include_joining={true} - start_date="01.01.2023" - periods={["2023", "2024", "2025"]} - note={gettext("Member pays for the year they joined")} - /> +
+ <.example_section + title={gettext("Yearly Interval - Joining Cycle Included")} + joining_date="15.03.2023" + include_joining={true} + start_date="01.01.2023" + periods={["2023", "2024", "2025"]} + note={gettext("Member pays for the year they joined")} + /> -
+
- <.example_section - title={gettext("Yearly Interval - Joining Cycle Excluded")} - joining_date="15.03.2023" - include_joining={false} - start_date="01.01.2024" - periods={["2024", "2025"]} - note={gettext("Member pays from the next full year")} - /> + <.example_section + title={gettext("Yearly Interval - Joining Cycle Excluded")} + joining_date="15.03.2023" + include_joining={false} + start_date="01.01.2024" + periods={["2024", "2025"]} + note={gettext("Member pays from the next full year")} + /> -
+
- <.example_section - title={gettext("Quarterly Interval - Joining Cycle Excluded")} - joining_date="15.05.2024" - include_joining={false} - start_date="01.07.2024" - periods={["Q3/2024", "Q4/2024", "Q1/2025"]} - note={gettext("Member pays from the next full quarter")} - /> + <.example_section + title={gettext("Quarterly Interval - Joining Cycle Excluded")} + joining_date="15.05.2024" + include_joining={false} + start_date="01.07.2024" + periods={["Q3/2024", "Q4/2024", "Q1/2025"]} + note={gettext("Member pays from the next full quarter")} + /> -
+
- <.example_section - title={gettext("Monthly Interval - Joining Cycle Included")} - joining_date="15.03.2024" - include_joining={true} - start_date="01.03.2024" - periods={["03/2024", "04/2024", "05/2024", "..."]} - note={gettext("Member pays from the joining month")} - /> + <.example_section + title={gettext("Monthly Interval - Joining Cycle Included")} + joining_date="15.03.2024" + include_joining={true} + start_date="01.03.2024" + periods={["03/2024", "04/2024", "05/2024", "..."]} + note={gettext("Member pays from the joining month")} + /> +
+
+ + <%!-- Fee Types Table --%> +
+

{gettext("Membership Fee Types")}

+ <.table + id="membership_fee_types" + rows={@membership_fee_types} + row_id={fn mft -> "mft-#{mft.id}" end} + > + <:col :let={mft} label={gettext("Name")}> + {mft.name} +

{mft.description}

+ + + <:col :let={mft} label={gettext("Amount")}> + {MembershipFeeHelpers.format_currency(mft.amount)} + + + <:col :let={mft} label={gettext("Interval")}> + + {MembershipFeeHelpers.format_interval(mft.interval)} + + + + <:col :let={mft} label={gettext("Members")}> + {get_member_count(mft, @member_counts)} + + + <:action :let={mft}> + <.link + navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} + class="btn btn-ghost btn-xs" + aria-label={gettext("Edit membership fee type")} + > + <.icon name="hero-pencil" class="size-4" /> + + + + <:action :let={mft}> +
0} + class="tooltip tooltip-left" + data-tip={ + gettext("Cannot delete - %{count} member(s) assigned", + count: get_member_count(mft, @member_counts) + ) + } + > + +
+ + + + +
+ + <.icon name="hero-information-circle" class="size-5" /> + {gettext("About Membership Fee Types")} + +
+

+ {gettext( + "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." + )} +

+
    +
  • + {gettext("Name & Amount")} + - {gettext("Can be changed at any time. Amount changes affect future periods only.")} +
  • +
  • + {gettext("Interval")} + - {gettext( + "Fixed after creation. Members can only switch between types with the same interval." + )} +
  • +
  • + {gettext("Deletion")} + - {gettext("Only possible if no members are assigned to this type.")} +
  • +
+
+
+
""" end @@ -286,6 +456,32 @@ defmodule MvWeb.MembershipFeeSettingsLive do defp format_interval(:half_yearly), do: gettext("Half-yearly") defp format_interval(:yearly), do: gettext("Yearly") + defp load_member_counts(fee_types, actor) do + fee_type_ids = Enum.map(fee_types, & &1.id) + + members = + Member + |> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids) + |> Ash.Query.select([:membership_fee_type_id]) + |> Ash.read!(domain: Membership, actor: actor) + + members + |> Enum.group_by(& &1.membership_fee_type_id) + |> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end) + |> Map.new() + end + + defp get_member_count(fee_type, member_counts) do + Map.get(member_counts, fee_type.id, 0) + end + + defp format_error(%Ash.Error.Invalid{} = error) do + Enum.map_join(error.errors, ", ", fn e -> e.message end) + end + + defp format_error(error) when is_binary(error), do: error + defp format_error(_error), do: gettext("An error occurred") + defp assign_form(%{assigns: %{settings: settings}} = socket) do form = AshPhoenix.Form.for_update( diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index 6fe80a8..d8569e2 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -384,7 +384,8 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do defp format_interval_value(value), do: to_string(value) @spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t() - defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types" + defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_settings" + defp return_path(_, _), do: ~p"/membership_fee_settings" @spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer() # Checks if amount changed and updates socket assigns accordingly diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index 84cd26d..f5f760f 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -47,7 +47,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do {gettext("Manage membership fee types for membership fees.")} <:actions> - <.button variant="primary" navigate={~p"/membership_fee_types/new"}> + <.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}> <.icon name="hero-plus" /> {gettext("New Membership Fee Type")} @@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do <:action :let={mft}> <.link - navigate={~p"/membership_fee_types/#{mft.id}/edit"} + navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} class="btn btn-ghost btn-xs" aria-label={gettext("Edit membership fee type")} > diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex index 2720c0f..551cada 100644 --- a/lib/mv_web/page_paths.ex +++ b/lib/mv_web/page_paths.ex @@ -8,30 +8,30 @@ defmodule MvWeb.PagePaths do # Sidebar top-level menu paths @members "/members" - @membership_fee_types "/membership_fee_types" @statistics "/statistics" # Administration submenu paths (all must match router) @users "/users" @groups "/groups" @admin_roles "/admin/roles" + @admin_datafields "/admin/datafields" @membership_fee_settings "/membership_fee_settings" + @admin_import "/admin/import" @settings "/settings" @admin_page_paths [ @users, @groups, @admin_roles, + @admin_datafields, @membership_fee_settings, + @admin_import, @settings ] @doc "Path for Members index (sidebar and page permission check)." def members, do: @members - @doc "Path for Membership Fee Types index (sidebar and page permission check)." - def membership_fee_types, do: @membership_fee_types - @doc "Path for Statistics page (sidebar and page permission check)." def statistics, do: @statistics @@ -41,6 +41,8 @@ defmodule MvWeb.PagePaths do def users, do: @users def groups, do: @groups def admin_roles, do: @admin_roles + def admin_datafields, do: @admin_datafields def membership_fee_settings, do: @membership_fee_settings + def admin_import, do: @admin_import def settings, do: @settings end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index ec90f1b..9e39937 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -68,16 +68,13 @@ defmodule MvWeb.Router do live "/settings", GlobalSettingsLive - # Membership Fee Settings + # Membership Fee Settings (includes fee types list; new/edit under sub-routes) live "/membership_fee_settings", MembershipFeeSettingsLive - - # Membership Fee Types Management - live "/membership_fee_types", MembershipFeeTypeLive.Index, :index + live "/membership_fee_settings/new_fee_type", MembershipFeeTypeLive.Form, :new + live "/membership_fee_settings/:id/edit_fee_type", MembershipFeeTypeLive.Form, :edit # Statistics live "/statistics", StatisticsLive, :index - live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new - live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit # Groups Management live "/groups", GroupLive.Index, :index @@ -91,6 +88,9 @@ defmodule MvWeb.Router do live "/admin/roles/:id", RoleLive.Show, :show live "/admin/roles/:id/edit", RoleLive.Form, :edit + # Datafields (member fields + custom fields) + live "/admin/datafields", DatafieldsLive + # Import (Admin only) live "/admin/import", ImportLive diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c0672c5..f70d343 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -18,6 +18,7 @@ msgid "Actions" msgstr "Aktionen" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -322,6 +323,7 @@ msgstr "Benutzer*innen auflisten" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -335,6 +337,7 @@ msgstr "Mitglieder" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -382,7 +385,6 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -842,6 +844,7 @@ msgid "Create Member" msgstr "Mitglied erstellen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -853,11 +856,13 @@ msgstr "Betrag" msgid "Back to Settings" msgstr "Zurück zu den Einstellungen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" @@ -868,6 +873,7 @@ msgstr "Löschen" msgid "Examples" msgstr "Beispiele" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -886,6 +892,7 @@ msgid "Half-yearly" msgstr "Halbjährlich" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -924,11 +931,13 @@ msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr" msgid "Monthly" msgstr "Monatlich" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "Name & Betrag" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1002,7 +1011,7 @@ msgstr "Alle auswählen" msgid "Select none" msgstr "Keine auswählen" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen." @@ -1044,11 +1053,6 @@ msgstr "Textfeld" msgid "Yes/No-Selection" msgstr "Ja/Nein-Auswahl" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Memberdata" -msgstr "Mitgliederdaten" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -1060,7 +1064,7 @@ msgstr "Optional" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" msgstr "Mitgliedsfeld wurde erfolgreich %{action}" @@ -1070,6 +1074,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}" msgid "A cycle for this period already exists" msgstr "Ein Zyklus für diesen Zeitraum existiert bereits" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1086,6 +1091,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag." #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1097,6 +1103,7 @@ msgstr "Ein Fehler ist aufgetreten" msgid "Are you sure you want to delete this cycle?" msgstr "Möchtest du diesen Zyklus wirklich löschen?" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Cannot delete - %{count} member(s) assigned" @@ -1117,11 +1124,6 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)." msgid "Click to edit amount" msgstr "Klicke, um den Betrag zu bearbeiten" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Configure global settings for membership fees." -msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1232,6 +1234,7 @@ msgstr "Feld bearbeiten: %{field}" msgid "Edit Membership Fee Type" msgstr "Mitgliedsbeitragsart bearbeiten" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit membership fee type" @@ -1330,6 +1333,7 @@ msgstr "Mitgliedsbeitragsstatus" msgid "Membership Fee Type" msgstr "Mitgliedsbeitragsart" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership Fee Types" @@ -1346,6 +1350,7 @@ msgstr "Mitgliedsbeiträge" msgid "Membership fee start" msgstr "Beitragsbeginn" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" @@ -1366,6 +1371,7 @@ msgstr "Mitgliedsbeitragsart erfolgreich gespeichert" msgid "Membership fee type updated. Cycles regenerated." msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." @@ -1376,6 +1382,7 @@ msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstruktur msgid "Monthly Interval - Joining Cycle Included" msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1550,6 +1557,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" msgid "You are about to delete all %{count} cycles for this member." msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete Membership Fee Type" @@ -1571,12 +1579,12 @@ msgstr "Spalten ein-/ausblenden" msgid "Back to settings" msgstr "Zurück zu den Einstellungen" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field %{action} successfully" msgstr "Datenfeld erfolgreich %{action}" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field deleted successfully" msgstr "Datenfeld erfolgreich gelöscht" @@ -1591,7 +1599,7 @@ msgstr "Datenfeld löschen" msgid "Edit Data Field" msgstr "Datenfeld bearbeiten" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete data field: %{error}" msgstr "Konnte Datenfeld nicht löschen: %{error}" @@ -1823,6 +1831,7 @@ msgstr "Zyklus löschen" msgid "The cycle period will be calculated based on this date and the interval." msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" @@ -1843,6 +1852,7 @@ msgstr "Benutzer*in erfolgreich gelöscht" msgid "User not found" msgstr "Benutzer*in nicht gefunden" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" @@ -1853,6 +1863,7 @@ msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" msgid "You do not have permission to access this user" msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" @@ -1924,16 +1935,6 @@ msgstr "E-Mail ist erforderlich." msgid "Roles" msgstr "Rollen" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Fee Settings" -msgstr "Beitragseinstellungen" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "Beitragstypen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2272,7 +2273,7 @@ msgstr "Dieser Benutzer kann nicht angezeigt werden." msgid "Not authorized." msgstr "Nicht berechtigt." -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." @@ -2423,6 +2424,7 @@ msgstr "Beitragsart auswählen" msgid "Linked" msgstr "Verknüpft" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2975,3 +2977,109 @@ msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Beitragsart" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "Admin-Gruppenname" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "Basis-URL" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "Grundeinstellungen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "Client-ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "Client-Geheimnis" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings and fee types for membership fees." +msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren." + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom fields" +msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "Datenfelder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "Aus OIDC_ADMIN_GROUP_NAME" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "Aus OIDC_BASE_URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "Aus OIDC_CLIENT_ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "Aus OIDC_CLIENT_SECRET" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "Aus OIDC_GROUPS_CLAIM" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "Aus OIDC_REDIRECT_URI" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Groups claim" +msgstr "Gruppen" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member fields" +msgstr "Mitgliedsfilter" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Membership fee settings" +msgstr "Beitragseinstellungen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "Weiterleitungs-URI" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save OIDC Settings" +msgstr "Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "z. B. admin" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index e26b874..5347731 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -19,6 +19,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -323,6 +324,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -336,6 +338,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -383,7 +386,6 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -843,6 +845,7 @@ msgid "Create Member" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -854,11 +857,13 @@ msgstr "" msgid "Back to Settings" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Deletion" @@ -869,6 +874,7 @@ msgstr "" msgid "Examples" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -887,6 +893,7 @@ msgid "Half-yearly" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -925,11 +932,13 @@ msgstr "" msgid "Monthly" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1003,7 +1012,7 @@ msgstr "" msgid "Select none" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "" @@ -1045,11 +1054,6 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Memberdata" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -1061,7 +1065,7 @@ msgstr "" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" msgstr "" @@ -1071,6 +1075,7 @@ msgstr "" msgid "A cycle for this period already exists" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1087,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1098,6 +1104,7 @@ msgstr "" msgid "Are you sure you want to delete this cycle?" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Cannot delete - %{count} member(s) assigned" @@ -1118,11 +1125,6 @@ msgstr "" msgid "Click to edit amount" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership fees." -msgstr "" - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1233,6 +1235,7 @@ msgstr "" msgid "Edit Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Edit membership fee type" @@ -1331,6 +1334,7 @@ msgstr "" msgid "Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership Fee Types" @@ -1347,6 +1351,7 @@ msgstr "" msgid "Membership fee start" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" @@ -1367,6 +1372,7 @@ msgstr "" msgid "Membership fee type updated. Cycles regenerated." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." @@ -1377,6 +1383,7 @@ msgstr "" msgid "Monthly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1551,6 +1558,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Delete Membership Fee Type" @@ -1572,12 +1580,12 @@ msgstr "" msgid "Back to settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Data field %{action} successfully" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Data field deleted successfully" msgstr "" @@ -1592,7 +1600,7 @@ msgstr "" msgid "Edit Data Field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Failed to delete data field: %{error}" msgstr "" @@ -1824,6 +1832,7 @@ msgstr "" msgid "The cycle period will be calculated based on this date and the interval." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" @@ -1844,6 +1853,7 @@ msgstr "" msgid "User not found" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" @@ -1854,6 +1864,7 @@ msgstr "" msgid "You do not have permission to access this user" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" @@ -1925,16 +1936,6 @@ msgstr "" msgid "Roles" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Settings" -msgstr "" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2273,7 +2274,7 @@ msgstr "" msgid "Not authorized." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "" @@ -2424,6 +2425,7 @@ msgstr "" msgid "Linked" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2975,3 +2977,109 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure global settings and fee types for membership fees." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Groups claim" +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Member fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Membership fee settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save OIDC Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 839a43b..c9e30ca 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -19,6 +19,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -323,6 +324,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -336,6 +338,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -383,7 +386,6 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -843,6 +845,7 @@ msgid "Create Member" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -854,11 +857,13 @@ msgstr "" msgid "Back to Settings" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" @@ -869,6 +874,7 @@ msgstr "" msgid "Examples" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -887,6 +893,7 @@ msgid "Half-yearly" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -925,11 +932,13 @@ msgstr "" msgid "Monthly" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1003,7 +1012,7 @@ msgstr "" msgid "Select none" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "" @@ -1045,11 +1054,6 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Memberdata" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -1061,7 +1065,7 @@ msgstr "" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Member field %{action} successfully" msgstr "" @@ -1071,6 +1075,7 @@ msgstr "" msgid "A cycle for this period already exists" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1087,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1098,6 +1104,7 @@ msgstr "" msgid "Are you sure you want to delete this cycle?" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Cannot delete - %{count} member(s) assigned" @@ -1118,11 +1125,6 @@ msgstr "" msgid "Click to edit amount" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Configure global settings for membership fees." -msgstr "" - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1233,6 +1235,7 @@ msgstr "" msgid "Edit Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit membership fee type" @@ -1331,6 +1334,7 @@ msgstr "" msgid "Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Types" @@ -1347,6 +1351,7 @@ msgstr "" msgid "Membership fee start" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" @@ -1367,6 +1372,7 @@ msgstr "" msgid "Membership fee type updated. Cycles regenerated." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." @@ -1377,6 +1383,7 @@ msgstr "" msgid "Monthly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy @@ -1551,6 +1558,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete Membership Fee Type" @@ -1572,12 +1580,12 @@ msgstr "" msgid "Back to settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field %{action} successfully" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field deleted successfully" msgstr "" @@ -1592,7 +1600,7 @@ msgstr "" msgid "Edit Data Field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete data field: %{error}" msgstr "" @@ -1824,6 +1832,7 @@ msgstr "" msgid "The cycle period will be calculated based on this date and the interval." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" @@ -1844,6 +1853,7 @@ msgstr "" msgid "User not found" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" @@ -1854,6 +1864,7 @@ msgstr "" msgid "You do not have permission to access this user" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" @@ -1925,16 +1936,6 @@ msgstr "" msgid "Roles" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Fee Settings" -msgstr "" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2273,7 +2274,7 @@ msgstr "" msgid "Not authorized." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "" @@ -2424,6 +2425,7 @@ msgstr "" msgid "Linked" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2975,3 +2977,109 @@ msgstr "Required for Vereinfacht integration and cannot be disabled." #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Fee Type" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings and fee types for membership fees." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Groups claim" +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Membership fee settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save OIDC Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "" diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs index 229f56a..a4729ec 100644 --- a/test/mv/oidc_role_sync_test.exs +++ b/test/mv/oidc_role_sync_test.exs @@ -12,14 +12,14 @@ defmodule Mv.OidcRoleSyncTest do setup do ensure_roles_exist() - restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups") + restore_config = put_oidc_env(admin_group_name: "mila-admin", groups_claim: "groups") on_exit(restore_config) :ok end describe "apply_admin_role_from_user_info/2" do test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do - restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups") + restore = put_oidc_env(admin_group_name: nil, groups_claim: "groups") on_exit(restore) email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com" @@ -58,7 +58,7 @@ defmodule Mv.OidcRoleSyncTest do end test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do - restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups") + restore = put_oidc_env(admin_group_name: "mila-admin", groups_claim: "ak_groups") on_exit(restore) email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com" @@ -131,13 +131,30 @@ defmodule Mv.OidcRoleSyncTest do end end - defp put_oidc_config(opts) do - current = Application.get_env(:mv, :oidc_role_sync, []) - merged = Keyword.merge(current, opts) - Application.put_env(:mv, :oidc_role_sync, merged) + defp put_oidc_env(opts) do + prev_admin = System.get_env("OIDC_ADMIN_GROUP_NAME") + prev_claim = System.get_env("OIDC_GROUPS_CLAIM") + + if opts[:admin_group_name] != nil do + System.put_env("OIDC_ADMIN_GROUP_NAME", to_string(opts[:admin_group_name])) + else + System.delete_env("OIDC_ADMIN_GROUP_NAME") + end + + if opts[:groups_claim] != nil do + System.put_env("OIDC_GROUPS_CLAIM", to_string(opts[:groups_claim])) + else + System.delete_env("OIDC_GROUPS_CLAIM") + end fn -> - Application.put_env(:mv, :oidc_role_sync, current) + if prev_admin, + do: System.put_env("OIDC_ADMIN_GROUP_NAME", prev_admin), + else: System.delete_env("OIDC_ADMIN_GROUP_NAME") + + if prev_claim, + do: System.put_env("OIDC_GROUPS_CLAIM", prev_claim), + else: System.delete_env("OIDC_GROUPS_CLAIM") end end diff --git a/test/mv_web/components/sidebar_authorization_test.exs b/test/mv_web/components/sidebar_authorization_test.exs index 110d9e5..5d4277b 100644 --- a/test/mv_web/components/sidebar_authorization_test.exs +++ b/test/mv_web/components/sidebar_authorization_test.exs @@ -30,7 +30,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) assert html =~ ~s(href="/members") - assert html =~ ~s(href="/membership_fee_types") + assert html =~ ~s(href="/membership_fee_settings") assert html =~ ~s(href="/statistics") assert html =~ ~s(data-testid="sidebar-administration") assert html =~ ~s(href="/users") @@ -55,7 +55,7 @@ defmodule MvWeb.SidebarAuthorizationTest do user = Fixtures.user_with_role_fixture("read_only") html = render_sidebar(sidebar_assigns(user)) - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(href="/admin/roles") refute html =~ ~s(href="/settings") @@ -76,7 +76,7 @@ defmodule MvWeb.SidebarAuthorizationTest do user = Fixtures.user_with_role_fixture("normal_user") html = render_sidebar(sidebar_assigns(user)) - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(href="/admin/roles") refute html =~ ~s(href="/settings") @@ -96,7 +96,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) refute html =~ ~s(href="/statistics") - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(data-testid="sidebar-administration") end @@ -117,7 +117,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) refute html =~ ~s(href="/members") - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") end end diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 36e46e2..28f98a2 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -54,7 +54,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Create custom field value create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Click delete button - find the delete link within the component view @@ -80,7 +80,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member1, custom_field, "test1") create_custom_field_value(member2, custom_field, "test2") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -93,7 +93,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "shows 0 members for custom field without values", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -108,7 +108,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "updates confirmation state when typing", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -126,7 +126,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "delete button is disabled when slug doesn't match", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -148,7 +148,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Open modal view @@ -185,7 +185,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do } do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -209,7 +209,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "closes modal without deleting", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -234,7 +234,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do describe "create custom field" do test "submitting new data field form creates custom field and shows success", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Open "New Data Field" form view diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 86680f3..6a739b5 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -64,21 +64,5 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert html =~ "must be present" end - - test "displays Memberdata section", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - assert html =~ "Memberdata" or html =~ "Member Data" - end - - test "displays flash message after member field visibility update", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate member field visibility update - send(view.pid, {:member_field_visibility_updated}) - - # Check for flash message - assert render(view) =~ "updated" or render(view) =~ "success" - end end end diff --git a/test/mv_web/live/member_field_live/index_component_test.exs b/test/mv_web/live/member_field_live/index_component_test.exs index 6ad1627..d3c1612 100644 --- a/test/mv_web/live/member_field_live/index_component_test.exs +++ b/test/mv_web/live/member_field_live/index_component_test.exs @@ -23,7 +23,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do describe "rendering" do test "renders all member fields from Constants", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Check that all member fields are displayed member_fields = Mv.Constants.member_fields() @@ -36,7 +36,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "displays show_in_overview status as badge", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Should have "Show in overview" column header assert html =~ "Show in overview" or html =~ "Show in Overview" @@ -46,7 +46,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "displays required status column", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Should have "Required" column; email is always required assert html =~ "Required" or html =~ "required" @@ -59,7 +59,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do {:ok, _updated} = Membership.update_settings(settings, %{member_field_visibility: %{}}) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # All fields should show as visible (Yes) by default # Check for "Yes" badge or similar indicator @@ -74,7 +74,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do {:ok, _updated} = Membership.update_member_field_visibility(settings, visibility_config) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Street and house_number should show as hidden (No) # Other fields should show as visible (Yes) @@ -102,7 +102,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "marks email as required (always from settings)", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Email is always required assert html =~ "email" or html =~ "Email" @@ -119,7 +119,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do required: true ) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # First name row should show Required (and Optional for others) assert html =~ "First name" or html =~ "first_name" @@ -127,7 +127,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "optional fields show Optional when not required in settings", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Email is required; other fields default to optional assert html =~ "Optional" diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs index 71edbba..a836c4d 100644 --- a/test/mv_web/live/membership_fee_type_live/form_test.exs +++ b/test/mv_web/live/membership_fee_type_live/form_test.exs @@ -11,7 +11,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do require Ash.Query setup %{conn: conn} do - # User must have admin role (or normal_user) to access /membership_fee_types pages + # User must have admin role (or normal_user) to access /membership_fee_settings pages user = Mv.Fixtures.user_with_role_fixture("admin") authenticated_conn = conn_with_password_user(conn, user) %{conn: authenticated_conn, user: user} @@ -51,7 +51,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do describe "create form" do test "creates new membership fee type", %{conn: conn, user: user} do - {:ok, view, _html} = live(conn, "/membership_fee_types/new") + {:ok, view, _html} = live(conn, "/membership_fee_settings/new_fee_type") form_data = %{ "membership_fee_type[name]" => "New Type", @@ -65,7 +65,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do |> form("#membership-fee-type-form", form_data) |> render_submit() - assert to == "/membership_fee_types" + assert to == "/membership_fee_settings" # Verify type was created (use actor so read is authorized) type = @@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do end test "interval field is editable on create", %{conn: conn} do - {:ok, _view, html} = live(conn, "/membership_fee_types/new") + {:ok, _view, html} = live(conn, "/membership_fee_settings/new_fee_type") # Interval field should be editable (not disabled) refute html =~ "disabled" || html =~ "readonly" @@ -90,7 +90,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "loads existing type data", %{conn: conn} do fee_type = create_fee_type(%{name: "Existing Type", amount: Decimal.new("60.00")}) - {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, _view, html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") assert html =~ "Existing Type" assert html =~ "60" || html =~ "60,00" @@ -99,7 +99,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "interval field is grayed out on edit", %{conn: conn} do fee_type = create_fee_type(%{interval: :yearly}) - {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, _view, html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Interval field should be disabled assert html =~ "disabled" || html =~ "readonly" @@ -109,7 +109,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) create_member(%{membership_fee_type_id: fee_type.id}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount view @@ -129,7 +129,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do create_member(%{membership_fee_type_id: fee_type.id}) end) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount html = @@ -144,7 +144,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "amount change can be confirmed", %{conn: conn, user: user} do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount and confirm view @@ -173,7 +173,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "amount change can be cancelled", %{conn: conn, user: user} do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount and cancel view @@ -195,7 +195,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do end test "validation errors display correctly", %{conn: conn} do - {:ok, view, _html} = live(conn, "/membership_fee_types/new") + {:ok, view, _html} = live(conn, "/membership_fee_settings/new_fee_type") # Submit with invalid data html = @@ -214,7 +214,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do describe "permissions" do test "only admin can access", %{conn: conn} do # This test assumes non-admin users cannot access - {:ok, _view, html} = live(conn, "/membership_fee_types/new") + {:ok, _view, html} = live(conn, "/membership_fee_settings/new_fee_type") # Should show the form (admin user in setup) assert html =~ "Membership Fee Type" || html =~ "Beitragsart" diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs index 7d2d4be..c9bb7ca 100644 --- a/test/mv_web/live/membership_fee_type_live/index_test.exs +++ b/test/mv_web/live/membership_fee_type_live/index_test.exs @@ -60,7 +60,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do admin_user ) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") assert html =~ "Regular" assert html =~ "Reduced" @@ -77,33 +77,33 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do create_member(%{membership_fee_type_id: fee_type.id}, admin_user) end) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") assert html =~ "3" || html =~ "Members" || html =~ "Mitglieder" end test "create button navigates to form", %{conn: conn} do - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") {:error, {:live_redirect, %{to: to}}} = view - |> element("a[href='/membership_fee_types/new']") + |> element("a[href='/membership_fee_settings/new_fee_type']") |> render_click() - assert to == "/membership_fee_types/new" + assert to == "/membership_fee_settings/new_fee_type" end test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do fee_type = create_fee_type(%{interval: :yearly}, admin_user) - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") {:error, {:live_redirect, %{to: to}}} = view - |> element("a[href='/membership_fee_types/#{fee_type.id}/edit']") + |> element("a[href='/membership_fee_settings/#{fee_type.id}/edit_fee_type']") |> render_click() - assert to == "/membership_fee_types/#{fee_type.id}/edit" + assert to == "/membership_fee_settings/#{fee_type.id}/edit_fee_type" end end @@ -112,7 +112,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do fee_type = create_fee_type(%{interval: :yearly}, admin_user) create_member(%{membership_fee_type_id: fee_type.id}, admin_user) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") # Delete button should be disabled assert html =~ "disabled" || html =~ "cursor-not-allowed" @@ -122,7 +122,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do fee_type = create_fee_type(%{interval: :yearly}, admin_user) # No members assigned - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") # Delete button should be enabled view @@ -142,10 +142,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do test "only admin can access", %{conn: conn} do # This test assumes non-admin users cannot access # Adjust based on actual permission implementation - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") # Should show the page (admin user in setup) - assert html =~ "Membership Fee Types" || html =~ "Beitragsarten" + assert html =~ "Membership Fee Settings" || html =~ "Beitragseinstellungen" || + html =~ "Membership Fee Types" end end end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index e342744..6dd8022 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -279,17 +279,11 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end @tag role: :member - test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - - @tag role: :member - test "GET /membership_fee_types/new redirects to user profile", %{ + test "GET /membership_fee_settings/new_fee_type redirects to user profile", %{ conn: conn, current_user: user } do - conn = get(conn, "/membership_fee_types/new") + conn = get(conn, "/membership_fee_settings/new_fee_type") assert redirected_to(conn) == "/users/#{user.id}" end @@ -385,7 +379,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end @tag role: :member - test "GET /membership_fee_types/:id/edit redirects to user profile", %{ + test "GET /membership_fee_settings/:id/edit_fee_type redirects to user profile", %{ conn: conn, current_user: user } do @@ -396,7 +390,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do |> List.first() if type do - conn = get(conn, "/membership_fee_types/#{type.id}/edit") + conn = get(conn, "/membership_fee_settings/#{type.id}/edit_fee_type") assert redirected_to(conn) == "/users/#{user.id}" end end @@ -680,15 +674,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert redirected_to(conn) == "/users/#{user.id}" end - @tag role: :read_only - test "GET /membership_fee_types redirects to user profile", %{ - conn: conn, - current_user: user - } do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - @tag role: :read_only test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do conn = get(conn, "/groups/new") @@ -864,15 +849,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert redirected_to(conn) == "/users/#{user.id}" end - @tag role: :normal_user - test "GET /membership_fee_types redirects to user profile", %{ - conn: conn, - current_user: user - } do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - @tag role: :normal_user test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do conn = get(conn, "/admin/roles") From 8fd2ee067ebb59818715dc154ee53cd8c15f5371 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:07:34 +0100 Subject: [PATCH 101/237] style: udate csv import --- lib/mv_web/live/import_live.ex | 8 +++++++- lib/mv_web/live/import_live/components.ex | 20 +++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index e97ecd7..2b2a58f 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -92,7 +92,13 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> - <.form_section title={gettext("Import Members (CSV)")}> + <.header> + {gettext("Import Members (CSV)")} + <:subtitle> + {gettext("Import members from CSV files.")} + + + <.form_section title={gettext("Datei auswählen")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 69354bd..9d5db4f 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -20,12 +20,12 @@ defmodule MvWeb.ImportLive.Components do """ def custom_fields_notice(assigns) do ~H""" -
+
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext( - "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." + "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." )}

@@ -48,7 +48,7 @@ defmodule MvWeb.ImportLive.Components do def template_links(assigns) do ~H"""

-

+

{gettext("Download CSV templates:")}

    @@ -88,22 +88,20 @@ defmodule MvWeb.ImportLive.Components do phx-submit="start_import" data-testid="csv-upload-form" > -
    -
    + <.button type="submit" From adb44241d96cb6c5f3c67159a82d0978c1389e6f Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:38 +0100 Subject: [PATCH 102/237] Add migration: oidc_only boolean to settings table --- ...260224180000_add_oidc_only_to_settings.exs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs diff --git a/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs b/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs new file mode 100644 index 0000000..3775ef6 --- /dev/null +++ b/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs @@ -0,0 +1,20 @@ +defmodule Mv.Repo.Migrations.AddOidcOnlyToSettings do + @moduledoc """ + Adds oidc_only flag to settings. When true and OIDC is configured, + the sign-in page shows only OIDC (password login is hidden). + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :oidc_only, :boolean, default: false, null: false + end + end + + def down do + alter table(:settings) do + remove :oidc_only + end + end +end From e775fe118bbb29c3df5d59b375b44a7f96f5e310 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:41 +0100 Subject: [PATCH 103/237] Setting: add oidc_only boolean attribute (ENV + DB) --- lib/membership/setting.ex | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 6e987de..894725f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -85,7 +85,8 @@ defmodule Mv.Membership.Setting do :oidc_redirect_uri, :oidc_client_secret, :oidc_admin_group_name, - :oidc_groups_claim + :oidc_groups_claim, + :oidc_only ] end @@ -108,7 +109,8 @@ defmodule Mv.Membership.Setting do :oidc_redirect_uri, :oidc_client_secret, :oidc_admin_group_name, - :oidc_groups_claim + :oidc_groups_claim, + :oidc_only ] end @@ -372,6 +374,14 @@ defmodule Mv.Membership.Setting do description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')" end + attribute :oidc_only, :boolean do + allow_nil? false + default false + public? true + + description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)" + end + timestamps() end From 4b31578f6c1ff62572dc41eaff2cbb198b970236 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:41 +0100 Subject: [PATCH 104/237] Config: oidc_configured?/0, oidc_only?/0, OIDC_ONLY ENV and settings fallback --- lib/mv/config.ex | 57 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/mv/config.ex b/lib/mv/config.ex index f70a07e..ec69b18 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -262,6 +262,20 @@ defmodule Mv.Config do end end + defp env_or_setting_bool(env_key, setting_key) do + case System.get_env(env_key) do + nil -> + get_from_settings_bool(setting_key) + + value when is_binary(value) -> + v = String.trim(value) |> String.downcase() + v in ["true", "1", "yes"] + + _ -> + false + end + end + defp get_vereinfacht_from_settings(key) do get_from_settings(key) end @@ -273,6 +287,19 @@ defmodule Mv.Config do end end + defp get_from_settings_bool(key) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + case Map.get(settings, key) do + true -> true + _ -> false + end + + {:error, _} -> + false + end + end + defp trim_nil(nil), do: nil defp trim_nil(s) when is_binary(s) do @@ -366,7 +393,34 @@ defmodule Mv.Config do def oidc_env_configured? do oidc_client_id_env_set?() or oidc_base_url_env_set?() or oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or - oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() + oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() or + oidc_only_env_set?() + end + + @doc """ + Returns true when OIDC is configured and can be used for sign-in (client ID, base URL, + redirect URI, and client secret must be set). Used to show or hide the Single Sign-On button on the + sign-in page. Without client secret, the OIDC flow fails with MissingSecret; without redirect_uri, + the OIDC Plug crashes with URI.new(nil). + """ + @spec oidc_configured?() :: boolean() + def oidc_configured? do + id = oidc_client_id() + base = oidc_base_url() + secret = oidc_client_secret() + redirect = oidc_redirect_uri() + present = &(is_binary(&1) and String.trim(&1) != "") + present.(id) and present.(base) and present.(secret) and present.(redirect) + end + + @doc """ + Returns true when only OIDC sign-in should be shown (password login hidden). + ENV OIDC_ONLY first (true/1/yes vs false/0/no), then Settings.oidc_only. + Only has effect when OIDC is configured; when false or OIDC not configured, both password and OIDC are shown as usual. + """ + @spec oidc_only?() :: boolean() + def oidc_only? do + env_or_setting_bool("OIDC_ONLY", :oidc_only) end def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID") @@ -375,4 +429,5 @@ defmodule Mv.Config do def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET") 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") end From c49758fc468029af77e70b514fc1394b8e835e58 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:41 +0100 Subject: [PATCH 105/237] Secrets: return MissingSecret when OIDC values nil to avoid crashes --- lib/mv/secrets.ex | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex index f315ea3..177ed90 100644 --- a/lib/mv/secrets.ex +++ b/lib/mv/secrets.ex @@ -14,45 +14,59 @@ defmodule Mv.Secrets do - OIDC_BASE_URL / settings.oidc_base_url - OIDC_REDIRECT_URI / settings.oidc_redirect_uri - ## Usage - This module is automatically called by AshAuthentication when resolving - secrets for the User resource's OIDC strategy. + When a value is nil, returns `{:error, MissingSecret}` so that AshAuthentication + does not crash (e.g. URI.new(nil)) and can redirect to sign-in with an error. """ use AshAuthentication.Secret + alias AshAuthentication.Errors.MissingSecret + def secret_for( [:authentication, :strategies, :oidc, :client_id], - Mv.Accounts.User, + resource, _opts, _meth ) do - {:ok, Mv.Config.oidc_client_id()} + secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id) end def secret_for( [:authentication, :strategies, :oidc, :redirect_uri], - Mv.Accounts.User, + resource, _opts, _meth ) do - {:ok, Mv.Config.oidc_redirect_uri()} + secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri) end def secret_for( [:authentication, :strategies, :oidc, :client_secret], - Mv.Accounts.User, + resource, _opts, _meth ) do - {:ok, Mv.Config.oidc_client_secret()} + secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret) end def secret_for( [:authentication, :strategies, :oidc, :base_url], - Mv.Accounts.User, + resource, _opts, _meth ) do - {:ok, Mv.Config.oidc_base_url()} + secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url) + end + + defp secret_or_error(nil, resource, key) do + path = [:authentication, :strategies, :oidc, key] + {:error, MissingSecret.exception(path: path, resource: resource)} + end + + defp secret_or_error(value, resource, key) when is_binary(value) do + if String.trim(value) == "" do + secret_or_error(nil, resource, key) + else + {:ok, value} + end end end From 3f73a36076b845d139720201680dc2f2e2d7617b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:44 +0100 Subject: [PATCH 106/237] GlobalSettings: oidc_only checkbox, ENV merge for OIDC, disable when OIDC not configured --- lib/mv_web/live/global_settings_live.ex | 101 +++++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index b67b6ac..a55edf6 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -58,6 +58,8 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_client_secret_env_set, Mv.Config.oidc_client_secret_env_set?()) |> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?()) |> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?()) + |> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?()) + |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret)) |> assign_form() @@ -293,12 +295,36 @@ defmodule MvWeb.GlobalSettingsLive do ) } /> +
    + +

    + {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) + @oidc_admin_group_name_env_set and @oidc_groups_claim_env_set and + @oidc_only_env_set) } phx-disable-with={gettext("Saving...")} variant="primary" @@ -419,8 +445,17 @@ defmodule MvWeb.GlobalSettingsLive do end defp assign_form(%{assigns: %{settings: settings}} = socket) do - # Never put API key / client secret into form/DOM to avoid secret leak - settings_for_form = %{settings | vereinfacht_api_key: nil, oidc_client_secret: nil} + # Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret + settings_display = + settings + |> merge_vereinfacht_env_values() + |> merge_oidc_env_values() + + settings_for_form = %{ + settings_display + | vereinfacht_api_key: nil, + oidc_client_secret: nil + } form = AshPhoenix.Form.for_update( @@ -434,6 +469,66 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: to_form(form)) end + defp put_if_env_set(map, _key, false, _value), do: map + defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value) + + defp merge_vereinfacht_env_values(s) do + s + |> put_if_env_set( + :vereinfacht_api_url, + Mv.Config.vereinfacht_api_url_env_set?(), + Mv.Config.vereinfacht_api_url() + ) + |> put_if_env_set( + :vereinfacht_club_id, + Mv.Config.vereinfacht_club_id_env_set?(), + Mv.Config.vereinfacht_club_id() + ) + |> put_if_env_set( + :vereinfacht_app_url, + Mv.Config.vereinfacht_app_url_env_set?(), + Mv.Config.vereinfacht_app_url() + ) + end + + defp merge_oidc_env_values(s) do + s + |> put_if_env_set( + :oidc_client_id, + Mv.Config.oidc_client_id_env_set?(), + Mv.Config.oidc_client_id() + ) + |> put_if_env_set( + :oidc_base_url, + Mv.Config.oidc_base_url_env_set?(), + Mv.Config.oidc_base_url() + ) + |> put_if_env_set( + :oidc_redirect_uri, + Mv.Config.oidc_redirect_uri_env_set?(), + Mv.Config.oidc_redirect_uri() + ) + |> put_if_env_set( + :oidc_admin_group_name, + Mv.Config.oidc_admin_group_name_env_set?(), + Mv.Config.oidc_admin_group_name() + ) + |> put_if_env_set( + :oidc_groups_claim, + Mv.Config.oidc_groups_claim_env_set?(), + Mv.Config.oidc_groups_claim() + ) + |> put_if_oidc_only_env_set() + end + + defp put_if_oidc_only_env_set(s) do + if Mv.Config.oidc_only_env_set?() do + Map.put(s, :oidc_only, Mv.Config.oidc_only?()) + else + s + end + end + defp enrich_sync_errors([]), do: [] defp enrich_sync_errors(errors) when is_list(errors) do From 2cab4b0de423110f3b43c6294842dc85d2f4d899 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:45 +0100 Subject: [PATCH 107/237] Sign-in: custom SignInLive, OIDC-only mode and hide OIDC when not configured, locale divider or/oder --- assets/css/app.css | 33 +++++++++ lib/mv_web/auth_overrides.ex | 8 +- lib/mv_web/live/auth/sign_in_live.ex | 105 +++++++++++++++++++++++++++ lib/mv_web/router.ex | 7 +- 4 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 lib/mv_web/live/auth/sign_in_live.ex diff --git a/assets/css/app.css b/assets/css/app.css index 21b1b25..1d82f73 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -369,4 +369,37 @@ left: 0 !important; } +/* Sign-in: hide SSO button and "or" divider when OIDC is not configured. + Use .divider (DaisyUI HorizontalRule) because LiveView does not set id on component root. */ +[data-oidc-configured="false"] [id*="oidc"] { + display: none !important; +} +[data-oidc-configured="false"] a[href*="oidc"] { + display: none !important; +} +[data-oidc-configured="false"] .divider { + display: none !important; +} + +/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */ +[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] { + display: none !important; +} +[data-oidc-configured="true"][data-oidc-only="true"] .divider { + display: none !important; +} + +/* Sign-in: show "oder" instead of "or" when locale is German (override is compile-time only). + Target div.contents so ::after has a box (span may have display:contents). */ +[data-locale="de"] .divider div.contents { + display: block !important; +} +[data-locale="de"] .divider div.contents > span { + font-size: 0; +} +[data-locale="de"] .divider div.contents::after { + content: "oder"; + font-size: 1rem; +} + /* This file is for your main application CSS */ diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index b121c4e..f28d81f 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -38,12 +38,10 @@ defmodule MvWeb.AuthOverrides do set :image_url, nil end - # Translate the or in the horizontal rule to German + # 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, - Gettext.with_locale(MvWeb.Gettext, "de", fn -> - Gettext.gettext(MvWeb.Gettext, "or") - end) + set :text, dgettext("auth", "or") end # Hide AshAuthentication's Flash component since we use flash_group in root layout diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex new file mode 100644 index 0000000..5d2a0dc --- /dev/null +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -0,0 +1,105 @@ +defmodule MvWeb.SignInLive do + @moduledoc """ + Custom sign-in page with language selector and conditional Single Sign-On button. + + - 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. + """ + use Phoenix.LiveView + use Gettext, backend: MvWeb.Gettext + + alias AshAuthentication.Phoenix.Components + alias Mv.Config + + @impl true + def mount(_params, session, socket) do + overrides = + session + |> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default]) + + # Locale from session (set by set_locale plug / LiveUserAuth); default "de" + locale = session["locale"] || "de" + Gettext.put_locale(MvWeb.Gettext, locale) + + socket = + socket + |> assign(overrides: overrides) + |> assign_new(:otp_app, fn -> nil end) + |> assign(:path, session["path"] || "/") + |> assign(:reset_path, session["reset_path"]) + |> assign(:register_path, session["register_path"]) + |> assign(:current_tenant, session["tenant"]) + |> assign(:resources, session["resources"]) + |> assign(:context, session["context"] || %{}) + |> assign(:auth_routes_prefix, session["auth_routes_prefix"]) + |> assign(:gettext_fn, session["gettext_fn"]) + |> assign(:live_action, :sign_in) + |> assign(: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) + + {:ok, socket} + end + + @impl true + def handle_params(_, _uri, socket) do + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+ <%!-- Language selector: use @locale from socket (set by LiveUserAuth) so selection matches actual locale --%> + + +
+ <.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/router.ex b/lib/mv_web/router.ex index 9e39937..8a4e6c0 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -112,7 +112,8 @@ defmodule MvWeb.Router do auth_routes_prefix: "/auth", on_mount: [{MvWeb.LiveUserAuth, :live_no_user}], overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], - gettext_backend: {MvWeb.Gettext, "auth"} + gettext_backend: {MvWeb.Gettext, "auth"}, + live_view: MvWeb.SignInLive # Remove this if you do not want to use the reset password feature reset_route auth_routes_prefix: "/auth", @@ -212,8 +213,8 @@ defmodule MvWeb.Router do end) end - # Our supported languages for now are german and english, english as fallback language + # Our supported languages: German and English; default German. defp supported_locale?(locale), do: locale in ["en", "de"] - defp fallback_locale(nil), do: "en" + defp fallback_locale(nil), do: Application.get_env(:mv, :default_locale, "de") defp fallback_locale(locale), do: locale end From 3a98f70ba558e5268613b5d83be9316e866819a5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:46 +0100 Subject: [PATCH 108/237] Locale: default German in dev/prod, English in test; validate locale in LocaleController --- config/test.exs | 3 +++ lib/mv_web/live_user_auth.ex | 5 ++--- lib/mv_web/locale_controller.ex | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config/test.exs b/config/test.exs index fe2b855..864222f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,6 +49,9 @@ config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false +# Use English as default locale in tests so UI tests can assert on English strings. +config :mv, :default_locale, "en" + # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex index b78ba21..c913caa 100644 --- a/lib/mv_web/live_user_auth.ex +++ b/lib/mv_web/live_user_auth.ex @@ -42,9 +42,8 @@ defmodule MvWeb.LiveUserAuth do end def on_mount(:live_no_user, _params, session, socket) do - # Set the locale for not logged in user to set the language in the Log-In Screen - # otherwise the locale is not taken for the Log-In Screen - locale = session["locale"] || "en" + # Set the locale for not logged in user (default from config, "de" in dev/prod). + locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") Gettext.put_locale(MvWeb.Gettext, locale) {:cont, assign(socket, :locale, locale)} diff --git a/lib/mv_web/locale_controller.ex b/lib/mv_web/locale_controller.ex index 99a200f..afa1737 100644 --- a/lib/mv_web/locale_controller.ex +++ b/lib/mv_web/locale_controller.ex @@ -1,10 +1,11 @@ defmodule MvWeb.LocaleController do use MvWeb, :controller - def set_locale(conn, %{"locale" => locale}) do + @supported_locales ["de", "en"] + + def set_locale(conn, %{"locale" => locale}) when locale in @supported_locales do conn |> put_session(:locale, locale) - # Store locale in a cookie that persists beyond the session |> put_resp_cookie("locale", locale, max_age: 365 * 24 * 60 * 60, same_site: "Lax", @@ -14,6 +15,8 @@ defmodule MvWeb.LocaleController do |> redirect(to: get_referer(conn) || "/") end + def set_locale(conn, _params), do: redirect(conn, to: get_referer(conn) || "/") + defp get_referer(conn) do conn.req_headers |> Enum.find(fn {k, _v} -> k == "referer" end) From 249fd12db06c141a8658da8fd175909f8a765e7c Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:51 +0100 Subject: [PATCH 109/237] Dev: comment out OIDC defaults so sign-in hides SSO when not configured --- config/dev.exs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index e7b2af8..139b816 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -93,11 +93,13 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx # Signing Secret for Authentication config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" -config :mv, :oidc, - client_id: "mv", - base_url: "http://localhost:8080/auth/v1", - client_secret: System.get_env("OIDC_CLIENT_SECRET"), - redirect_uri: "http://localhost:4000/auth/user/oidc/callback" +# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out, +# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs. +# config :mv, :oidc, +# client_id: "mv", +# base_url: "http://localhost:8080/auth/v1", +# client_secret: System.get_env("OIDC_CLIENT_SECRET"), +# redirect_uri: "http://localhost:4000/auth/user/oidc/callback" # AshAuthentication development configuration config :mv, :session_identifier, :jti From 2d1d1c62dcb78010e7ec0f4945e397cbd43e91d4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:52 +0100 Subject: [PATCH 110/237] Docs and .env.example: document OIDC_ONLY --- .env.example | 4 ++++ docs/admin-bootstrap-and-oidc-role-sync.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 543579c..e24b118 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,10 @@ ASSOCIATION_NAME="Sportsclub XYZ" # OIDC_ADMIN_GROUP_NAME=admin # OIDC_GROUPS_CLAIM=groups +# Optional: Show only OIDC sign-in on login page (hide password form). +# When set to true and OIDC is configured, users see only the Single Sign-On button. +# OIDC_ONLY=true + # Optional: Vereinfacht accounting integration (finance-contacts sync) # If set, these override values from Settings UI; those fields become read-only. # VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index ef7c4ce..abbd03f 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -33,6 +33,10 @@ - `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups"). - Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0). +### Sign-in page (OIDC-only mode) + +- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings. + ### Sync Logic - Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups. From 7af65d997b1d1c689200cdc2084dc438d0b52001 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:53 +0100 Subject: [PATCH 111/237] Gettext: add DE/EN for OIDC-only labels and auth divider (or/oder) --- priv/gettext/auth.pot | 7 +++++++ priv/gettext/de/LC_MESSAGES/auth.po | 7 +++++++ priv/gettext/de/LC_MESSAGES/default.po | 15 +++++++++++++++ priv/gettext/default.pot | 15 +++++++++++++++ priv/gettext/en/LC_MESSAGES/auth.po | 7 +++++++ priv/gettext/en/LC_MESSAGES/default.po | 15 +++++++++++++++ 6 files changed, 66 insertions(+) diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 8608e17..550b238 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -137,11 +137,18 @@ 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 +#, elixir-autogen, elixir-format +msgid "or" +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 67e8551..4193b93 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -133,11 +133,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer 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 +#, elixir-autogen, elixir-format +msgid "or" +msgstr "oder" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f70d343..a8deca8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3083,3 +3083,18 @@ msgstr "Einstellungen speichern" #, elixir-autogen, elixir-format msgid "e.g. admin" msgstr "z. B. admin" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From OIDC_ONLY" +msgstr "Aus OIDC_ONLY" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5347731..ea8e976 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3083,3 +3083,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "e.g. admin" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ONLY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 0ec49bc..d3511b7 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -130,11 +130,18 @@ 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 +#, elixir-autogen, elixir-format +msgid "or" +msgstr "or" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index c9e30ca..915fc52 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3083,3 +3083,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "e.g. admin" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From OIDC_ONLY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "" From 951d01dc4de559ffa44b4aff0b1269137c47cff4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:07:53 +0100 Subject: [PATCH 112/237] Tests: accept DE or EN in auth controller sign-in and error assertions --- test/mv_web/controllers/auth_controller_test.exs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index f31327c..c75364b 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -25,7 +25,7 @@ defmodule MvWeb.AuthControllerTest do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") - assert html_response(conn, 200) =~ "Sign in" + assert html_response(conn, 200) =~ "Sign in" or html_response(conn, 200) =~ "Anmelden" end test "GET /sign-out redirects to home", %{conn: authenticated_conn} do @@ -82,7 +82,8 @@ defmodule MvWeb.AuthControllerTest do ) |> render_submit() - assert html =~ "Email or password was incorrect" + assert html =~ "Email or password was incorrect" or + html =~ "Email oder Passwort nicht korrekt" end test "password user with non-existent email shows error via LiveView", %{ @@ -100,7 +101,8 @@ defmodule MvWeb.AuthControllerTest do ) |> render_submit() - assert html =~ "Email or password was incorrect" + assert html =~ "Email or password was incorrect" or + html =~ "Email oder Passwort nicht korrekt" end # Registration (LiveView) From aaa897c8dcc322e4507fe24172543b141d962cd4 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:27:12 +0100 Subject: [PATCH 113/237] style: restyle PDF export --- lib/mv_web/live/import_live.ex | 10 +++---- lib/mv_web/live/import_live/components.ex | 2 +- priv/pdf_templates/members_export.typ | 32 ++++++++++++++++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2b2a58f..2e32f8e 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -93,11 +93,11 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> <.header> - {gettext("Import Members (CSV)")} - <:subtitle> - {gettext("Import members from CSV files.")} - - + {gettext("Import Members (CSV)")} + <:subtitle> + {gettext("Import members from CSV files.")} + + <.form_section title={gettext("Datei auswählen")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 9d5db4f..93dc154 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -25,7 +25,7 @@ defmodule MvWeb.ImportLive.Components do

{gettext( - "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." + "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored with a warning. Groups and membership fees are not supported for import." )}

diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ index 5dca208..33b793e 100644 --- a/priv/pdf_templates/members_export.typ +++ b/priv/pdf_templates/members_export.typ @@ -9,7 +9,13 @@ #set page( paper: "a4", flipped: true, - margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm) + margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm), + numbering: "1", + footer: context [ + #set text(size: 8pt) + #set align(center) + #counter(page).display("1 / 1", both: true) + ] ) #set text(size: 9pt, hyphenate: true) @@ -58,7 +64,6 @@ #let start = fixed_count + chunk_index * max_dynamic_cols #let page_cols = fixed_cols + dyn_cols_chunk - #let headers = page_cols.map(c => c.at("label", default: "")) // widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr #let widths = ( @@ -67,9 +72,9 @@ ..((1fr,) * dyn_count) ) - #let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h]) + #let header_cells = page_cols.map(c => text(weight: "bold", size: 9pt)[#c.at("label", default: "")]) - // Body cells (row-major), nur die Spalten dieses Chunks + // Body cells (row-major), only columns of this chunk #let body_cells = ( rows .map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count)) @@ -77,8 +82,27 @@ .flatten() ) + // Thinner grid for body; thicker vertical line between column 2 and 3; header and outer borders thick + #let thin_stroke = 0.3pt + black + #let thick_sep = 1.5pt + black + #let thick_stroke = 1pt + black + #let last_x = fixed_count + dyn_count - 1 + #let last_y = rows.len() + #let stroke_fn = (x, y) => { + let top = if y == 0 or y == 1 { thick_stroke } else { thin_stroke } + let bottom = if y == 0 or y == last_y { thick_stroke } else { thin_stroke } + let left = if x == 0 { thick_stroke } else if x == 2 { thick_sep } else { thin_stroke } + let right = if x == last_x { thick_stroke } else { thin_stroke } + (top: top, bottom: bottom, left: left, right: right) + } + + // Light gray background for first two columns (first_name, last_name) + #let fill_fn = (x, y) => if x < 2 { rgb("f2f2f2") } else { none } + #table( columns: widths, + stroke: stroke_fn, + fill: fill_fn, table.header(..header_cells), ..body_cells, ) From 6417958cccfca46c78c034b72c9465273c008353 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:38:20 +0100 Subject: [PATCH 114/237] i18n: Update translations --- lib/mv_web/live/import_live.ex | 4 +-- lib/mv_web/live/import_live/components.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 35 ++++++++++++++++------- priv/gettext/default.pot | 30 ++++++++++++------- priv/gettext/en/LC_MESSAGES/default.po | 30 ++++++++++++------- 5 files changed, 68 insertions(+), 33 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2e32f8e..894fe36 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -93,12 +93,12 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> <.header> - {gettext("Import Members (CSV)")} + {gettext("Import Members")} <:subtitle> {gettext("Import members from CSV files.")} - <.form_section title={gettext("Datei auswählen")}> + <.form_section title={gettext("Choose CSV file")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 93dc154..9d5db4f 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -25,7 +25,7 @@ defmodule MvWeb.ImportLive.Components do

{gettext( - "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored with a warning. Groups and membership fees are not supported for import." + "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." )}

diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index ea9422d..b7f1144 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2049,11 +2049,6 @@ msgstr "Fehlgeschlagen: %{count} Zeile(n)" msgid "German Template" msgstr "Deutsche Vorlage" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "Mitglieder importieren (CSV)" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2382,11 +2377,6 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgid "Manage Member Data" msgstr "Mitgliederdaten verwalten" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert." - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2927,3 +2917,28 @@ msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Beitragsart" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +msgstr "Miglieder aus CSV Dateien importieren." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." +msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und der Beitragsstatus kann nicht importiert werden." + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "CSV Datei auswählen" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Import Members" +msgstr "Mitglieder importieren (CSV)" + +#~ #: lib/mv_web/live/import_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Datei auswählen" +#~ msgstr "" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 84ad2a0..81d6d94 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2050,11 +2050,6 @@ msgstr "" msgid "German Template" msgstr "" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2383,11 +2378,6 @@ msgstr "" msgid "Manage Member Data" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "Export members to CSV" @@ -2927,3 +2917,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import Members" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9928e76..3121959 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2050,11 +2050,6 @@ msgstr "" msgid "German Template" msgstr "" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2383,11 +2378,6 @@ msgstr "" msgid "Manage Member Data" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2927,3 +2917,23 @@ msgstr "Required for Vereinfacht integration and cannot be disabled." #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Fee Type" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Import Members" +msgstr "" From fae1804fb1c0ccfcbb0d289200222852badc0ca5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:41:30 +0100 Subject: [PATCH 115/237] Code review: SignInLive locale fallback, single root + id, CSS scoped to #sign-in-page, remove or-hack, refresh oidc_configured after save, tests assert English only --- assets/css/app.css | 25 +++------- lib/mv_web/live/auth/sign_in_live.ex | 46 +++++++++---------- lib/mv_web/live/global_settings_live.ex | 5 +- .../controllers/auth_controller_test.exs | 8 ++-- 4 files changed, 33 insertions(+), 51 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 1d82f73..bbe7424 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -370,36 +370,23 @@ } /* Sign-in: hide SSO button and "or" divider when OIDC is not configured. - Use .divider (DaisyUI HorizontalRule) because LiveView does not set id on component root. */ -[data-oidc-configured="false"] [id*="oidc"] { + Scoped to #sign-in-page to avoid hiding unrelated elements. */ +#sign-in-page[data-oidc-configured="false"] [id*="oidc"] { display: none !important; } -[data-oidc-configured="false"] a[href*="oidc"] { +#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] { display: none !important; } -[data-oidc-configured="false"] .divider { +#sign-in-page[data-oidc-configured="false"] .divider { display: none !important; } /* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */ -[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] { +#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] { display: none !important; } -[data-oidc-configured="true"][data-oidc-only="true"] .divider { +#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider { display: none !important; } -/* Sign-in: show "oder" instead of "or" when locale is German (override is compile-time only). - Target div.contents so ::after has a box (span may have display:contents). */ -[data-locale="de"] .divider div.contents { - display: block !important; -} -[data-locale="de"] .divider div.contents > span { - font-size: 0; -} -[data-locale="de"] .divider div.contents::after { - content: "oder"; - font-size: 1rem; -} - /* This file is for your main application CSS */ diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex index 5d2a0dc..aa0d640 100644 --- a/lib/mv_web/live/auth/sign_in_live.ex +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -18,8 +18,10 @@ defmodule MvWeb.SignInLive do session |> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default]) - # Locale from session (set by set_locale plug / LiveUserAuth); default "de" - locale = session["locale"] || "de" + # 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") + Gettext.put_locale(MvWeb.Gettext, locale) socket = @@ -53,12 +55,13 @@ defmodule MvWeb.SignInLive do def render(assigns) do ~H"""

- <%!-- Language selector: use @locale from socket (set by LiveUserAuth) so selection matches actual locale --%> + <%!-- 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} - /> -
+ <.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 diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index a55edf6..752c8d6 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -34,8 +34,8 @@ defmodule MvWeb.GlobalSettingsLive do def mount(_params, session, socket) do {:ok, settings} = Membership.get_settings() - # Get locale from session for translations - locale = session["locale"] || "de" + # Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test) + locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") Gettext.put_locale(MvWeb.Gettext, locale) socket = @@ -407,6 +407,7 @@ defmodule MvWeb.GlobalSettingsLive do |> 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_configured, Mv.Config.oidc_configured?()) |> assign(:vereinfacht_test_result, test_result) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index c75364b..f31327c 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -25,7 +25,7 @@ defmodule MvWeb.AuthControllerTest do # Create unauthenticated conn for this test conn = build_unauthenticated_conn(authenticated_conn) conn = get(conn, ~p"/sign-in") - assert html_response(conn, 200) =~ "Sign in" or html_response(conn, 200) =~ "Anmelden" + assert html_response(conn, 200) =~ "Sign in" end test "GET /sign-out redirects to home", %{conn: authenticated_conn} do @@ -82,8 +82,7 @@ defmodule MvWeb.AuthControllerTest do ) |> render_submit() - assert html =~ "Email or password was incorrect" or - html =~ "Email oder Passwort nicht korrekt" + assert html =~ "Email or password was incorrect" end test "password user with non-existent email shows error via LiveView", %{ @@ -101,8 +100,7 @@ defmodule MvWeb.AuthControllerTest do ) |> render_submit() - assert html =~ "Email or password was incorrect" or - html =~ "Email oder Passwort nicht korrekt" + assert html =~ "Email or password was incorrect" end # Registration (LiveView) From 89a48cbaf79bdbc7408bbea3a1e2e987ed8a9e2b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:41:30 +0100 Subject: [PATCH 116/237] Nitpick: add missing newline at EOF in settings resource_snapshots JSON files --- priv/resource_snapshots/repo/settings/20251127134451.json | 2 +- priv/resource_snapshots/repo/settings/20251201115939.json | 2 +- priv/resource_snapshots/repo/settings/20251211195058.json | 2 +- priv/resource_snapshots/repo/settings/20260218185541.json | 2 +- priv/resource_snapshots/repo/settings/20260224122831.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/priv/resource_snapshots/repo/settings/20251127134451.json b/priv/resource_snapshots/repo/settings/20251127134451.json index fefc223..8767574 100644 --- a/priv/resource_snapshots/repo/settings/20251127134451.json +++ b/priv/resource_snapshots/repo/settings/20251127134451.json @@ -64,4 +64,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json index 4e635c4..da284c6 100644 --- a/priv/resource_snapshots/repo/settings/20251201115939.json +++ b/priv/resource_snapshots/repo/settings/20251201115939.json @@ -76,4 +76,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20251211195058.json b/priv/resource_snapshots/repo/settings/20251211195058.json index 4b437b8..ea73ec3 100644 --- a/priv/resource_snapshots/repo/settings/20251211195058.json +++ b/priv/resource_snapshots/repo/settings/20251211195058.json @@ -100,4 +100,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20260218185541.json b/priv/resource_snapshots/repo/settings/20260218185541.json index 4334f9a..9c7ef33 100644 --- a/priv/resource_snapshots/repo/settings/20260218185541.json +++ b/priv/resource_snapshots/repo/settings/20260218185541.json @@ -137,4 +137,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20260224122831.json b/priv/resource_snapshots/repo/settings/20260224122831.json index b1ad095..73678b4 100644 --- a/priv/resource_snapshots/repo/settings/20260224122831.json +++ b/priv/resource_snapshots/repo/settings/20260224122831.json @@ -161,4 +161,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} From eec14517439313cb22479dd02509a6ae2160785b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 15:50:41 +0100 Subject: [PATCH 117/237] Fix DE translations: Groups claim, Member fields, Save OIDC Settings; remove fuzzy --- priv/gettext/de/LC_MESSAGES/default.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a8deca8..49fbe83 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3055,14 +3055,14 @@ msgid "From OIDC_REDIRECT_URI" msgstr "Aus OIDC_REDIRECT_URI" #: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Groups claim" -msgstr "Gruppen" +msgstr "Gruppenclaim" #: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Member fields" -msgstr "Mitgliedsfilter" +msgstr "Mitgliedsfelder" #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format, fuzzy @@ -3075,9 +3075,9 @@ msgid "Redirect URI" msgstr "Weiterleitungs-URI" #: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Save OIDC Settings" -msgstr "Einstellungen speichern" +msgstr "OIDC-Einstellungen speichern" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -3085,7 +3085,7 @@ msgid "e.g. admin" msgstr "z. B. admin" #: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "From OIDC_ONLY" msgstr "Aus OIDC_ONLY" From c62b105518745e60c6339b7daf4dd1232b911aa3 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 16:00:46 +0100 Subject: [PATCH 118/237] test: updated --- lib/mv_web/live/import_live.ex | 30 ++++++++++++++------------- test/mv_web/live/import_live_test.exs | 3 +-- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 894fe36..4e172ed 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -92,20 +92,22 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> - <.header> - {gettext("Import Members")} - <:subtitle> - {gettext("Import members from CSV files.")} - - - <.form_section title={gettext("Choose CSV file")}> - - - - <%= if @import_status == :running or @import_status == :done or @import_status == :error do %> - - <% end %> - +
+ <.header> + {gettext("Import Members")} + <:subtitle> + {gettext("Import members from CSV files.")} + + + <.form_section title={gettext("Choose CSV file")}> + + + + <%= if @import_status == :running or @import_status == :done or @import_status == :error do %> + + <% end %> + +
<% else %> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm"> + <.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}> <.icon name="hero-pencil" class="size-4" /> - {gettext("Edit")} - + {gettext("Edit role")} + <% end %> <:action :let={role}> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> - <.link + <.button + variant="danger" + size="sm" phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} data-confirm={gettext("Are you sure?")} - class="btn btn-ghost btn-sm text-error" > <.icon name="hero-trash" class="size-4" /> {gettext("Delete")} - + <% else %> -
-
+ <% end %> diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 0e1c7ca..4dbbb1f 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -165,23 +165,23 @@ defmodule MvWeb.RoleLive.Show do <:subtitle>{gettext("Role details and permissions.")} <:actions> - <.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}> - <.icon name="hero-arrow-left" /> - {gettext("Back to roles list")} + <.button navigate={~p"/admin/roles"} variant="neutral" aria-label={gettext("Back to roles list")}> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> - <.icon name="hero-pencil-square" /> {gettext("Edit Role")} + <.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")} <% end %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> - <.link + <.button + variant="danger" phx-click={JS.push("delete", value: %{id: @role.id})} data-confirm={gettext("Are you sure?")} - class="btn btn-error" > <.icon name="hero-trash" /> {gettext("Delete Role")} - + <% end %> diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 46e23b3..f9f17bb 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -167,13 +167,14 @@ defmodule MvWeb.UserLive.Form do

{@user.member.email}

- +
<% else %> @@ -281,10 +282,12 @@ defmodule MvWeb.UserLive.Form do <% end %>
+ <.button navigate={return_path(@return_to, @user)} variant="neutral"> + {gettext("Cancel")} + <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} - <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index ab13f90..364e5a4 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -91,7 +91,7 @@ <%= if can?(@current_user, :update, user) do %> <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit"> - {gettext("Edit")} + {gettext("Edit user")} <% end %> diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 4d803cd..3530b36 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -37,7 +37,7 @@ defmodule MvWeb.UserLive.Show do <:subtitle>{gettext("This is a user record from your database.")} <:actions> - <.button navigate={~p"/users"} aria-label={gettext("Back to users list")}> + <.button navigate={~p"/users"} variant="neutral" aria-label={gettext("Back to users list")}> <.icon name="hero-arrow-left" /> {gettext("Back to users list")} From ff9f98f8e7b5941fc4ea06fa91d875298cc097b2 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 09:45:10 +0100 Subject: [PATCH 121/237] style: consitent flash messages --- DESIGN_DUIDELINES.md | 6 +- docs/feature-roadmap.md | 5 ++ lib/mv_web/components/core_components.ex | 2 +- lib/mv_web/components/layouts.ex | 6 +- lib/mv_web/controllers/auth_controller.ex | 4 +- .../live/auth/link_oidc_account_live.ex | 4 +- lib/mv_web/live/datafields_live.ex | 6 +- lib/mv_web/live/global_settings_live.ex | 25 +++--- lib/mv_web/live/group_live/form.ex | 2 +- lib/mv_web/live/group_live/index.ex | 26 +++--- lib/mv_web/live/group_live/show.ex | 4 +- lib/mv_web/live/member_live/form.ex | 2 +- lib/mv_web/live/member_live/index.ex | 2 +- .../show/membership_fees_component.ex | 18 ++-- .../live/membership_fee_settings_live.ex | 4 +- .../live/membership_fee_type_live/form.ex | 2 +- .../live/membership_fee_type_live/index.ex | 2 +- lib/mv_web/live/role_live/form.ex | 86 +++++++++---------- lib/mv_web/live/role_live/index.ex | 2 +- lib/mv_web/live/role_live/show.ex | 8 +- lib/mv_web/live/user_live/form.ex | 2 +- lib/mv_web/live/user_live/index.ex | 2 +- 22 files changed, 117 insertions(+), 103 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index b0372ef..18864b5 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -286,11 +286,11 @@ Notes: - warning: 6–8s - error: 8–12s (or manual dismiss for critical errors) - **MUST:** Keep a dismiss button for accessibility and user control. +- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency. -### 9.3 Variants + special “email copied” +### 9.3 Variants (unified) - Supported semantic variants: `info`, `success`, `warning`, `error`. -- **Special case:** clipboard “Email copied” uses a **soft/light blue** tone distinct from normal info. -- **MUST:** Model this as `tone="soft"` (or similar prop) on the flash component, not hard-coded colors in views. +- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app. ### 9.4 Accessibility - Flash must work with screen readers (live region behavior belongs in the flash component implementation). diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index b699560..66b46eb 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -191,6 +191,11 @@ - ❌ Mobile navigation - ❌ Context-sensitive help - ❌ Onboarding tooltips +- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9) + - Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility. + - Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element. + - LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`. + - All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_DUIDELINES.md` §9. --- diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 4f9d7af..1e8e7f3 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -60,7 +60,7 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-bottom toast-end" + class="pointer-events-auto" {@rest} >
+
<.flash kind={:success} flash={@flash} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 28f3846..20a76f5 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do |> store_in_session(user) # If your resource has a different name, update the assign name here (i.e :current_admin) |> assign(:current_user, user) - |> put_flash(:info, message) + |> put_flash(:success, message) |> redirect(to: return_to) end @@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do conn |> clear_session(:mv) - |> put_flash(:info, gettext("You are now signed out")) + |> put_flash(:success, gettext("You are now signed out")) |> redirect(to: return_to) end end diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index b6c24b1..01bd57b 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do socket |> put_flash( - :info, + :success, dgettext("auth", "Account activated! Redirecting to complete sign-in...") ) |> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc") @@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do {:noreply, socket |> put_flash( - :info, + :success, dgettext( "auth", "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex index f7436ab..f922d22 100644 --- a/lib/mv_web/live/datafields_live.ex +++ b/lib/mv_web/live/datafields_live.ex @@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do {:noreply, socket |> assign(:active_editing_section, nil) - |> put_flash(:info, gettext("Data field %{action} successfully", action: action))} + |> put_flash(:success, gettext("Data field %{action} successfully", action: action))} end @impl true def handle_info({:custom_field_deleted, _custom_field}, socket) do - {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))} + {:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))} end @impl true @@ -115,7 +115,7 @@ defmodule MvWeb.DatafieldsLive do socket |> assign(:settings, updated_settings) |> assign(:active_editing_section, nil) - |> put_flash(:info, gettext("Member field %{action} successfully", action: action))} + |> put_flash(:success, gettext("Member field %{action} successfully", action: action))} end @impl true diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index f3a61bc..485601a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -357,20 +357,21 @@ defmodule MvWeb.GlobalSettingsLive do errors_with_names = enrich_sync_errors(errors) result = %{synced: synced, errors: errors_with_names} + {flash_kind, flash_message} = + if(errors_with_names == [], + do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)}, + else: + {:warning, + gettext("Synced %{count} member(s). %{error_count} failed.", + count: synced, + error_count: length(errors_with_names) + )} + ) + socket = socket |> assign(:last_vereinfacht_sync_result, result) - |> put_flash( - :info, - if(errors_with_names == [], - do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced), - else: - gettext("Synced %{count} member(s). %{error_count} failed.", - count: synced, - error_count: length(errors_with_names) - ) - ) - ) + |> put_flash(flash_kind, flash_message) {:noreply, socket} @@ -409,7 +410,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:vereinfacht_test_result, test_result) - |> put_flash(:info, gettext("Settings updated successfully")) + |> put_flash(:success, gettext("Settings updated successfully")) |> assign_form() {:noreply, socket} diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index 5f781a7..d9999d3 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -128,7 +128,7 @@ defmodule MvWeb.GroupLive.Form do socket = socket - |> put_flash(:info, gettext("Group saved successfully.")) + |> put_flash(:success, gettext("Group saved successfully.")) |> push_navigate(to: redirect_path) {:noreply, socket} diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index b6c8277..70358e0 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -76,24 +76,24 @@ defmodule MvWeb.GroupLive.Index do <:col :let={group} label={gettext("Members")} class="text-right"> {group.member_count || 0} - <:action :let={group}> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}"} - > - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <:action :let={group}> <.button variant="ghost" size="sm" - navigate={~p"/groups/#{group.slug}/edit"} + navigate={~p"/groups/#{group.slug}"} > - {gettext("Edit group")} + {gettext("View")} - <% end %> - + <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <.button + variant="ghost" + size="sm" + navigate={~p"/groups/#{group.slug}/edit"} + > + {gettext("Edit group")} + + <% end %> + <% end %>
diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 46766ef..7e2d57f 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -150,7 +150,7 @@ defmodule MvWeb.GroupLive.Show do
- <%= for member <- @selected_members do %> + <%= for member <- @selected_members do %> {MvWeb.Helpers.MemberHelpers.display_name(member)} <.tooltip content={gettext("Remove")} position="top"> @@ -909,7 +909,7 @@ defmodule MvWeb.GroupLive.Show do :ok -> {:noreply, socket - |> put_flash(:info, gettext("Group deleted successfully.")) + |> put_flash(:success, gettext("Group deleted successfully.")) |> redirect(to: ~p"/groups")} {:error, error} -> diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 625ab2a..66260f4 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -390,7 +390,7 @@ defmodule MvWeb.MemberLive.Form do socket = socket - |> put_flash(:info, flash_message) + |> put_flash(:success, flash_message) |> maybe_put_vereinfacht_sync_flash(member.id) |> push_navigate(to: return_path(socket.assigns.return_to, member)) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 3283b5c..a7f6316 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -175,7 +175,7 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:members, updated_members) - |> put_flash(:info, gettext("Member deleted successfully"))} + |> put_flash(:success, gettext("Member deleted successfully"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 2e75b57..23f0dda 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -562,7 +562,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:info, gettext("Membership fee type removed"))} + |> put_flash(:success, gettext("Membership fee type removed"))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -621,7 +621,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} + |> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -649,7 +649,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do {:noreply, socket |> assign(:cycles, updated_cycles) - |> put_flash(:info, gettext("Cycle status updated"))} + |> put_flash(:success, gettext("Cycle status updated"))} {:error, %Ash.Error.Invalid{} = error} -> error_msg = @@ -705,7 +705,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, cycles) |> assign(:regenerating, false) - |> put_flash(:info, gettext("Cycles regenerated successfully"))} + |> put_flash(:success, gettext("Cycles regenerated successfully"))} {:error, error} -> {:noreply, @@ -755,7 +755,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:editing_cycle, nil) - |> put_flash(:info, gettext("Cycle amount updated"))} + |> put_flash(:success, gettext("Cycle amount updated"))} {:error, error} -> {:noreply, @@ -794,7 +794,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:info, gettext("Cycle deleted"))} + |> put_flash(:success, gettext("Cycle deleted"))} {:ok, _destroyed} -> # Handle case where return_destroyed? is true @@ -804,7 +804,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:info, gettext("Cycle deleted"))} + |> put_flash(:success, gettext("Cycle deleted"))} {:error, error} -> {:noreply, @@ -950,7 +950,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:creating_cycle, false) |> assign(:create_cycle_date, nil) |> assign(:create_cycle_error, nil) - |> put_flash(:info, gettext("Cycle created successfully"))} + |> put_flash(:success, gettext("Cycle created successfully"))} {:error, error} -> {:noreply, @@ -1013,7 +1013,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, updated_cycles) |> reset_modal.() - |> put_flash(:info, gettext("All cycles deleted"))} + |> put_flash(:success, gettext("All cycles deleted"))} {:ok, _} -> {:noreply, diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index aabb210..84ce662 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do {:noreply, socket |> assign(:settings, updated_settings) - |> put_flash(:info, gettext("Settings saved successfully.")) + |> put_flash(:success, gettext("Settings saved successfully.")) |> assign_form()} {:error, form} -> @@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) - |> put_flash(:info, gettext("Membership fee type deleted"))} + |> put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index 72add11..ca61e19 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -317,7 +317,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do socket = socket - |> put_flash(:info, gettext("Membership fee type saved successfully")) + |> put_flash(:success, gettext("Membership fee type saved successfully")) |> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type)) {:noreply, socket} diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index 0a17920..ee3b791 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -149,7 +149,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) - |> put_flash(:info, gettext("Membership fee type deleted"))} + |> put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index ccd03cf..684e695 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -39,50 +39,50 @@ defmodule MvWeb.RoleLive.Form do
<.input field={@form[:name]} type="text" label={gettext("Name")} required /> - <.input - field={@form[:description]} - type="textarea" - label={gettext("Description")} - rows="3" - /> + <.input + field={@form[:description]} + type="textarea" + label={gettext("Description")} + rows="3" + /> -
- - + + <%= for permission_set <- all_permission_sets() do %> + + <% end %> + + <%= if @form.errors[:permission_set_name] do %> + <%= for error <- List.wrap(@form.errors[:permission_set_name]) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

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

+ <% end %> <% end %> - - <%= if @form.errors[:permission_set_name] do %> - <%= for error <- List.wrap(@form.errors[:permission_set_name]) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

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

- <% end %> - <% end %> -
+
@@ -177,7 +177,7 @@ defmodule MvWeb.RoleLive.Form do socket = socket - |> put_flash(:info, gettext("Role saved successfully.")) + |> put_flash(:success, gettext("Role saved successfully.")) |> push_navigate(to: redirect_path) {:noreply, socket} diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 091b191..2169400 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -100,7 +100,7 @@ defmodule MvWeb.RoleLive.Index do socket |> assign(:roles, updated_roles) |> assign(:user_counts, updated_counts) - |> put_flash(:info, gettext("Role deleted successfully."))} + |> put_flash(:success, gettext("Role deleted successfully."))} {:error, error} -> error_message = format_error(error) diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 4dbbb1f..8b5b1b2 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do :ok -> {:noreply, socket - |> put_flash(:info, gettext("Role deleted successfully.")) + |> put_flash(:success, gettext("Role deleted successfully.")) |> push_navigate(to: ~p"/admin/roles")} {:error, error} -> @@ -165,7 +165,11 @@ defmodule MvWeb.RoleLive.Show do <:subtitle>{gettext("Role details and permissions.")} <:actions> - <.button navigate={~p"/admin/roles"} variant="neutral" aria-label={gettext("Back to roles list")}> + <.button + navigate={~p"/admin/roles"} + variant="neutral" + aria-label={gettext("Back to roles list")} + > <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index f9f17bb..8ff5966 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -556,7 +556,7 @@ defmodule MvWeb.UserLive.Form do socket = socket - |> put_flash(:info, gettext("User %{action} successfully", action: action)) + |> put_flash(:success, gettext("User %{action} successfully", action: action)) |> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) {:noreply, socket} diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 72cc55c..ba36605 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Index do {:noreply, socket |> assign(:users, updated_users) - |> put_flash(:info, gettext("User deleted successfully"))} + |> put_flash(:success, gettext("User deleted successfully"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, From 02af136fd99f8c61e184690cd56d8a8996735f01 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 10:33:30 +0100 Subject: [PATCH 122/237] feat: restyle tabs and move delete to edit view --- .../components/member_filter_component.ex | 2 +- lib/mv_web/live/member_live/form.ex | 14 +- lib/mv_web/live/member_live/index.ex | 57 --- lib/mv_web/live/member_live/index.html.heex | 22 +- lib/mv_web/live/member_live/show.ex | 442 +++++++++++------- .../member_live/form_error_handling_test.exs | 27 ++ test/mv_web/member_live/index_test.exs | 44 +- test/mv_web/member_live/show_test.exs | 29 ++ 8 files changed, 361 insertions(+), 276 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index c020fc1..7a3517b 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do # Get boolean filter label (comma-separated list of active filter names) defp boolean_filter_label(_boolean_custom_fields, boolean_filters) when map_size(boolean_filters) == 0 do - gettext("All") + gettext("Apply filters") end defp boolean_filter_label(boolean_custom_fields, boolean_filters) do diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 66260f4..9138236 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do - Create new members with personal information - Edit existing member details - Grouped sections for better organization - - Tab navigation (Payments tab disabled, coming soon) - Manage custom properties (dynamic fields, displayed sorted by name) - Real-time validation with visual feedback @@ -56,23 +55,12 @@ defmodule MvWeb.MemberLive.Form do
- <%!-- Tab Navigation --%> + <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
-
<%!-- Personal Data and Custom Fields Row --%> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index a7f6316..4309611 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do - `sort_order` - Sort direction (:asc or :desc) ## Events - - `delete` - Remove a member from the database - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members - `copy_emails` - Copy email addresses of selected members to clipboard @@ -157,50 +156,10 @@ defmodule MvWeb.MemberLive.Index do Handles member-related UI events. ## Supported events: - - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ - @impl true - def handle_event("delete", %{"id" => id}, socket) do - actor = current_actor(socket) - - case Ash.get(Mv.Membership.Member, id, actor: actor) do - {:ok, member} -> - case Ash.destroy(member, actor: actor) do - :ok -> - updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) - - {:noreply, - socket - |> assign(:members, updated_members) - |> put_flash(:success, gettext("Member deleted successfully"))} - - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this member") - )} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} - end - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, put_flash(socket, :error, gettext("Member not found"))} - - {:error, %Ash.Error.Forbidden{} = _error} -> - {:noreply, - put_flash(socket, :error, gettext("You do not have permission to access this member"))} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} - end - end - @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = @@ -343,22 +302,6 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} end - # Helper to format errors for display - defp format_error(%Ash.Error.Invalid{errors: errors}) do - error_messages = - Enum.map(errors, fn error -> - case error do - %{field: field, message: message} -> "#{field}: #{message}" - %{message: message} -> message - _ -> inspect(error) - end - end) - - Enum.join(error_messages, ", ") - end - - defp format_error(error), do: inspect(error) - # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index c54ec7c..a696b00 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -379,26 +379,10 @@ <:action :let={member}>
- <.link navigate={~p"/members/#{member}"}>{gettext("Show")} + <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} +
- - <%= if can?(@current_user, :update, member) do %> - <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> - {gettext("Edit member")} - - <% end %> - - - <:action :let={member}> - <%= if can?(@current_user, :destroy, member) do %> - <.link - phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} - data-confirm={gettext("Are you sure?")} - data-testid="member-delete" - > - {gettext("Delete")} - - <% end %> diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 3af0ed2..ae69c30 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -54,217 +54,283 @@ defmodule MvWeb.MemberLive.Show do
- <%!-- Tab Navigation --%> -
+ <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> +
<%= if @active_tab == :contact do %> <%!-- Contact Data Tab Content --%> - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field - label={gettext("First Name")} - value={@member.first_name} - class="w-48" - /> - <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> -
+
+ <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.section_box title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+ <.data_field + label={gettext("First Name")} + value={@member.first_name} + class="w-48" + /> + <.data_field + label={gettext("Last Name")} + value={@member.last_name} + class="w-48" + /> +
- <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
+ <%!-- Address --%> +
+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
- <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
+ <%!-- Email --%> +
+ <.data_field label={gettext("Email")}> + + {@member.email} + + +
- <%!-- Membership Dates Row --%> -
- <.data_field - label={gettext("Join Date")} - value={format_date(@member.join_date)} - class="w-28" - /> - <.data_field - label={gettext("Exit Date")} - value={format_date(@member.exit_date)} - class="w-28" - /> -
+ <%!-- Membership Dates Row --%> +
+ <.data_field + label={gettext("Join Date")} + value={format_date(@member.join_date)} + class="w-28" + /> + <.data_field + label={gettext("Exit Date")} + value={format_date(@member.exit_date)} + class="w-28" + /> +
- <%!-- Linked User: only show when current user can see other users (e.g. admin). + <%!-- Linked User: only show when current user can see other users (e.g. admin). read_only cannot see linked user, so hide the section to avoid "No user linked" when a user is linked but not visible. --%> - <%= if can_access_page?(@current_user, "/users") do %> + <%= if can_access_page?(@current_user, "/users") do %> +
+ <.data_field label={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" + > + <.icon name="hero-user" class="size-4" /> + {@member.user.email} + + <% else %> + + {gettext("No user linked")} + + <% end %> + +
+ <% end %> + + <%!-- Groups (in Personal Data) --%> + <% groups = @member.groups || [] %>
- <.data_field label={gettext("Linked User")}> - <%= if @member.user do %> - <.link - navigate={~p"/users/#{@member.user}"} - class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" - > - <.icon name="hero-user" class="size-4" /> - {@member.user.email} - + <.data_field label={gettext("Groups")}> + <%= if Enum.empty?(groups) do %> + {gettext("No groups")} <% else %> - {gettext("No user linked")} +
+ <%= for group <- groups do %> + <.button + variant="outline" + size="sm" + navigate={~p"/groups/#{group.slug}"} + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
<% end %>
- <% end %> - <%!-- Groups (in Personal Data) --%> - <% groups = @member.groups || [] %> -
- <.data_field label={gettext("Groups")}> - <%= if Enum.empty?(groups) do %> - {gettext("No groups")} - <% else %> -
- <%= for group <- groups do %> - <.button - variant="outline" - size="sm" - navigate={~p"/groups/#{group.slug}"} - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> -
- <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> -
- <.data_field label={gettext("Notes")}> -

{@member.notes}

- -
- <% end %> -
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= for custom_field <- @custom_fields do %> - <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> - <.data_field label={custom_field.name}> - {format_custom_field_value(cfv, custom_field.value_type)} - + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
<% end %>
- <% end %> -
- <%!-- Payment Data Section --%> -
- <.section_box title={gettext("Payment Data")}> - <%= if @member.membership_fee_type do %> -
- <.data_field - label={gettext("Type")} - value={@member.membership_fee_type.name} - class="min-w-32" - /> - <.data_field - label={gettext("Membership Fee")} - value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} - class="min-w-24" - /> - <.data_field - label={gettext("Payment Interval")} - value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} - class="min-w-32" - /> - <.data_field label={gettext("Last Cycle")} class="min-w-32"> - <%= if @member.last_cycle_status do %> - <% status = @member.last_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - - <.data_field label={gettext("Current Cycle")} class="min-w-36"> - <%= if @member.current_cycle_status do %> - <% status = @member.current_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - -
- <% else %> -
- {gettext("No membership fee type assigned")} + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for custom_field <- @custom_fields do %> + <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> + <.data_field label={custom_field.name}> + {format_custom_field_value(cfv, custom_field.value_type)} + + <% end %> +
+
<% end %> - +
+ + <%!-- Payment Data Section --%> +
+ <.section_box title={gettext("Payment Data")}> + <%= if @member.membership_fee_type do %> +
+ <.data_field + label={gettext("Type")} + value={@member.membership_fee_type.name} + class="min-w-32" + /> + <.data_field + label={gettext("Membership Fee")} + value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} + class="min-w-24" + /> + <.data_field + label={gettext("Payment Interval")} + value={ + MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval) + } + class="min-w-32" + /> + <.data_field label={gettext("Last Cycle")} class="min-w-32"> + <%= if @member.last_cycle_status do %> + <% status = @member.last_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + + <.data_field label={gettext("Current Cycle")} class="min-w-36"> + <%= if @member.current_cycle_status do %> + <% status = @member.current_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + +
+ <% else %> +
+ {gettext("No membership fee type assigned")} +
+ <% end %> + +
<% end %> <%= if @active_tab == :membership_fees do %> <%!-- Membership Fees Tab Content --%> - <.live_component - module={MvWeb.MemberLive.Show.MembershipFeesComponent} - id={"membership-fees-#{@member.id}"} - member={@member} - current_user={@current_user} - vereinfacht_receipts={@vereinfacht_receipts} - /> +
+ <.live_component + module={MvWeb.MemberLive.Show.MembershipFeesComponent} + id={"membership-fees-#{@member.id}"} + member={@member} + current_user={@current_user} + vereinfacht_receipts={@vereinfacht_receipts} + /> +
+ <% end %> + + <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%> + <%= if can?(@current_user, :destroy, @member) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." + )} +

+ <.button + variant="danger" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext("Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} + +
+
<% end %>
@@ -328,6 +394,35 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + member = socket.assigns.member + actor = current_actor(socket) + + if to_string(id) != to_string(member.id) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this member") + )} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_error(error))} + end + end + end + def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do response = case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do @@ -358,6 +453,19 @@ defmodule MvWeb.MemberLive.Show do defp page_title(:show), do: gettext("Show Member") defp page_title(:edit), do: gettext("Edit Member") + defp format_error(%Ash.Error.Invalid{errors: errors}) do + error_messages = + Enum.map(errors, fn + %{field: field, message: message} -> "#{field}: #{message}" + %{message: message} -> message + _ -> inspect(errors) + end) + + Enum.join(error_messages, ", ") + end + + defp format_error(error), do: inspect(error) + # ----------------------------------------------------------------- # Helper Components # ----------------------------------------------------------------- diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index d61d3fd..fec7df4 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -3,11 +3,38 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do Tests for error handling in the member form, specifically flash message display. """ use MvWeb.ConnCase, async: false + use Gettext, backend: MvWeb.Gettext import Phoenix.LiveViewTest require Ash.Query + describe "tab visibility" do + @tag :ui + test "Payments tab is not visible on new member form", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members/new") + + refute html =~ gettext("Payments") + end + + @tag :ui + test "Payments tab is not visible on edit member form", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Edit", last_name: "Member", email: "edit@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}/edit") + + refute html =~ gettext("Payments") + end + end + describe "error handling - flash messages" do setup do {:ok, settings} = Mv.Membership.get_settings() diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 53a2815..d8846ea 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -266,36 +266,42 @@ defmodule MvWeb.MemberLive.IndexTest do assert is_list(state.socket.assigns.members) end - test "can delete a member without error", %{conn: conn} do + @tag :ui + test "member index does not render Edit or Delete actions", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() - # Create a test member first - {:ok, member} = + {:ok, _member} = Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "User", - email: "test@example.com" - }, + %{first_name: "Test", last_name: "User", email: "test@example.com"}, actor: system_actor ) conn = conn_with_oidc_user(conn) - {:ok, index_view, _html} = live(conn, "/members") + {:ok, view, html} = live(conn, "/members") - # Verify the member is displayed - assert has_element?(index_view, "#members", "Test User") + refute has_element?(view, "[data-testid='member-edit']") + refute html =~ ~s(data-testid="member-delete") + end - # Click the delete link for this member - index_view - |> element("a", "Delete") + @tag :ui + test "row click navigates to member show", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Row", last_name: "Click", email: "rowclick@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click a data cell (e.g. second column = first name) to trigger row navigation + view + |> element("#row-#{member.id} td:nth-child(2)") |> render_click() - # Verify the member is no longer displayed - refute has_element?(index_view, "#members", "Test User") - - # Verify the member was actually deleted from the database - assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) + assert_redirect(view, ~p"/members/#{member}") end describe "copy_emails feature" do diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index 26c3f00..8c7a23a 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -134,6 +134,35 @@ defmodule MvWeb.MemberLive.ShowTest do end end + describe "delete action" do + test "renders Delete button when user can destroy member", %{ + conn: conn, + member: member + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + assert has_element?(view, "[data-testid='member-delete']") + end + + test "delete event removes member and redirects to index", %{ + conn: conn, + member: member + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + view + |> render_click("delete", %{"id" => member.id}) + + assert_redirect(view, ~p"/members") + + refute Mv.Membership.Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.exists?() + end + end + describe "custom field value formatting" do test "formats string custom field values", %{conn: conn, member: member, actor: actor} do {:ok, custom_field} = From 49fd2181a7fdee1ebd5f74ccc906b83797754e7f Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 13:16:27 +0100 Subject: [PATCH 123/237] style: highlight selected table and add tooltip --- DESIGN_DUIDELINES.md | 1 + lib/mv_web/components/core_components.ex | 84 ++++++++- .../components/member_filter_component.ex | 4 +- .../live/custom_field_live/index_component.ex | 1 + lib/mv_web/live/group_live/index.ex | 1 + .../live/member_field_live/index_component.ex | 1 + lib/mv_web/live/member_live/index.ex | 20 ++ lib/mv_web/live/member_live/index.html.heex | 5 +- lib/mv_web/live/member_live/show.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 1 + lib/mv_web/live/role_live/show.ex | 4 +- lib/mv_web/live/user_live/index.html.heex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 176 +++++++++++++----- priv/gettext/default.pot | 151 ++++++++++----- priv/gettext/en/LC_MESSAGES/default.po | 176 +++++++++++++----- .../components/core_components_table_test.exs | 154 +++++++++++++++ .../live/member_live_authorization_test.exs | 14 +- .../index_membership_fee_status_test.exs | 4 +- test/mv_web/member_live/index_test.exs | 38 ++++ 19 files changed, 687 insertions(+), 151 deletions(-) create mode 100644 test/mv_web/components/core_components_table_test.exs diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 18864b5..98e43db 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -209,6 +209,7 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. ### 8.1 Default behavior: row click opens details - **DEFAULT:** Clicking a row navigates to the details page. - **EXCEPTIONS:** Highly interactive rows may disable row-click (document why). +- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index. **IMPORTANT (correctness with our `<.table>` CoreComponent):** Our table implementation attaches the `phx-click` to the **``** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation. diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 1e8e7f3..22aeae7 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -660,6 +660,10 @@ defmodule MvWeb.CoreComponents do Renders a table with generic styling. When `row_click` is set, clicking a row (or a data cell) triggers the handler. + Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring). + When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`), + that row gets a stronger selected outline (ring-primary) for accessibility (not color-only). + The action column has no phx-click on its ``, so action buttons do not trigger row navigation. For interactive elements inside other columns (e.g. checkboxes, buttons), use `Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired. @@ -670,12 +674,36 @@ defmodule MvWeb.CoreComponents do <:col :let={user} label="id">{user.id} <:col :let={user} label="username">{user.username} + + <.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}> + <:col :let={m} label="Name">{m.name} + """ attr :id, :string, required: true attr :rows, :list, required: true attr :row_id, :any, default: nil, doc: "the function for generating the row id" attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + attr :selected_row_id, :any, + default: nil, + doc: + "when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)" + + attr :row_selected?, :any, + default: nil, + doc: + "optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set" + + attr :row_tooltip, :string, + default: nil, + doc: + "optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers." + + attr :row_value_id, :any, + default: nil, + doc: + "optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id" + attr :row_item, :any, default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" @@ -704,6 +732,12 @@ defmodule MvWeb.CoreComponents do assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) end + # Function to get the row's value id for selected_row_id comparison (no extra DB reads) + row_value_id_fn = + assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end + + assigns = assign(assigns, :row_value_id_fn, row_value_id_fn) + ~H"""
@@ -732,9 +766,15 @@ defmodule MvWeb.CoreComponents do - + ` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table. + --- ## 9) Flash / Toast messages (mandatory UX) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 22aeae7..83d506a 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -715,6 +715,11 @@ defmodule MvWeb.CoreComponents do attr :sort_field, :any, default: nil, doc: "current sort field" attr :sort_order, :atom, default: nil, doc: "current sort order" + attr :sticky_header, :boolean, + default: false, + doc: + "when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop" + slot :col, required: true do attr :label, :string attr :class, :string @@ -745,12 +750,12 @@ defmodule MvWeb.CoreComponents do - - @@ -891,6 +896,18 @@ defmodule MvWeb.CoreComponents do end end + # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). + defp table_th_class(col, sticky_header) do + base = Map.get(col, :class) + sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil + [base, sticky] |> Enum.filter(& &1) |> Enum.join(" ") + end + + defp table_th_sticky_class(true), + do: "lg:sticky lg:top-0 bg-base-100 z-10" + + defp table_th_sticky_class(_), do: nil + @doc """ Renders a data list. diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eec49de..709e084 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -90,302 +90,311 @@ /> - <.table - id="members" - rows={@members} - row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} - row_tooltip={gettext("Click for member details")} - row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} - dynamic_cols={@dynamic_cols} - sort_field={@sort_field} - sort_order={@sort_order} + <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%> +
- + <.table + id="members" + rows={@members} + sticky_header={true} + row_id={fn member -> "row-#{member.id}" end} + row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + row_tooltip={gettext("Click for member details")} + row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} + > + - <:col - :let={member} - col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} - label={ - ~H""" + <:col + :let={member} + col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} + label={ + ~H""" + <.input + type="checkbox" + name="select_all" + phx-click="select_all" + checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} + aria-label={gettext("Select all members")} + role="checkbox" + /> + """ + } + > <.input type="checkbox" - name="select_all" - phx-click="select_all" - checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} - aria-label={gettext("Select all members")} + name={member.id} + checked={MapSet.member?(@selected_members, member.id)} + aria-label={gettext("Select member")} role="checkbox" /> - """ - } - > - <.input - type="checkbox" - name={member.id} - checked={MapSet.member?(@selected_members, member.id)} - aria-label={gettext("Select member")} - role="checkbox" - /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - {gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - + + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + {gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} + +
+ + +
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index dd2c4f2..b5a25ce 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -174,7 +174,11 @@ defmodule MvWeb.RoleLive.Show do {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid=role-edit"> + <.button + variant="primary" + navigate={~p"/admin/roles/#{@role}/edit"} + data-testid="role-show-edit-btn" + > {gettext("Edit role")} <% end %> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4561f24..4e6c888 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "Rolle bearbeiten" @@ -3125,16 +3126,6 @@ msgstr "Rolle bearbeiten" msgid "Edit user" msgstr "Benutzer*in bearbeiten" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "Rolle bearbeiten" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3160,11 +3151,26 @@ msgstr "Klicke für Rollen-Details" msgid "Click for user details" msgstr "Klicke für Benutzer*innen-Details" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click for dataield details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Members table" +msgstr "Mitglieder" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "Zurück zu den Einstellungen" +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for custom field details" +#~ msgstr "Klicke für Datenfeld-Details" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" @@ -3175,6 +3181,11 @@ msgstr "Klicke für Benutzer*innen-Details" #~ msgid "Reset" #~ msgstr "Zurücksetzen" +#~ #: lib/mv_web/live/role_live/show.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Rolle bearbeiten" +#~ msgstr "Rolle bearbeiten" + #~ #: lib/mv_web/live/role_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Save Role" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index cea7991..ed020a0 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Edit role" msgstr "" @@ -3125,16 +3126,6 @@ msgstr "" msgid "Edit user" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3159,3 +3150,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Click for user details" msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for dataield details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Members table" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9f38efe..a44e87c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "" @@ -3125,16 +3126,6 @@ msgstr "" msgid "Edit user" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3160,11 +3151,26 @@ msgstr "" msgid "Click for user details" msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click for dataield details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Members table" +msgstr "" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "" +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for custom field details" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" @@ -3175,6 +3181,11 @@ msgstr "" #~ msgid "Reset" #~ msgstr "" +#~ #: lib/mv_web/live/role_live/show.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Rolle bearbeiten" +#~ msgstr "" + #~ #: lib/mv_web/live/role_live/form.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Save Role" diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 1c8328f..b75fcd8 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,6 +46,35 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: actor) end + describe "desktop layout: scroll container and sticky table header" do + @describetag :ui + + test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto", + %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert html =~ ~r/data-testid="members-table-scroll"/ + # Scroll container has lg: overflow and max-height for desktop-only scroll + assert html =~ "lg:overflow-auto" + assert html =~ "lg:max-h-[calc(100vh-14rem)]" + + # Header (page title) is present and not inside the scroll container (scroll container comes after filters) + assert html =~ "Members" + assert html =~ "id=\"members\"" + end + + test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + # CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th + assert html =~ "lg:sticky" + assert html =~ "lg:top-0" + assert html =~ "bg-base-100" + end + end + describe "translations" do @describetag :ui From 91cf7cca6a7dcd0caf8165ec63079ba8493ceefc Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 15:09:37 +0100 Subject: [PATCH 125/237] feat: conistent danger zone delete flow --- DESIGN_DUIDELINES.md | 43 ++++++++ .../live/custom_field_live/form_component.ex | 36 +++++++ .../live/custom_field_live/index_component.ex | 60 +++++++----- lib/mv_web/live/datafields_live.ex | 11 +++ lib/mv_web/live/group_live/form.ex | 25 +++++ lib/mv_web/live/group_live/index.ex | 18 ---- lib/mv_web/live/group_live/show.ex | 55 ++++++++--- .../live/member_field_live/index_component.ex | 12 +-- lib/mv_web/live/member_live/form.ex | 84 ++++++++++++++++ lib/mv_web/live/member_live/show.ex | 2 + lib/mv_web/live/role_live/index.ex | 98 +------------------ lib/mv_web/live/role_live/index.html.heex | 42 -------- lib/mv_web/live/role_live/show.ex | 40 ++++++-- lib/mv_web/live/user_live/form.ex | 71 ++++++++++++++ lib/mv_web/live/user_live/index.ex | 45 +-------- lib/mv_web/live/user_live/index.html.heex | 24 ----- lib/mv_web/live/user_live/show.ex | 67 +++++++++++++ .../member_live/form_error_handling_test.exs | 47 +++++++++ test/mv_web/member_live/show_test.exs | 6 +- 19 files changed, 499 insertions(+), 287 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 37428a3..e3faf50 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -365,3 +365,46 @@ Detail pages should not drift into random layouts. Add to this glossary when new terminology appears. --- + +## 14) Destructive actions: Delete flow (canonical) + +This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere. + +### Tables: no row action buttons +- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views. +- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below. + +### Navigation: row click → details +- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`). +- **MUST NOT:** Use the table for primary edit/delete actions. + +### Edit: from details header, not from table +- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”). +- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table. + +### Delete: only via “Danger zone” +- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page. +- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource. +- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone. + +### Danger zone layout and wording (canonical pattern) +- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`). +- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text. +- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`). +- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone. + +### Confirmation and button semantics +- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow. +- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”). +- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action. + +### Accessibility +- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above). +- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user. +- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA). + +### Authorization visibility +- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`). +- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users. + +--- diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 8e59ac9..5f09a8d 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -98,6 +98,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do label={gettext("Show in overview")} /> + <%= if @custom_field do %> + <%!-- Danger zone: canonical pattern (same as member form) --%> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this data field cannot be undone. All custom field values for this field will be permanently removed." + )} +

+ <.button + type="button" + variant="danger" + phx-click="request_delete" + phx-target={@myself} + data-testid="custom-field-delete" + aria-label={gettext("Delete data field %{name}", name: @custom_field.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete data field")} + +
+
+ <% end %> +
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> {gettext("Cancel")} @@ -170,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do {:noreply, socket} end + @impl true + def handle_event("request_delete", _params, socket) do + if custom_field = socket.assigns[:custom_field] do + send(self(), {:open_delete_modal_for, custom_field}) + end + + {:noreply, socket} + end + defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do form = if custom_field do diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index ebc4930..b0e9862 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,7 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) end } - row_tooltip={gettext("Click for dataield details")} + row_tooltip={gettext("Click to edit datafield")} > <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} @@ -96,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do {gettext("No")} - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Edit datafield")} - - - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Delete")} - -
@@ -223,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do # Get actor from assigns or fall back to socket assigns actor = Map.get(assigns, :actor, socket.assigns[:actor]) - {:ok, - socket - |> assign(assigns) - |> assign_new(:show_form, fn -> false end) - |> assign_new(:form_id, fn -> "custom-field-form-new" end) - |> assign_new(:editing_custom_field, fn -> nil end) - |> assign_new(:show_delete_modal, fn -> false end) - |> assign_new(:custom_field_to_delete, fn -> nil end) - |> assign_new(:slug_confirmation, fn -> "" end) - |> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)} + socket = + socket + |> assign(assigns) + |> assign_new(:show_form, fn -> false end) + |> assign_new(:form_id, fn -> "custom-field-form-new" end) + |> assign_new(:editing_custom_field, fn -> nil end) + |> assign_new(:show_delete_modal, fn -> false end) + |> assign_new(:custom_field_to_delete, fn -> nil end) + |> assign_new(:slug_confirmation, fn -> "" end) + |> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true) + + # Open delete modal when requested from form (e.g. Danger zone in FormComponent) + socket = + case Map.get(assigns, :open_delete_for_id) do + nil -> + socket + + id -> + custom_field = + Ash.get!(Mv.Membership.CustomField, id, + load: [:assigned_members_count], + actor: actor + ) + + socket + |> assign(:show_delete_modal, true) + |> assign(:custom_field_to_delete, custom_field) + |> assign(:slug_confirmation, "") + |> assign(:open_delete_for_id, nil) + end + + {:ok, socket} end @impl true diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex index f922d22..0fc4c3c 100644 --- a/lib/mv_web/live/datafields_live.ex +++ b/lib/mv_web/live/datafields_live.ex @@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do {:noreply, assign(socket, :active_editing_section, section)} end + # Open delete modal for custom field (triggered from Danger zone in FormComponent) + @impl true + def handle_info({:open_delete_modal_for, custom_field}, socket) do + send_update(MvWeb.CustomFieldLive.IndexComponent, + id: "custom-fields-component", + open_delete_for_id: custom_field.id + ) + + {:noreply, socket} + end + @impl true def handle_info({:member_field_saved, _member_field, action}, socket) do {:ok, updated_settings} = Membership.get_settings() diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index d9999d3..490214f 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -101,6 +101,31 @@ defmodule MvWeb.GroupLive.Form do rows="4" /> + + <%!-- Danger zone: canonical pattern (same as member form) --%> + <%= if @group && can?(@current_user, :destroy, @group) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this group cannot be undone. All member-group associations will be permanently removed." + )} +

+ <.button + variant="danger" + navigate={~p"/groups/#{@group.slug}?confirm_delete=1"} + data-testid="group-form-delete-btn" + aria-label={gettext("Delete group %{name}", name: @group.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete group")} + +
+
+ <% end %> diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index 76663fd..ff22b91 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -77,24 +77,6 @@ defmodule MvWeb.GroupLive.Index do <:col :let={group} label={gettext("Members")} class="text-right"> {group.member_count || 0} - <:action :let={group}> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}"} - > - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}/edit"} - > - {gettext("Edit group")} - - <% end %> - <% end %> diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7e2d57f..d970f2a 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do end @impl true - def handle_params(%{"slug" => slug}, _url, socket) do + def handle_params(%{"slug" => slug} = params, _url, socket) do actor = current_actor(socket) # Check if user can read groups if can?(actor, :read, Mv.Membership.Group) do - load_group_by_slug(socket, slug, actor) + load_group_by_slug(socket, slug, actor, params) else {:noreply, redirect(socket, to: ~p"/members")} end end - defp load_group_by_slug(socket, slug, actor) do + defp load_group_by_slug(socket, slug, actor, params \\ %{}) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query @@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do |> redirect(to: ~p"/groups")} {:ok, group} -> - {:noreply, - socket - |> assign(:page_title, group.name) - |> assign(:group, group)} + open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group) + + socket = + socket + |> assign(:page_title, group.name) + |> assign(:group, group) + |> assign(:show_delete_modal, open_delete) + |> assign(:name_confirmation, "") + + {:noreply, socket} {:error, _error} -> {:noreply, @@ -105,15 +111,6 @@ defmodule MvWeb.GroupLive.Show do {gettext("Edit group")} <% end %> - <%= if can?(@current_user, :destroy, @group) do %> - <.button - variant="danger" - phx-click="open_delete_modal" - data-testid="group-show-delete-btn" - > - {gettext("Delete")} - - <% end %> @@ -339,6 +336,32 @@ defmodule MvWeb.GroupLive.Show do + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, @group) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this group cannot be undone. All member-group associations will be permanently removed." + )} +

+ <.button + variant="danger" + type="button" + phx-click="open_delete_modal" + data-testid="group-show-delete-btn" + aria-label={gettext("Delete group %{name}", name: @group.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete group")} + +
+
+ <% end %> + <%!-- Delete Confirmation Modal --%> <%= if assigns[:show_delete_modal] do %> diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 419b585..28384b5 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -57,7 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) end } - row_tooltip={gettext("Click for datafield details")} + row_tooltip={gettext("Click to edit datafield")} > <:col :let={{_field_name, field_data}} label={gettext("Name")}> {MemberFields.label(field_data.field)} @@ -92,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do {gettext("No")} - - <:action :let={{_field_name, field_data}}> - <.link - phx-click="edit_member_field" - phx-value-field={Atom.to_string(field_data.field)} - phx-target={@myself} - > - {gettext("Edit datafield")} - - """ diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 9138236..1875205 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -20,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do """ use MvWeb, :live_view + require Logger import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] alias Mv.Membership @@ -246,6 +247,42 @@ defmodule MvWeb.MemberLive.Form do {gettext("Save Member")} + + <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." + )} +

+ <.button + variant="danger" + type="button" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext("Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} + +
+
+ <% end %> @@ -366,6 +403,40 @@ defmodule MvWeb.MemberLive.Form do end end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + member = socket.assigns.member + actor = current_actor(socket) + + if is_nil(member) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + if to_string(id) != to_string(member.id) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this member") + )} + + {:error, error} -> + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") + {:noreply, put_flash(socket, :error, format_destroy_error(error))} + end + end + end + end + defp handle_save_success(socket, member) do notify_parent({:saved, member}) @@ -413,6 +484,19 @@ defmodule MvWeb.MemberLive.Form do end end + defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do + error_messages = + Enum.map(errors, fn + %{field: field, message: message} -> "#{field}: #{message}" + %{message: message} -> message + _ -> inspect(errors) + end) + + Enum.join(error_messages, ", ") + end + + defp format_destroy_error(error), do: inspect(error) + defp handle_save_error(socket, form) do # Always show a flash message when save fails # Field-level validation errors are displayed in form fields, but flash provides additional feedback diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 6757646..63349be 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -418,6 +418,8 @@ defmodule MvWeb.MemberLive.Show do )} {:error, error} -> + require Logger + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") {:noreply, put_flash(socket, :error, format_error(error))} end end diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 2169400..ed64eb7 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do ## Features - List all roles with name, description, permission_set_name, is_system_role - Create new roles - - Navigate to role details and edit forms - - Delete non-system roles - - ## Events - - `delete` - Remove a role from the database (only non-system roles) + - Navigate to role details (row click) and edit from details header + - Delete only via Danger zone on role show page ## Security Only admins can access this page (enforced by authorization). @@ -21,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, - only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3] + import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1] @impl true def mount(_params, _session, socket) do @@ -37,83 +33,6 @@ defmodule MvWeb.RoleLive.Index do |> assign(:user_counts, user_counts)} end - @impl true - def handle_event("delete", %{"id" => id}, socket) do - case Authorization.get_role(id, actor: socket.assigns.current_user) do - {:ok, role} -> - handle_delete_role(role, id, socket) - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("Role not found.") - )} - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to delete role: %{error}", error: error_message) - )} - end - end - - defp handle_delete_role(role, id, socket) do - if role.is_system_role do - {:noreply, - put_flash( - socket, - :error, - gettext("System roles cannot be deleted.") - )} - else - user_count = recalculate_user_count(role, socket.assigns.current_user) - - if user_count > 0 do - {:noreply, - put_flash( - socket, - :error, - gettext( - "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.", - count: user_count - ) - )} - else - perform_role_deletion(role, id, socket) - end - end - end - - defp perform_role_deletion(role, id, socket) do - case Authorization.destroy_role(role, actor: socket.assigns.current_user) do - :ok -> - updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id)) - updated_counts = Map.delete(socket.assigns.user_counts, id) - - {:noreply, - socket - |> assign(:roles, updated_roles) - |> assign(:user_counts, updated_counts) - |> put_flash(:success, gettext("Role deleted successfully."))} - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to delete role: %{error}", error: error_message) - )} - end - end - @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()] defp load_roles(actor) do opts = MvWeb.LiveHelpers.ash_actor_opts(actor) @@ -154,15 +73,4 @@ defmodule MvWeb.RoleLive.Index do defp get_user_count(role, user_counts) do Map.get(user_counts, role.id, 0) end - - # Recalculates user count for a specific role (used before deletion) - @spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer() - defp recalculate_user_count(role, actor) do - opts = opts_with_actor([], actor, Mv.Accounts) - - case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do - {:ok, count} -> count - _ -> 0 - end - end end diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 5947472..43f2fc7 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -53,47 +53,5 @@ <:col :let={role} label={gettext("Users")}> {get_user_count(role, @user_counts)} - - <:action :let={role}> -
- <.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")} -
- - <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}> - <.icon name="hero-pencil" class="size-4" /> - {gettext("Edit role")} - - <% end %> - - - <:action :let={role}> - <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> - <.button - variant="danger" - size="sm" - phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} - data-confirm={gettext("Are you sure?")} - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete")} - - <% else %> - <.tooltip - :if={role.is_system_role} - content={gettext("System roles cannot be deleted")} - position="left" - > - - - <% end %> - diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index b5a25ce..8b615b6 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -182,15 +182,6 @@ defmodule MvWeb.RoleLive.Show do {gettext("Edit role")} <% end %> - <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> - <.button - variant="danger" - phx-click={JS.push("delete", value: %{id: @role.id})} - data-confirm={gettext("Are you sure?")} - > - <.icon name="hero-trash" /> {gettext("Delete Role")} - - <% end %> @@ -216,6 +207,37 @@ defmodule MvWeb.RoleLive.Show do <% end %> + + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." + )} +

+ <.button + variant="danger" + phx-click={JS.push("delete", value: %{id: @role.id})} + data-confirm={ + gettext( + "Are you sure you want to delete the role %{name}? This action cannot be undone.", + name: @role.name + ) + } + data-testid="role-delete" + aria-label={gettext("Delete role %{name}", name: @role.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete role")} + +
+
+ <% end %> """ end diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 8ff5966..34defe1 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -39,6 +39,7 @@ defmodule MvWeb.UserLive.Form do import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.Authorization, only: [can?: 3] + import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def render(assigns) do @@ -281,6 +282,38 @@ defmodule MvWeb.UserLive.Form do <% end %> + <%!-- Danger zone: canonical pattern (same as member form) --%> + <%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this user cannot be undone. The user account and any linked member association will be affected." + )} +

+ <.button + type="button" + variant="danger" + phx-click="delete" + phx-value-id={@user.id} + data-confirm={ + gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + email: @user.email + ) + } + data-testid="user-delete" + aria-label={gettext("Delete user %{email}", email: @user.email)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete user")} + +
+
+ <% end %> +
<.button navigate={return_path(@return_to, @user)} variant="neutral"> {gettext("Cancel")} @@ -404,6 +437,44 @@ defmodule MvWeb.UserLive.Form do end end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = socket.assigns.user + actor = current_actor(socket) + + if is_nil(user) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if to_string(id) != to_string(user.id) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if Mv.Helpers.SystemActor.system_user?(user) do + {:noreply, + put_flash(socket, :error, gettext("System user cannot be deleted."))} + else + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this user") + )} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} + end + end + end + end + end + @impl true def handle_event("show_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: true)} diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index ba36605..d72c1fd 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -5,15 +5,14 @@ defmodule MvWeb.UserLive.Index do ## Features - List all users with email and linked member - Sort users by email (default) - - Delete users - - Navigate to user details and edit forms + - Navigate to user details (row click) and edit from details header + - Delete only via Danger zone on user show/edit - Bulk selection for future batch operations ## Relationships Displays linked member information when a user is connected to a member account. ## Events - - `delete` - Remove a user from the database - `select_user` - Toggle individual user selection - `select_all` - Toggle selection of all visible users @@ -26,7 +25,6 @@ defmodule MvWeb.UserLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] require Ash.Query - import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def mount(_params, _session, socket) do @@ -48,45 +46,6 @@ defmodule MvWeb.UserLive.Index do |> assign(:selected_users, [])} end - @impl true - def handle_event("delete", %{"id" => id}, socket) do - actor = current_actor(socket) - - case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do - {:ok, user} -> - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - updated_users = Enum.reject(socket.assigns.users, &(&1.id == id)) - - {:noreply, - socket - |> assign(:users, updated_users) - |> put_flash(:success, gettext("User deleted successfully"))} - - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, put_flash(socket, :error, gettext("User not found"))} - - {:error, %Ash.Error.Forbidden{} = _error} -> - {:noreply, - put_flash(socket, :error, gettext("You do not have permission to access this user"))} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - end - # Selects one user in the list of users @impl true def handle_event("select_user", %{"id" => id}, socket) do diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 858e784..7ffa0e3 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -84,29 +84,5 @@ <% end %> - - <:action :let={user}> -
- <.link navigate={~p"/users/#{user}"}>{gettext("Show")} -
- - <%= if can?(@current_user, :update, user) do %> - <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit"> - {gettext("Edit user")} - - <% end %> - - - <:action :let={user}> - <%= if can?(@current_user, :destroy, user) do %> - <.link - phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")} - data-confirm={gettext("Are you sure?")} - data-testid="user-delete" - > - {gettext("Delete")} - - <% end %> - diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 3530b36..a77a1c4 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -27,6 +27,7 @@ defmodule MvWeb.UserLive.Show do use MvWeb, :live_view import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def render(assigns) do @@ -80,6 +81,37 @@ defmodule MvWeb.UserLive.Show do <% end %> + + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this user cannot be undone. The user account and any linked member association will be affected." + )} +

+ <.button + variant="danger" + phx-click="delete" + phx-value-id={@user.id} + data-confirm={ + gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + email: @user.email + ) + } + data-testid="user-delete" + aria-label={gettext("Delete user %{email}", email: @user.email)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete user")} + +
+
+ <% end %> """ end @@ -103,4 +135,39 @@ defmodule MvWeb.UserLive.Show do |> assign(:user, user)} end end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = socket.assigns.user + actor = current_actor(socket) + + if to_string(id) != to_string(user.id) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if Mv.Helpers.SystemActor.system_user?(user) do + {:noreply, + put_flash(socket, :error, gettext("System user cannot be deleted."))} + else + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this user") + )} + + {:error, error} -> + {:noreply, + put_flash(socket, :error, format_ash_error(error))} + end + end + end + end end diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index fec7df4..0ec142e 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -9,6 +9,53 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do require Ash.Query + describe "danger zone on edit" do + @tag :ui + test "edit form shows Danger zone and delete button when user can destroy member", %{ + conn: conn + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Delete", last_name: "FromEdit", email: "delete.from.edit@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, ~p"/members/#{member}/edit") + + assert html =~ gettext("Danger zone") + assert has_element?(view, "[data-testid='member-delete']") + end + + test "delete event from edit form removes member and redirects to /members", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "ToDelete", + last_name: "FromForm", + email: "todelete.from.form.#{System.unique_integer([:positive])}@example.com" + }, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}/edit") + + view + |> render_click("delete", %{"id" => member.id}) + + assert_redirect(view, ~p"/members") + + refute Mv.Membership.Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.exists?() + end + end + describe "tab visibility" do @tag :ui test "Payments tab is not visible on new member form", %{conn: conn} do diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index 8c7a23a..54829de 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -135,14 +135,16 @@ defmodule MvWeb.MemberLive.ShowTest do end describe "delete action" do - test "renders Delete button when user can destroy member", %{ + test "renders Danger zone section and Delete button when user can destroy member", %{ conn: conn, member: member } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, ~p"/members/#{member}") + {:ok, view, html} = live(conn, ~p"/members/#{member}") assert has_element?(view, "[data-testid='member-delete']") + assert html =~ gettext("Danger zone") + assert has_element?(view, "section[aria-labelledby='danger-zone-heading']") end test "delete event removes member and redirects to index", %{ From 0f12befd11f3f7b2e2c4d3828e9c6e062ff60546 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 16:25:13 +0100 Subject: [PATCH 126/237] style: consistent back button and some translations --- CODE_GUIDELINES.md | 2 +- DESIGN_DUIDELINES.md | 30 +- lib/mv_web/components/core_components.ex | 17 +- .../field_visibility_dropdown_component.ex | 2 +- .../components/member_filter_component.ex | 2 +- .../live/custom_field_live/form_component.ex | 2 +- .../live/custom_field_live/index_component.ex | 2 +- lib/mv_web/live/global_settings_live.ex | 2 +- lib/mv_web/live/group_live/form.ex | 6 +- lib/mv_web/live/group_live/show.ex | 10 +- lib/mv_web/live/member_live/form.ex | 57 ++-- lib/mv_web/live/member_live/show.ex | 8 +- .../live/membership_fee_type_live/form.ex | 16 ++ lib/mv_web/live/role_live/form.ex | 8 +- lib/mv_web/live/role_live/show.ex | 12 +- lib/mv_web/live/user_live/form.ex | 72 +++-- lib/mv_web/live/user_live/index.ex | 42 +-- lib/mv_web/live/user_live/index.html.heex | 27 -- lib/mv_web/live/user_live/show.ex | 64 +++-- priv/gettext/de/LC_MESSAGES/default.po | 265 ++++++++++++------ priv/gettext/default.pot | 214 ++++++++------ priv/gettext/en/LC_MESSAGES/default.po | 265 ++++++++++++------ .../live/custom_field_live/deletion_test.exs | 59 ++-- test/mv_web/live/role_live_test.exs | 52 ++-- .../live/user_live_authorization_test.exs | 12 +- test/mv_web/user_live/index_test.exs | 209 ++------------ 26 files changed, 747 insertions(+), 710 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 68e7887..d4769f3 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -61,7 +61,7 @@ We are building a membership management system (Mila) using the following techno 8. [Accessibility Guidelines](#8-accessibility-guidelines) **Related documents:** -- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. +- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. --- diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index e3faf50..fc3acac 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -46,19 +46,35 @@ Every authenticated page should follow the same structure: **MUST:** Use `<.header>` on every page (except login/public pages). **SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks. -**Template:** +### 2.2 Edit/New form header: Back button left (mandatory) + +For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type): + +- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right). +- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper. +- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right. +- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right. + +**Template for form pages:** ```heex <.header> - Title - <:subtitle>Short explanation of what the page is for. + <:leading> + <.button navigate={return_path(@return_to, @resource)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + + Page title (e.g. “Edit Member” or “New User”) + <:subtitle>Short explanation. <:actions> - <.button variant="primary" navigate={...}>Primary action + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + +``` -
- -
+If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). ## 3) Typography (system) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 83d506a..85c26c7 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -111,7 +111,7 @@ defmodule MvWeb.CoreComponents do <.button variant="ghost" size="sm">Edit <.button disabled={true}>Disabled """ - attr :rest, :global, include: ~w(href navigate patch method data-testid) + attr :rest, :global, include: ~w(href navigate patch method data-testid form) attr :variant, :string, values: ~w(primary secondary neutral ghost outline danger link icon), @@ -633,17 +633,24 @@ defmodule MvWeb.CoreComponents do @doc """ Renders a header with title. + + Use the `:leading` slot for the Back button (left side, consistent with data fields). + Use the `:actions` slot for primary actions (e.g. Save) on the right. """ attr :class, :string, default: nil + slot :leading, doc: "Content on the left (e.g. Back button)" slot :inner_block, required: true slot :subtitle slot :actions def header(assigns) do ~H""" -
-
+
+
+ {render_slot(@leading)} +
+

{render_slot(@inner_block)}

@@ -651,7 +658,9 @@ defmodule MvWeb.CoreComponents do {render_slot(@subtitle)}

-
{render_slot(@actions)}
+
+ {render_slot(@actions)} +
""" end diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex index a8e8d45..58777da 100644 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex @@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do defp find_custom_field_name(id, _field_string, custom_fields) do case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do - nil -> gettext("Custom Field %{id}", id: id) + nil -> gettext("Datafield %{id}", id: id) custom_field -> custom_field.name end end diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index b837bc0..4ee72d3 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -252,7 +252,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
0} class="mb-2">
- {gettext("Custom Fields")} + {gettext("Individual datafields")}

{gettext( - "Deleting this data field cannot be undone. All custom field values for this field will be permanently removed." + "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." )}

<.button diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index b0e9862..3b70c3d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -158,7 +158,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do phx-target={@myself} disabled={@slug_confirmation != @custom_field_to_delete.slug} > - {gettext("Delete Custom Field and All Values")} + {gettext("Delete Datafields and All Values")}
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 485601a..bb8eb32 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Settings")} + {gettext("Save Name")} diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index 490214f..2e79a7f 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -79,12 +79,14 @@ defmodule MvWeb.GroupLive.Form do <.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> <.header> - {@page_title} - <:actions> + <:leading> <.button navigate={return_path(@return_to, @group)} variant="neutral"> <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@page_title} + <:actions> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index d970f2a..dbc0523 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -50,7 +50,7 @@ defmodule MvWeb.GroupLive.Show do end end - defp load_group_by_slug(socket, slug, actor, params \\ %{}) do + defp load_group_by_slug(socket, slug, actor, params) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query @@ -92,8 +92,7 @@ defmodule MvWeb.GroupLive.Show do ~H""" <.header> - {@group.name} - <:actions> + <:leading> <.button navigate={~p"/groups"} variant="neutral" @@ -102,13 +101,16 @@ defmodule MvWeb.GroupLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@group.name} + <:actions> <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"} data-testid="group-show-edit-btn" > - {gettext("Edit group")} + <.icon name="hero-pencil-square" /> {gettext("Edit group")} <% end %> diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 1875205..d0eaabf 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -39,16 +39,18 @@ defmodule MvWeb.MemberLive.Form do <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.header> + <:leading> + <.button navigate={return_path(@return_to, @member)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + <%= if @member do %> {MvWeb.Helpers.MemberHelpers.display_name(@member)} <% else %> {gettext("New Member")} <% end %> <:actions> - <.button navigate={return_path(@return_to, @member)} variant="neutral"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} @@ -408,32 +410,33 @@ defmodule MvWeb.MemberLive.Form do member = socket.assigns.member actor = current_actor(socket) - if is_nil(member) do - {:noreply, put_flash(socket, :error, gettext("Member not found"))} - else - if to_string(id) != to_string(member.id) do + cond do + is_nil(member) -> {:noreply, put_flash(socket, :error, gettext("Member not found"))} - else - case Ash.destroy(member, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("Member deleted successfully")) - |> push_navigate(to: ~p"/members")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this member") - )} + to_string(id) != to_string(member.id) -> + {:noreply, put_flash(socket, :error, gettext("Member not found"))} - {:error, error} -> - Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") - {:noreply, put_flash(socket, :error, format_destroy_error(error))} - end - end + true -> + handle_member_delete_destroy(socket, member, actor) + end + end + + defp handle_member_delete_destroy(socket, member, actor) do + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this member"))} + + {:error, error} -> + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") + {:noreply, put_flash(socket, :error, format_destroy_error(error))} end end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 63349be..ecd2d51 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -31,8 +31,7 @@ defmodule MvWeb.MemberLive.Show do ~H""" <.header> - {MvWeb.Helpers.MemberHelpers.display_name(@member)} - <:actions> + <:leading> <.button navigate={~p"/members?highlight=#{@member.id}"} variant="neutral" @@ -41,13 +40,16 @@ defmodule MvWeb.MemberLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {MvWeb.Helpers.MemberHelpers.display_name(@member)} + <:actions> <%= if can?(@current_user, :update, @member) do %> <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"} data-testid="member-edit" > - {gettext("Edit member")} + <.icon name="hero-pencil-square" /> {gettext("Edit member")} <% end %> diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index ca61e19..237b4b4 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -27,10 +27,26 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do ~H""" <.header> + <:leading> + <.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {@page_title} <:subtitle> {gettext("Use this form to manage membership fee types in your database.")} + <:actions> + <.button + form="membership-fee-type-form" + phx-disable-with={gettext("Saving...")} + variant="primary" + type="submit" + > + {gettext("Save")} + + <.form diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index 684e695..e066555 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -23,13 +23,15 @@ defmodule MvWeb.RoleLive.Form do <.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save"> <.header> - {@page_title} - <:subtitle>{gettext("Use this form to manage roles in your database.")} - <:actions> + <:leading> <.button navigate={return_path(@return_to, @role)} variant="neutral"> <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@page_title} + <:subtitle>{gettext("Use this form to manage roles in your database.")} + <:actions> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 8b615b6..4f36eca 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -161,10 +161,7 @@ defmodule MvWeb.RoleLive.Show do ~H""" <.header> - {gettext("Role")} {@role.name} - <:subtitle>{gettext("Role details and permissions.")} - - <:actions> + <:leading> <.button navigate={~p"/admin/roles"} variant="neutral" @@ -173,13 +170,18 @@ defmodule MvWeb.RoleLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {gettext("Role")} {@role.name} + <:subtitle>{gettext("Role details and permissions.")} + + <:actions> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid="role-show-edit-btn" > - {gettext("Edit role")} + <.icon name="hero-pencil-square" /> {gettext("Edit role")} <% end %> diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 34defe1..f7c440d 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -46,8 +46,24 @@ defmodule MvWeb.UserLive.Form do ~H""" <.header> + <:leading> + <.button navigate={return_path(@return_to, @user)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {@page_title} <:subtitle>{gettext("Use this form to manage user records in your database.")} + <:actions> + <.button + form="user-form" + phx-disable-with={gettext("Saving...")} + variant="primary" + type="submit" + > + {gettext("Save User")} + + <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> @@ -300,7 +316,8 @@ defmodule MvWeb.UserLive.Form do phx-click="delete" phx-value-id={@user.id} data-confirm={ - gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + gettext( + "Are you sure you want to delete the user %{email}? This action cannot be undone.", email: @user.email ) } @@ -442,36 +459,18 @@ defmodule MvWeb.UserLive.Form do user = socket.assigns.user actor = current_actor(socket) - if is_nil(user) do - {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if to_string(id) != to_string(user.id) do + cond do + is_nil(user) -> {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if Mv.Helpers.SystemActor.system_user?(user) do - {:noreply, - put_flash(socket, :error, gettext("System user cannot be deleted."))} - else - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("User deleted successfully")) - |> push_navigate(to: ~p"/users")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} + to_string(id) != to_string(user.id) -> + {:noreply, put_flash(socket, :error, gettext("User not found"))} - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - end - end + Mv.Helpers.SystemActor.system_user?(user) -> + {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))} + + true -> + handle_user_delete_destroy(socket, user, actor) end end @@ -585,6 +584,23 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end + defp handle_user_delete_destroy(socket, user, actor) do + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this user"))} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} + end + end + defp handle_member_linking(socket, user, actor) do result = perform_member_link_action(socket, user, actor) diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index d72c1fd..4858202 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -7,15 +7,10 @@ defmodule MvWeb.UserLive.Index do - Sort users by email (default) - Navigate to user details (row click) and edit from details header - Delete only via Danger zone on user show/edit - - Bulk selection for future batch operations ## Relationships Displays linked member information when a user is connected to a member account. - ## Events - - `select_user` - Toggle individual user selection - - `select_all` - Toggle selection of all visible users - ## Security User deletion requires admin permissions (enforced by Ash policies). """ @@ -42,24 +37,7 @@ defmodule MvWeb.UserLive.Index do |> assign(:page_title, gettext("Listing Users")) |> assign(:sort_field, :email) |> assign(:sort_order, :asc) - |> assign(:users, sorted) - |> assign(:selected_users, [])} - end - - # Selects one user in the list of users - @impl true - def handle_event("select_user", %{"id" => id}, socket) do - # Normalize ID to string for consistent comparison - id_str = to_string(id) - - selected = - if id_str in socket.assigns.selected_users do - List.delete(socket.assigns.selected_users, id_str) - else - [id_str | socket.assigns.selected_users] - end - - {:noreply, assign(socket, :selected_users, selected)} + |> assign(:users, sorted)} end # Sorts the list of users according to a field, when you click on the column header @@ -86,24 +64,6 @@ defmodule MvWeb.UserLive.Index do |> assign(:users, sorted_users)} end - # Selects all users in the list of users - @impl true - def handle_event("select_all", _params, socket) do - users = socket.assigns.users - - # Normalize IDs to strings for consistent comparison - all_ids = Enum.map(users, &to_string(&1.id)) - - selected = - if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do - [] - else - all_ids - end - - {:noreply, assign(socket, :selected_users, selected)} - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp sort_fun(:asc), do: &<=/2 diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 7ffa0e3..86f0ab7 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -19,33 +19,6 @@ sort_field={@sort_field} sort_order={@sort_order} > - <:col - :let={user} - label={ - ~H""" - <.input - type="checkbox" - name="select_all" - phx-click="select_all" - checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()} - aria-label={gettext("Select all users")} - role="checkbox" - /> - """ - } - > - <.input - type="checkbox" - name={to_string(user.id)} - phx-click="select_user" - phx-value-id={to_string(user.id)} - checked={to_string(user.id) in @selected_users} - phx-capture-click - phx-stop-propagation - aria-label={gettext("Select user")} - role="checkbox" - /> - <:col :let={user} sort_field={:email} diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index a77a1c4..d7a12b2 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -34,14 +34,20 @@ defmodule MvWeb.UserLive.Show do ~H""" <.header> + <:leading> + <.button + navigate={~p"/users"} + variant="neutral" + aria-label={gettext("Back to users list")} + > + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {gettext("User")} {@user.email} <:subtitle>{gettext("This is a user record from your database.")} <:actions> - <.button navigate={~p"/users"} variant="neutral" aria-label={gettext("Back to users list")}> - <.icon name="hero-arrow-left" /> - {gettext("Back to users list")} - <%= if can?(@current_user, :update, @user) do %> <.button variant="primary" @@ -99,7 +105,8 @@ defmodule MvWeb.UserLive.Show do phx-click="delete" phx-value-id={@user.id} data-confirm={ - gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + gettext( + "Are you sure you want to delete the user %{email}? This action cannot be undone.", email: @user.email ) } @@ -141,33 +148,32 @@ defmodule MvWeb.UserLive.Show do user = socket.assigns.user actor = current_actor(socket) - if to_string(id) != to_string(user.id) do - {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if Mv.Helpers.SystemActor.system_user?(user) do + cond do + to_string(id) != to_string(user.id) -> + {:noreply, put_flash(socket, :error, gettext("User not found"))} + + Mv.Helpers.SystemActor.system_user?(user) -> + {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))} + + true -> + handle_user_delete_destroy(socket, user, actor) + end + end + + defp handle_user_delete_destroy(socket, user, actor) do + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> {:noreply, - put_flash(socket, :error, gettext("System user cannot be deleted."))} - else - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("User deleted successfully")) - |> push_navigate(to: ~p"/users")} + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this user"))} - {:error, error} -> - {:noreply, - put_flash(socket, :error, format_ash_error(error))} - end - end + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} end end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4e6c888..1bd57e1 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -18,9 +18,6 @@ msgstr "Aktionen" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -38,11 +35,8 @@ msgstr "Verbindung wird wiederhergestellt" msgid "City" msgstr "Stadt" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" @@ -100,8 +94,6 @@ msgid "New Member" msgstr "Neues Mitglied" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -480,16 +472,6 @@ msgstr "Passwort" msgid "Password requirements" msgstr "Passwort-Anforderungen" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select all users" -msgstr "Alle Benutzer*innen auswählen" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select user" -msgstr "Benutzer*in auswählen" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -605,7 +587,6 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -624,11 +605,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht." -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "Benutzerdefiniertes Feld und alle Werte löschen" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -662,7 +638,6 @@ msgstr "Vereinsdaten" msgid "Manage global settings for the association." msgstr "Passe übergreifende Einstellungen für den Verein an." -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" @@ -785,8 +760,11 @@ msgstr "Adresse" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "Zurück" @@ -821,6 +799,7 @@ msgstr "Persönliche Daten" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" @@ -964,11 +943,6 @@ msgstr "Unbezahlt" msgid "Yearly" msgstr "jährlich" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "Benutzerdefiniertes Feld %{id}" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -1604,17 +1578,11 @@ msgstr "Datenfeld speichern" msgid "Back to roles list" msgstr "Zurück zur Rollen-Liste" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "System-Rolle kann nicht gelöscht werden" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" msgstr "Benutzerdefiniert" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Failed to delete role: %{error}" @@ -1631,7 +1599,6 @@ msgstr "Rollen auflisten" msgid "Manage user roles and their permission sets." msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze." -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1642,11 +1609,6 @@ msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser msgid "Close sidebar" msgstr "Sidebar schließen" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Delete Role" -msgstr "Rolle löschen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1690,7 +1652,6 @@ msgstr "Profil" msgid "Role" msgstr "Rolle" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -1702,7 +1663,6 @@ msgid "Role details and permissions." msgstr "Rollen-Details und Berechtigungen." #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1734,12 +1694,6 @@ msgstr "System" msgid "System Role" msgstr "System-Rolle" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "System-Rollen können nicht gelöscht werden" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "System roles cannot be deleted." @@ -1816,12 +1770,14 @@ msgstr "Mitgliedsbeitragsart nicht gefunden" msgid "User %{action} successfully" msgstr "Benutzer*in wurde erfolgreich %{action}" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "Benutzer*in erfolgreich gelöscht" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "Benutzer*in nicht gefunden" @@ -1832,18 +1788,14 @@ msgstr "Benutzer*in nicht gefunden" msgid "You do not have permission to access this membership fee type" msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this user" -msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen" @@ -1864,16 +1816,19 @@ msgstr "aktualisiert" msgid "Unknown error" msgstr "Unbekannter Fehler" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "Mitglied nicht gefunden" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2130,6 +2085,7 @@ msgstr "Gruppe erstellen" msgid "Delete Group" msgstr "Gruppe löschen" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete group" @@ -2210,11 +2166,6 @@ msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnung msgid "To confirm deletion, please enter the group name:" msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "Anzeigen" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "Filter zurücksetzen“" msgid "Apply filters" msgstr "Filter auswählen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "Gefahrenzone" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete member" msgstr "Mitglied löschen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "Mitglied %{name} löschen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht." -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit datafield" -msgstr "Datenfeld bearbeiten" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "Gruppe bearbeiten" msgid "Edit member" msgstr "Mitglied bearbeiten" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "Rolle bearbeiten" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit user" -msgstr "Benutzer*in bearbeiten" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,31 +3095,155 @@ msgstr "Klicke für Rollen-Details" msgid "Click for user details" msgstr "Klicke für Benutzer*innen-Details" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Click for dataield details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Members table" msgstr "Mitglieder" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click to edit datafield" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete data field" +msgstr "Datenfeld löschen" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "Datenfeld %{name} löschen" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete group %{name}" +msgstr "Gruppe %{name} löschen" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role" +msgstr "Rolle löschen" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role %{name}" +msgstr "Mitglied %{name} löschen" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete user" +msgstr "Löschen" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "Benutzer*in %{email} löschen" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglieds-Gruppen Zugehörigkeiten werden gelöscht." + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*inen die dieser Rolle zugewiesen wurden, müssen zuerst einer anderen Rolle zugewiesen werden." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "Das Löschen kann nicht rückgängig gemacht werden. Der Account und Verlinkungen zu Mitgliedern werden entfernt." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "System user cannot be deleted." +msgstr "System-Rollen können nicht gelöscht werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Name" +msgstr "Speichern" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Datafield %{id}" +msgstr "Datenfelder" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete Datafields and All Values" +msgstr "Benutzerdefiniertes Feld und alle Werte löschen" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle " + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "Individuelle Datenfelder" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "Zurück zu den Einstellungen" +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Cannot delete system role" +#~ msgstr "System-Rolle kann nicht gelöscht werden" + #~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Click for custom field details" #~ msgstr "Klicke für Datenfeld-Details" +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for datafield details" +#~ msgstr "Klicke für Datenfeld-Details" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" #~ msgstr "Demnächst verfügbar" +#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Custom Field %{id}" +#~ msgstr "Benutzerdefiniertes Feld %{id}" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit datafield" +#~ msgstr "Datenfeld bearbeiten" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit user" +#~ msgstr "Benutzer*in bearbeiten" + #~ #: lib/mv_web/live/components/member_filter_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Reset" @@ -3191,7 +3259,32 @@ msgstr "Mitglieder" #~ msgid "Save Role" #~ msgstr "Rolle speichern" +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Select all users" +#~ msgstr "Alle Benutzer*innen auswählen" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Select user" +#~ msgstr "Benutzer*in auswählen" + +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "System roles cannot be deleted" +#~ msgstr "System-Rollen können nicht gelöscht werden" + +#~ #: lib/mv_web/live/group_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View" +#~ msgstr "Anzeigen" + #~ #: lib/mv_web/live/member_live/index.ex #~ #, elixir-autogen, elixir-format #~ msgid "You do not have permission to access this member" #~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" + +#~ #: lib/mv_web/live/user_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this user" +#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ed020a0..309b425 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -19,9 +19,6 @@ msgstr "" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -39,11 +36,8 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "" @@ -101,8 +95,6 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -481,16 +473,6 @@ msgstr "" msgid "Password requirements" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select all users" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select user" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -606,7 +588,6 @@ msgstr "" msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -625,11 +606,6 @@ msgstr[1] "" msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -663,7 +639,6 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Save Settings" @@ -786,8 +761,11 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" @@ -822,6 +800,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" @@ -965,11 +944,6 @@ msgstr "" msgid "Yearly" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -1605,17 +1579,11 @@ msgstr "" msgid "Back to roles list" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Failed to delete role: %{error}" @@ -1632,7 +1600,6 @@ msgstr "" msgid "Manage user roles and their permission sets." msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1643,11 +1610,6 @@ msgstr "" msgid "Close sidebar" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Delete Role" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1691,7 +1653,6 @@ msgstr "" msgid "Role" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -1703,7 +1664,6 @@ msgid "Role details and permissions." msgstr "" #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1735,12 +1695,6 @@ msgstr "" msgid "System Role" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "System roles cannot be deleted." @@ -1817,12 +1771,14 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "" @@ -1833,18 +1789,14 @@ msgstr "" msgid "You do not have permission to access this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this user" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" msgstr "" @@ -1865,16 +1817,19 @@ msgstr "" msgid "Unknown error" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2131,6 +2086,7 @@ msgstr "" msgid "Delete Group" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Delete group" @@ -2211,11 +2167,6 @@ msgstr[1] "" msgid "To confirm deletion, please enter the group name:" msgstr "" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "" msgid "Apply filters" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Edit datafield" -msgstr "" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "" msgid "Edit member" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Edit role" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Edit user" -msgstr "" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,12 +3095,110 @@ msgstr "" msgid "Click for user details" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for dataield details" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Members table" msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click to edit datafield" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete group %{name}" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete role" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete role %{name}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "System user cannot be deleted." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Name" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Datafield %{id}" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Delete Datafields and All Values" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a44e87c..a42bdbd 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -19,9 +19,6 @@ msgstr "" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -39,11 +36,8 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "" @@ -101,8 +95,6 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -481,16 +473,6 @@ msgstr "" msgid "Password requirements" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select all users" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select user" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -606,7 +588,6 @@ msgstr "" msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -625,11 +606,6 @@ msgstr[1] "" msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -663,7 +639,6 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" @@ -786,8 +761,11 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" @@ -822,6 +800,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save" @@ -965,11 +944,6 @@ msgstr "" msgid "Yearly" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom Field %{id}" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Last name" @@ -1605,17 +1579,11 @@ msgstr "" msgid "Back to roles list" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete role: %{error}" @@ -1632,7 +1600,6 @@ msgstr "" msgid "Manage user roles and their permission sets." msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1643,11 +1610,6 @@ msgstr "" msgid "Close sidebar" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Delete Role" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1691,7 +1653,6 @@ msgstr "" msgid "Role" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Role deleted successfully." @@ -1703,7 +1664,6 @@ msgid "Role details and permissions." msgstr "" #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1735,12 +1695,6 @@ msgstr "" msgid "System Role" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "System roles cannot be deleted." @@ -1817,12 +1771,14 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "User not found" msgstr "" @@ -1833,18 +1789,14 @@ msgstr "" msgid "You do not have permission to access this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "You do not have permission to access this user" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this user" msgstr "" @@ -1865,16 +1817,19 @@ msgstr "" msgid "Unknown error" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2131,6 +2086,7 @@ msgstr "" msgid "Delete Group" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete group" @@ -2211,11 +2167,6 @@ msgstr[1] "" msgid "To confirm deletion, please enter the group name:" msgstr "" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "" msgid "Apply filters" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete member" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit datafield" -msgstr "" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "" msgid "Edit member" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit user" -msgstr "" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,31 +3095,155 @@ msgstr "" msgid "Click for user details" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Click for dataield details" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Members table" msgstr "" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click to edit datafield" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete data field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete group %{name}" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role %{name}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete user" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "System user cannot be deleted." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Name" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Datafield %{id}" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete Datafields and All Values" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "" +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Cannot delete system role" +#~ msgstr "" + #~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Click for custom field details" #~ msgstr "" +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for datafield details" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" #~ msgstr "" +#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Field %{id}" +#~ msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit datafield" +#~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit user" +#~ msgstr "" + #~ #: lib/mv_web/live/components/member_filter_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Reset" @@ -3191,7 +3259,32 @@ msgstr "" #~ msgid "Save Role" #~ msgstr "" +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Select all users" +#~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Select user" +#~ msgstr "" + +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "System roles cannot be deleted" +#~ msgstr "" + +#~ #: lib/mv_web/live/group_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/index.ex #~ #, elixir-autogen, elixir-format #~ msgid "You do not have permission to access this member" #~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "You do not have permission to access this user" +#~ msgstr "" diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 28f98a2..5ec955e 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -46,6 +46,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do %{conn: conn, user: user_with_role} end + # Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click) + defp open_delete_modal(view, custom_field) do + view + |> element("tr#custom_fields-#{custom_field.id} td", custom_field.name) + |> render_click() + + view + |> element("[data-testid=custom-field-delete]") + |> render_click() + end + describe "delete button and modal" do test "opens modal with correct member count when delete is clicked", %{conn: conn} do {:ok, member} = create_member() @@ -55,11 +66,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member, custom_field, "test") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - # Click delete button - find the delete link within the component - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Modal should be visible assert has_element?(view, "#delete-custom-field-modal") @@ -81,23 +88,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member2, custom_field, "test2") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Should show plural form assert render(view) =~ "2 members have values assigned for this custom field" end test "shows 0 members for custom field without values", %{conn: conn} do - {:ok, _custom_field} = create_custom_field("test_field", :string) + {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Should show 0 members assert render(view) =~ "0 members have values assigned for this custom field" @@ -109,10 +110,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Type in slug input - use element to find the form with phx-target view @@ -124,13 +122,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do end test "delete button is disabled when slug doesn't match", %{conn: conn} do - {:ok, _custom_field} = create_custom_field("test_field", :string) + {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Type wrong slug - use element to find the form with phx-target view @@ -149,11 +144,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - # Open modal - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Enter correct slug - use element to find the form with phx-target view @@ -162,7 +153,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Click confirm view - |> element("#delete-custom-field-modal button", "Delete Custom Field and All Values") + |> element("#delete-custom-field-modal button", "Delete Datafields and All Values") |> render_click() # Should show success message @@ -186,10 +177,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Enter wrong slug - use element to find the form with phx-target view @@ -210,10 +198,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Modal should be visible assert has_element?(view, "#delete-custom-field-modal") diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index cb112f2..57ce814 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -138,7 +138,7 @@ defmodule MvWeb.RoleLiveTest do assert html =~ "System Role" || html =~ "system" end - test "delete button disabled for system roles", %{conn: conn, actor: actor} do + test "delete button not shown for system roles", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -148,28 +148,19 @@ defmodule MvWeb.RoleLiveTest do |> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.create!(actor: actor) - {:ok, view, _html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}") - assert has_element?( - view, - "button[phx-click='delete'][phx-value-id='#{system_role.id}'][disabled]" - ) || - not has_element?( - view, - "button[phx-click='delete'][phx-value-id='#{system_role.id}']" - ) + # Danger zone (and delete button) is not rendered for system roles + refute has_element?(view, "[data-testid=role-delete]") end test "delete button enabled for non-system roles", %{conn: conn} do role = create_role() - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") - # Delete is a link with phx-click containing delete event - # Check if delete link exists in HTML (phx-click contains delete and role id) - assert (html =~ "phx-click" && html =~ "delete" && html =~ role.id) || - has_element?(view, "a[phx-click*='delete'][phx-value-id='#{role.id}']") || - has_element?(view, "a[aria-label='Delete role']") + # Delete is on show page (Danger zone) + assert has_element?(view, "[data-testid=role-delete]") end test "new role button navigates to form", %{conn: conn} do @@ -393,21 +384,21 @@ defmodule MvWeb.RoleLiveTest do test "deletes non-system role", %{conn: conn, actor: actor} do role = create_role() - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") - # Delete is a link - JS.push creates phx-click with value containing id - # Verify the role id is in the HTML (in phx-click value) - assert html =~ role.id + # Delete from Danger zone on show page + view + |> element("[data-testid=role-delete]") + |> render_click() - # Send delete event directly to avoid selector issues with multiple delete buttons - render_click(view, "delete", %{"id" => role.id}) + assert_redirect(view, "/admin/roles") # Verify deletion by checking database assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = Authorization.get_role(role.id, actor: actor) end - test "fails to delete system role with error message", %{conn: conn, actor: actor} do + test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -417,19 +408,12 @@ defmodule MvWeb.RoleLiveTest do |> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.create!(actor: actor) - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}") - # System role delete button should be disabled - assert html =~ "disabled" || html =~ "cursor-not-allowed" || - html =~ "System roles cannot be deleted" + # Danger zone is not rendered for system roles (no delete button) + refute has_element?(view, "[data-testid=role-delete]") - # Try to delete via event (backend check) - render_click(view, "delete", %{"id" => system_role.id}) - - # Should show error message - assert render(view) =~ "System roles cannot be deleted" - - # Role should still exist + # Role still exists {:ok, _role} = Authorization.get_role(system_role.id, actor: actor) end end diff --git a/test/mv_web/live/user_live_authorization_test.exs b/test/mv_web/live/user_live_authorization_test.exs index f4b4746..ee9f2b6 100644 --- a/test/mv_web/live/user_live_authorization_test.exs +++ b/test/mv_web/live/user_live_authorization_test.exs @@ -10,14 +10,16 @@ defmodule MvWeb.UserLiveAuthorizationTest do describe "User Index - Admin" do @tag role: :admin - test "sees New User, Edit and Delete buttons", %{conn: conn} do + test "sees New User button; Edit and Delete are on show page", %{conn: conn} do user = Fixtures.user_with_role_fixture("admin") - {:ok, view, _html} = live(conn, "/users") + {:ok, index_view, _html} = live(conn, "/users") + assert has_element?(index_view, "[data-testid=user-new]") - assert has_element?(view, "[data-testid=user-new]") - assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]") - assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]") + # Edit and Delete are on user show page (Danger zone), not on index + {:ok, show_view, _html} = live(conn, "/users/#{user.id}") + assert has_element?(show_view, "[data-testid=user-edit]") + assert has_element?(show_view, "[data-testid=user-delete]") end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 11cd70b..f748000 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -16,11 +16,10 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ "alice@example.com" assert html =~ "bob@example.com" - # UI elements: New User button, action links + # UI elements: New User button; row click navigates to show (no Edit/Delete on index) assert html =~ "New User" - assert html =~ "Edit" - assert html =~ "Delete" - assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/ + # Row or navigation contains user id (e.g. row id or phx-click navigate) + assert html =~ "row-#{user1.id}" or html =~ to_string(user1.id) end @tag :ui @@ -116,177 +115,29 @@ defmodule MvWeb.UserLive.IndexTest do end end - describe "checkbox selection functionality" do - setup do - user1 = create_test_user(%{email: "user1@example.com", oidc_id: "user1"}) - user2 = create_test_user(%{email: "user2@example.com", oidc_id: "user2"}) - %{users: [user1, user2]} - end - - @tag :ui - test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/users") - - # Check select all checkbox exists - assert html =~ ~s(name="select_all") - assert html =~ ~s(phx-click="select_all") - - # Check individual user checkboxes exist - assert html =~ ~s(name="#{user1.id}") - assert html =~ ~s(name="#{user2.id}") - assert html =~ ~s(phx-click="select_user") - end - - @tag :ui - test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") - - # Initially, individual checkboxes should exist but not be checked - assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?() - assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?() - - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Select first user checkbox - html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() - assert html =~ "Email" - assert html =~ to_string(user1.email) - - # The select_all checkbox should still not be checked (not all users selected) - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Deselect user - html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() - assert html =~ "Email" - - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - end - - @tag :ui - test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") - - # Initially no checkboxes should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user1.id}'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user2.id}'][checked]") - |> has_element?() - - # Click select all - html = view |> element("input[type='checkbox'][name='select_all']") |> render_click() - - # After selecting all, the select_all checkbox should be checked - assert view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - assert html =~ "Email" - assert html =~ to_string(user1.email) - assert html =~ to_string(user2.email) - - # Then deselect all - html = view |> element("input[type='checkbox'][name='select_all']") |> render_click() - - # After deselecting all, no checkboxes should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user1.id}'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user2.id}'][checked]") - |> has_element?() - - assert html =~ "Email" - end - - @tag :slow - test "select all automatically checks when all individual users are selected", %{ - conn: conn, - users: [_user1, _user2] - } do - conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/users") - - # Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user" - # Extract user IDs from the HTML (they appear as name attributes on checkboxes) - user_ids = - html - |> String.split("phx-click=\"select_user\"") - |> Enum.flat_map(fn part -> - case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do - [_, user_id] -> [user_id] - _ -> [] - end - end) - |> Enum.uniq() - - # Skip if no users found (shouldn't happen, but be safe) - if user_ids != [] do - # Initially nothing should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Select all users one by one - Enum.each(user_ids, fn user_id -> - view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click() - end) - - # Now select all should be automatically checked (all individual users are selected) - assert view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - end - end - end - describe "delete functionality" do - test "can delete a user", %{conn: conn} do - _user = create_test_user(%{email: "delete-me@example.com"}) + # Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete) + test "can delete a user from show page", %{conn: conn} do + user = create_test_user(%{email: "delete-me@example.com"}) conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") + {:ok, index_view, _html} = live(conn, "/users") + assert render(index_view) =~ "delete-me@example.com" - # Confirm user is displayed - assert render(view) =~ "delete-me@example.com" + # Navigate to user show and trigger delete from Danger zone + {:ok, show_view, _html} = live(conn, "/users/#{user.id}") - # Click the delete button (phx-click="delete" event) - view |> element("tbody tr:first-child a[data-confirm]") |> render_click() + show_view + |> element("[data-testid=user-delete]") + |> render_click() - # Verify user was actually deleted (should not appear in HTML anymore) - html = render(view) + # Should redirect to index + assert_redirect(show_view, "/users") + + # Reload index with same session; user should be gone + {:ok, _view_after, html} = live(conn, "/users") refute html =~ "delete-me@example.com" - # Table header should still be there assert html =~ "Email" end - - test "shows delete confirmation", %{conn: conn} do - _user = create_test_user(%{email: "confirm-delete@example.com"}) - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/users") - - # Check that delete link has confirmation attribute - assert html =~ ~s(data-confirm="Are you sure?") - end end describe "navigation" do @@ -296,36 +147,14 @@ defmodule MvWeb.UserLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/users") - # Check that user row contains link to show page + # Row click navigates to show page (edit is on show page) assert html =~ ~s(/users/#{user.id}) - # Check edit link points to correct edit page - assert html =~ ~s(/users/#{user.id}/edit) - # Check new user button points to correct new page assert html =~ ~s(/users/new) end end - describe "translations" do - @tag :ui - test "shows translations for selection in different locales", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Test German translations - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html_de} = live(conn, "/users") - assert html_de =~ "Alle Benutzer*innen auswählen" - assert html_de =~ "Benutzer*in auswählen" - - # Test English translations - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html_en} = live(conn, "/users") - # Check that aria-label attributes exist (structure is there) - assert html_en =~ ~s(aria-label=) - end - end - describe "edge cases" do test "handles empty user list gracefully", %{conn: conn} do # Don't create any users besides the authenticated one From d0b8cb672ad00f3862b023ed4e99928202bc9165 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 08:33:52 +0100 Subject: [PATCH 127/237] style: consistent badges with sufficient color contrast --- CODE_GUIDELINES.md | 6 +- assets/css/app.css | 132 ++++++++++++++++++ docs/badge-wcag-phase1-analysis.md | 88 ++++++++++++ lib/mv_web/components/core_components.ex | 95 +++++++++++++ lib/mv_web/helpers/membership_fee_helpers.ex | 11 ++ .../components/member_filter_component.ex | 19 +-- .../live/custom_field_live/index_component.ex | 8 +- lib/mv_web/live/global_settings_live.ex | 8 +- lib/mv_web/live/group_live/show.ex | 4 +- .../live/member_field_live/index_component.ex | 8 +- lib/mv_web/live/member_live/index.html.heex | 17 +-- lib/mv_web/live/member_live/show.ex | 20 +-- .../show/membership_fees_component.ex | 12 +- .../live/membership_fee_settings_live.ex | 8 +- .../live/membership_fee_type_live/index.ex | 6 +- lib/mv_web/live/role_live/helpers.ex | 14 ++ lib/mv_web/live/role_live/index.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 23 +-- lib/mv_web/live/role_live/show.ex | 17 +-- .../index/membership_fee_status.ex | 14 +- .../components/core_components_badge_test.exs | 91 ++++++++++++ .../helpers/membership_fee_helpers_test.exs | 8 ++ 22 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 docs/badge-wcag-phase1-analysis.md create mode 100644 test/mv_web/components/core_components_badge_test.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 50c9eca..4d303c3 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2781,7 +2781,11 @@ Building accessible applications ensures that all users, including those with di ### 8.4 Color and Contrast -**Ensure Sufficient Contrast:** +**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):** + +- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button. +- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient. +- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states. ```elixir # Tailwind classes with sufficient contrast (4.5:1 minimum) diff --git a/assets/css/app.css b/assets/css/app.css index bbe7424..04d887f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -118,6 +118,138 @@ color: oklch(0.45 0.2 25); } +/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use + Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures + outline badges always have a visible background in both themes. */ +[data-theme="light"] .badge.badge-outline, +[data-theme="dark"] .badge.badge-outline { + background-color: var(--color-base-100); +} + +/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. + Theme tokens *-content are often too light on * backgrounds in light theme, and + badge-soft uses variant as text on a light tint (low contrast). We override + --badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */ + +/* Light theme: use dark text on all colored badges (solid, soft, outline). */ +[data-theme="light"] .badge.badge-primary { + --badge-fg: oklch(0.25 0.08 47); +} +[data-theme="light"] .badge.badge-primary.badge-soft { + color: oklch(0.38 0.14 47); +} +[data-theme="light"] .badge.badge-success { + --badge-fg: oklch(0.26 0.06 165); +} +[data-theme="light"] .badge.badge-success.badge-soft { + color: oklch(0.35 0.10 165); +} +[data-theme="light"] .badge.badge-error { + --badge-fg: oklch(0.22 0.08 25); +} +[data-theme="light"] .badge.badge-error.badge-soft { + color: oklch(0.38 0.14 25); +} +[data-theme="light"] .badge.badge-warning { + --badge-fg: oklch(0.28 0.06 75); +} +[data-theme="light"] .badge.badge-warning.badge-soft { + color: oklch(0.42 0.12 75); +} +[data-theme="light"] .badge.badge-info { + --badge-fg: oklch(0.26 0.08 250); +} +[data-theme="light"] .badge.badge-info.badge-soft { + color: oklch(0.38 0.12 250); +} +[data-theme="light"] .badge.badge-neutral { + --badge-fg: oklch(0.22 0.01 285); +} +[data-theme="light"] .badge.badge-neutral.badge-soft { + color: oklch(0.32 0.02 285); +} +[data-theme="light"] .badge.badge-outline.badge-primary, +[data-theme="light"] .badge.badge-outline.badge-success, +[data-theme="light"] .badge.badge-outline.badge-error, +[data-theme="light"] .badge.badge-outline.badge-warning, +[data-theme="light"] .badge.badge-outline.badge-info, +[data-theme="light"] .badge.badge-outline.badge-neutral { + --badge-fg: oklch(0.25 0.02 285); +} + +/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1). + Slightly darken solid variant backgrounds so theme *-content (light) passes. */ +[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.20 277); + --badge-fg: oklch(0.97 0.02 277); +} +[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.10 185); + --badge-fg: oklch(0.97 0.01 185); +} +[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.42 0.18 18); + --badge-fg: oklch(0.97 0.02 18); +} +[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.48 0.14 58); + --badge-fg: oklch(0.22 0.02 58); +} +[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.45 0.14 242); + --badge-fg: oklch(0.97 0.02 242); +} +[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) { + --badge-bg: oklch(0.32 0.02 257); + --badge-fg: oklch(0.96 0.01 257); +} +[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); } +[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); } +[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); } +[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); } +[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); } +[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); } +[data-theme="dark"] .badge.badge-outline.badge-primary, +[data-theme="dark"] .badge.badge-outline.badge-success, +[data-theme="dark"] .badge.badge-outline.badge-error, +[data-theme="dark"] .badge.badge-outline.badge-warning, +[data-theme="dark"] .badge.badge-outline.badge-info, +[data-theme="dark"] .badge.badge-outline.badge-neutral { + --badge-fg: oklch(0.92 0.02 257); +} + +/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean). + Inactive state uses base-content on a light/dark surface; active state ensures + *-content on * background meets 4.5:1. */ +.member-filter-dropdown .join .btn { + /* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */ + border-color: var(--color-base-300); +} +[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) { + color: oklch(0.25 0.02 285); + background-color: var(--color-base-100); +} +[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active { + background-color: oklch(0.42 0.12 165); + color: oklch(0.98 0.01 165); +} +[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active { + background-color: oklch(0.42 0.18 18); + color: oklch(0.98 0.02 18); +} +[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) { + color: oklch(0.92 0.02 257); + background-color: var(--color-base-200); +} +[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active { + background-color: oklch(0.42 0.10 165); + color: oklch(0.97 0.01 165); +} +[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active { + background-color: oklch(0.42 0.18 18); + color: oklch(0.97 0.02 18); +} + /* ============================================ Sidebar Base Styles ============================================ */ diff --git a/docs/badge-wcag-phase1-analysis.md b/docs/badge-wcag-phase1-analysis.md new file mode 100644 index 0000000..5b6a834 --- /dev/null +++ b/docs/badge-wcag-phase1-analysis.md @@ -0,0 +1,88 @@ +# Phase 1 — Badge WCAG Analysis & Migration + +## 1) Repo-Analyse (Stand vor Änderungen) + +### Badge-Verwendungen (alle Fundstellen) + +| Datei | Kontext | Markup | +|-------|---------|--------| +| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `` / `` | +| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `` (2×) | +| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) | +| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" | +| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | ``, `badge-ghost` (No cycles) | +| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) | +| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) | +| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` | +| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` | +| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` | +| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` | +| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) | +| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + ``, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) | +| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" | +| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` | +| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) | + +### DaisyUI/Tailwind Config + +- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier. +- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen. +- **Themes:** Zwei Custom-Themes in `app.css`: + - `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false) + - `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true) +- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`). + +### Core Components + +- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents). +- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc. +- **Badge:** Bisher keine zentrale `<.badge>`-Komponente. + +### DaisyUI Badge (Vendor) + +- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`. +- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300. +- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar. +- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen). + +--- + +## 2) Core Component <.badge> API (geplant) + +- **attr :variant** — `:neutral | :primary | :info | :success | :warning | :error` +- **attr :style** — `:soft | :solid | :outline` (Default: `:soft`) +- **attr :size** — `:sm | :md` (Default: `:md`) +- **slot :inner_block** — Badge-Text +- **attr :sr_label** — optional, für Icon-only (Screen Reader) +- **slot :icon** — optional + +Regeln: + +- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default). +- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt. +- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit. + +--- + +## 3) Theme-Overrides (WCAG) + +- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens. +- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`: + - **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100. + - **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100. + +--- + +## 4) Migration (erledigt) + +- Alle `` durch `<.badge variant="..." style="...">...` ersetzt. +- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container). +- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error). +- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>. +- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show. + +## 5) Weitere Anpassungen (nach Phase 1) + +- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1). +- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)` → `:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat. +- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden). diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 21e3546..3ee5ede 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -145,6 +145,101 @@ defmodule MvWeb.CoreComponents do end end + @doc """ + Renders a non-interactive badge with WCAG-compliant contrast. + + Use for status labels, counts, or tags. For clickable elements (e.g. filter chips), + use a button or link component instead, not this badge. + + ## Variants and styles + + - **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error` + - **style:** `:soft` (default, tinted background), `:solid`, `:outline` + - **size:** `:sm`, `:md` (default) + + Outline and soft styles always use a visible background so the badge remains + readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed + by default to avoid low-contrast on gray backgrounds. + + ## Examples + + <.badge variant="success">Paid + <.badge variant="error" style="solid">Unpaid + <.badge variant="neutral" size="sm">Custom + <.badge variant="primary" style="outline">Label + <.badge variant="success" sr_label="Paid"> + <.icon name="hero-check-circle" class="size-4" /> + + """ + attr :variant, :any, + default: "neutral", + doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)" + + attr :style, :any, + default: "soft", + doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast" + + attr :size, :any, + default: "md", + doc: "Badge size: sm | md" + + attr :sr_label, :string, + default: nil, + doc: "Optional screen-reader label for icon-only content" + + attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)" + + slot :inner_block, required: true, doc: "Badge text (and optional icon)" + slot :icon, doc: "Optional leading icon slot" + + def badge(assigns) do + # Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work + variant = to_string(assigns.variant || "neutral") + style = to_string(assigns.style || "soft") + size = to_string(assigns.size || "md") + + variant_class = "badge-#{variant}" + style_class = badge_style_class(style) + size_class = "badge-#{size}" + # Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300 + outline_bg = if style == "outline", do: "bg-base-100", else: nil + + rest = assigns.rest || [] + rest = if is_list(rest), do: rest, else: Map.to_list(rest) + extra_class = Keyword.get(rest, :class) + rest = Keyword.drop(rest, [:class]) + rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest + + class = + ["badge", variant_class, style_class, size_class, outline_bg, extra_class] + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + + assigns = + assigns + |> assign(:class, class) + |> assign(:rest, rest) + |> assign(:has_icon, assigns.icon != []) + + ~H""" + + <%= if @has_icon do %> + {render_slot(@icon)} + <% end %> + {render_slot(@inner_block)} + <%= if @sr_label do %> + {@sr_label} + <% end %> + + """ + end + + defp badge_style_class("soft"), do: "badge-soft" + defp badge_style_class("solid"), do: nil + defp badge_style_class("outline"), do: "badge-outline" + defp badge_style_class(_), do: nil + @doc """ Renders a dropdown menu. diff --git a/lib/mv_web/helpers/membership_fee_helpers.ex b/lib/mv_web/helpers/membership_fee_helpers.ex index 27c99f5..e8a2ce8 100644 --- a/lib/mv_web/helpers/membership_fee_helpers.ex +++ b/lib/mv_web/helpers/membership_fee_helpers.ex @@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do def status_color(:unpaid), do: "badge-error" def status_color(:suspended), do: "badge-ghost" + @doc """ + Returns the Core Components badge variant for a cycle status (WCAG-compliant). + + Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>. + Suspended uses :warning (yellow) to match the edit cycle-status button. + """ + @spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning + def status_variant(:paid), do: :success + def status_variant(:unpaid), do: :error + def status_variant(:suspended), do: :warning + @doc """ Gets the icon name for a status. diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ef6f32e..95a3954 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do def render(assigns) do ~H"""
- 0} - class="badge badge-primary badge-sm" + variant="primary" + size="sm" > {active_boolean_filters_count(@boolean_filters)} - - + <.badge :if={ (@cycle_status_filter || map_size(@group_filters) > 0) && active_boolean_filters_count(@boolean_filters) == 0 } - class="badge badge-primary badge-sm" + variant="primary" + size="sm" > {@member_count} - +
0} class="mb-4">
@@ -249,7 +249,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
0} class="mb-2">
@@ -316,7 +316,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
<.button diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7e802b8..4ecc6f3 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -130,149 +130,153 @@ defmodule MvWeb.GroupLive.Show do
-
-

{gettext("Members")}

-
-

- {ngettext( - "Total: %{count} member", - "Total: %{count} members", - @group.member_count || 0, - count: @group.member_count || 0 - )} -

+
+

{gettext("Members")}

+
+

+ {ngettext( + "Total: %{count} member", + "Total: %{count} members", + @group.member_count || 0, + count: @group.member_count || 0 + )} +

- <%= if can?(@current_user, :update, @group) do %> -
- <%= if assigns[:show_add_member_input] do %> -
- -
-
+ <%= if can?(@current_user, :update, @group) do %> +
+ <%= if assigns[:show_add_member_input] do %> +
+ +
+
<%= for member <- @selected_members do %> - <.badge variant="primary" style="outline" class="flex items-center gap-1"> - {MvWeb.Helpers.MemberHelpers.display_name(member)} - <.tooltip content={gettext("Remove")} position="top"> - <.button - type="button" - variant="icon" - size="sm" - phx-click="remove_selected_member" - phx-value-member_id={member.id} - aria-label={ - gettext("Remove %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(member) - ) - } - class="p-0 h-4 w-4 min-h-0" - > - <.icon name="hero-x-mark" class="size-3" /> - - - - <% end %> - -
- - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

- {MvWeb.Helpers.MemberHelpers.display_name(member)} -

-

- {member.email || gettext("No email")} -

-
+ {MvWeb.Helpers.MemberHelpers.display_name(member)} + <.tooltip content={gettext("Remove")} position="top"> + <.button + type="button" + variant="icon" + size="sm" + phx-click="remove_selected_member" + phx-value-member_id={member.id} + aria-label={ + gettext("Remove %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(member) + ) + } + class="p-0 h-4 w-4 min-h-0" + > + <.icon name="hero-x-mark" class="size-3" /> + + + <% end %> +
- <% end %> -
- - <.button - type="button" - variant="primary" - phx-click="add_selected_members" - data-testid="group-show-add-selected-members-btn" - disabled={Enum.empty?(@selected_member_ids)} - aria-label={gettext("Add members")} - class="join-item" - > - <.icon name="hero-plus" class="size-5" /> - - <.button - type="button" - variant="neutral" - phx-click="hide_add_member_input" - aria-label={gettext("Cancel")} - class="join-item" - > - {gettext("Cancel")} - -
- <% else %> - <.button - variant="primary" - phx-click="show_add_member_input" - aria-label={gettext("Add Member")} - > - {gettext("Add Member")} - - <% end %> -
- <% end %> - <%= if Enum.empty?(@group.members || []) do %> + <%= if length(@available_members) > 0 do %> +
+ <%= for {member, index} <- Enum.with_index(@available_members) do %> +
+

+ {MvWeb.Helpers.MemberHelpers.display_name(member)} +

+

+ {member.email || gettext("No email")} +

+
+ <% end %> +
+ <% end %> +
+ + <.button + type="button" + variant="primary" + phx-click="add_selected_members" + data-testid="group-show-add-selected-members-btn" + disabled={Enum.empty?(@selected_member_ids)} + aria-label={gettext("Add members")} + class="join-item" + > + <.icon name="hero-plus" class="size-5" /> + + <.button + type="button" + variant="neutral" + phx-click="hide_add_member_input" + aria-label={gettext("Cancel")} + class="join-item" + > + {gettext("Cancel")} + +
+ <% else %> + <.button + variant="primary" + phx-click="show_add_member_input" + aria-label={gettext("Add Member")} + > + {gettext("Add Member")} + + <% end %> +
+ <% end %> + + <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index d6f87b1..d8b2616 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do :if={!@show_form} id="member_fields" rows={@member_fields} + row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end} row_click={ fn {field_name, _field_data} -> JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 45da418..e4a627b 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do > <%!-- Personal Data and Custom Fields Row --%>
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
-
- <.input - field={@form[:first_name]} - label={gettext("First Name")} - required={@member_field_required_map[:first_name]} - /> + <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
+ <.input + field={@form[:first_name]} + label={gettext("First Name")} + required={@member_field_required_map[:first_name]} + /> +
+
+ <.input + field={@form[:last_name]} + label={gettext("Last Name")} + required={@member_field_required_map[:last_name]} + /> +
-
- <.input - field={@form[:last_name]} - label={gettext("Last Name")} - required={@member_field_required_map[:last_name]} - /> -
-
- <%!-- Address: Country, Postal Code, City in one row --%> -
-
- <.input field={@form[:country]} label={gettext("Country")} /> + <%!-- Address: Country, Postal Code, City in one row --%> +
+
+ <.input field={@form[:country]} label={gettext("Country")} /> +
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input field={@form[:city]} label={gettext("City")} /> +
-
- <.input - field={@form[:postal_code]} - label={gettext("Postal Code")} - required={@member_field_required_map[:postal_code]} - /> -
-
- <.input field={@form[:city]} label={gettext("City")} /> -
-
- <%!-- Street and Nr. below --%> -
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+
+ + <%!-- Email --%>
- <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> -
-
- <%!-- Email --%> -
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input - field={@form[:join_date]} - label={gettext("Join Date")} - type="date" - required={@member_field_required_map[:join_date]} - /> + <%!-- Membership Dates Row --%> +
+
+ <.input + field={@form[:join_date]} + label={gettext("Join Date")} + type="date" + required={@member_field_required_map[:join_date]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
-
+ + <%!-- Notes --%> +
<.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} />
+ +
- <%!-- Notes --%> + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.form_section title={gettext("Custom Fields")}> +
+ <%!-- Render in sorted order by finding the form for each sorted custom field --%> + <%= for cf <- @sorted_custom_fields do %> + <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> + <%= if f_cfv[:custom_field_id].value == cf.id do %> +
+ <.inputs_for :let={value_form} field={f_cfv[:value]}> + <.input + field={value_form[:value]} + label={cf.name} + type={custom_field_input_type(cf.value_type)} + required={cf.required} + /> + + +
+ <% end %> + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Membership Fee Section --%> +
+ <.form_section title={gettext("Membership Fee")}> +
- <.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} - /> + + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

+ <% end %> + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +

+ {gettext( + "Select a membership fee type for this member. Members can only switch between types with the same interval." + )} +

- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- Render in sorted order by finding the form for each sorted custom field --%> - <%= for cf <- @sorted_custom_fields do %> - <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> - <%= if f_cfv[:custom_field_id].value == cf.id do %> -
- <.inputs_for :let={value_form} field={f_cfv[:value]}> - <.input - field={value_form[:value]} - label={cf.name} - type={custom_field_input_type(cf.value_type)} - required={cf.required} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
+ <%!-- Bottom Action Buttons --%> +
+ <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save Member")} + +
- <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
-
- - - <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

{msg}

- <% end %> - <%= if @interval_warning do %> -
- <.icon name="hero-exclamation-triangle" class="size-5" /> - {@interval_warning} -
- <% end %> -

+ <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +

+

+ {gettext("Danger zone")} +

+
+

{gettext( - "Select a membership fee type for this member. Members can only switch between types with the same interval." + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." )}

+ <.button + variant="danger" + type="button" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext( + "Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} +
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - -
- - <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> - <%= if @member && can?(@current_user, :destroy, @member) do %> -
-

- {gettext("Danger zone")} -

-
-

- {gettext( - "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." - )} -

- <.button - variant="danger" - type="button" - phx-click="delete" - phx-value-id={@member.id} - data-confirm={ - gettext("Are you sure you want to delete %{name}? This action cannot be undone.", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - data-testid="member-delete" - aria-label={ - gettext("Delete member %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete member")} - -
-
- <% end %> + + <% end %>
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 412a5c4..c49e343 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -109,7 +109,7 @@ sort_field={@sort_field} sort_order={@sort_order} > - + <:col :let={member} @@ -134,286 +134,286 @@ aria-label={gettext("Select member")} role="checkbox" /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <:col - :let={member} - :if={:country in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_country} - field={:country} - label={gettext("Country")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.country} - - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <:col + :let={member} + :if={:country in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_country} + field={:country} + label={gettext("Country")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.country} + + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MembershipFeeStatus.format_cycle_status_badge( MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.badge variant="neutral">{gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - + <.badge variant={badge.variant}> + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + <.badge variant="neutral">{gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} + +
+ +
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index c63ced5..a957b61 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do /> <.data_field label={gettext("Last Cycle")} class="min-w-32"> <%= if @member.last_cycle_status do %> - <.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}> + <.badge variant={ + MembershipFeeHelpers.status_variant(@member.last_cycle_status) + }> {format_status_label(@member.last_cycle_status)} <% else %> diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 0bdc226..58f98d4 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, - only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3] + import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1] @impl true def mount(_params, _session, socket) do diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 1dc41c8..94d1fc6 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -16,6 +16,7 @@ <.table id="roles" rows={@roles} + row_id={fn role -> "role-#{role.id}" end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_tooltip={gettext("Click for role details")} > From e422e5f4ef4c53cdf3846ab3ff4d39955529602d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 11:17:21 +0100 Subject: [PATCH 131/237] feat: consistent and accessible modal on delete --- CODE_GUIDELINES.md | 49 ++++++++--- DESIGN_DUIDELINES.md | 9 +- .../live/custom_field_live/index_component.ex | 14 +++- lib/mv_web/live/group_live/show.ex | 14 +++- lib/mv_web/live/member_live/form.ex | 73 +++++++++++++--- lib/mv_web/live/member_live/show.ex | 72 +++++++++++++--- .../show/membership_fees_component.ex | 58 ++++++++++--- lib/mv_web/live/role_live/show.ex | 83 ++++++++++++++----- lib/mv_web/live/user_live/form.ex | 79 +++++++++++++++--- lib/mv_web/live/user_live/show.ex | 75 ++++++++++++++--- 10 files changed, 424 insertions(+), 102 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index d68d0b5..bbc5ee4 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -3011,24 +3011,53 @@ end - [ ] Skip links are available - [ ] Tables have proper structure (th, scope, caption) - [ ] ARIA labels used for icon-only buttons +- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) -### 8.11 DaisyUI Accessibility +### 8.11 Modals and Dialogs -DaisyUI components are designed with accessibility in mind, but ensure: +Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `` so focus and semantics are correct (WCAG 2.4.3, 2.1.2). + +**Structure and semantics:** + +- Use `` with DaisyUI classes `modal modal-open` when the modal is visible. +- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose. +- Give the title (e.g. `

`) a unique `id` (e.g. `id="delete-role-modal-title"`). + +**Focus management (WCAG 2.4.3):** + +- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element: + - If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group). + - If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard. +- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse. + +**Layout and consistency:** + +- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action). +- Place Cancel (or neutral) first, primary/danger action second. +- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons. + +**Closing:** + +- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). +- Optionally support Escape to close via `phx-window-keydown` on the LiveView/LiveComponent. + +**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button). + +### 8.12 DaisyUI Accessibility + +DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure: ```heex - - + + diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index fc3acac..b497254 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -331,14 +331,17 @@ No “silent success”. ### 10.2 Destructive actions: one standard confirmation pattern - **MUST:** All destructive actions use the same confirm style and wording conventions. -- Choose one approach and standardize: - - `JS.confirm("…")` everywhere (simple, consistent) - - or a modal component everywhere (more flexible, more work) +- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible. **Recommended copy style:** - Title/confirm text is clear and specific (what will be deleted, consequences). - Buttons: `Cancel` (neutral) + `Delete` (danger). +### 10.3 Dialogs and modals (mandatory) +- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element). +- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse. +- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout). + --- ## 11) Detail pages (consistent structure) diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a9f921d..b15e49d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -99,10 +99,18 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

- <%!-- Delete Confirmation Modal --%> - + <%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> + <% end %> + + <%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> + <%= if assigns[:show_delete_modal] do %> + + + + <% end %>
""" @@ -346,7 +374,8 @@ defmodule MvWeb.MemberLive.Show do {:ok, socket |> assign(:active_tab, :contact) - |> assign(:vereinfacht_receipts, nil)} + |> assign(:vereinfacht_receipts, nil) + |> assign_new(:show_delete_modal, fn -> false end)} end @impl true @@ -398,13 +427,26 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + @impl true + def handle_event("open_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, true)} + end + + @impl true + def handle_event("cancel_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, false)} + end + @impl true def handle_event("delete", %{"id" => id}, socket) do member = socket.assigns.member actor = current_actor(socket) if to_string(id) != to_string(member.id) do - {:noreply, put_flash(socket, :error, gettext("Member not found"))} + {:noreply, + socket + |> put_flash(:error, gettext("Member not found")) + |> assign(:show_delete_modal, false)} else case Ash.destroy(member, actor: actor) do :ok -> @@ -415,16 +457,20 @@ defmodule MvWeb.MemberLive.Show do {:error, %Ash.Error.Forbidden{}} -> {:noreply, - put_flash( - socket, + socket + |> put_flash( :error, gettext("You do not have permission to delete this member") - )} + ) + |> assign(:show_delete_modal, false)} {:error, error} -> require Logger Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") - {:noreply, put_flash(socket, :error, format_error(error))} + {:noreply, + socket + |> put_flash(:error, format_error(error)) + |> assign(:show_delete_modal, false)} end end end diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 09a9ee1..1db11e3 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -288,11 +288,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %> - <%!-- Edit Cycle Amount Modal --%> + <%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @editing_cycle do %> - + <% end %> - <%!-- Delete Cycle Confirmation Modal --%> + <%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_cycle do %> - + <% end %> - <%!-- Delete All Cycles Confirmation Modal --%> + <%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_all_cycles do %> - + <% end %> - <%!-- Create Cycle Modal --%> + <%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @creating_cycle do %> - +
` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table. +### 8.6 Empty table cells (missing values) +- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a"). +- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells. +- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent). +- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified". + --- ## 9) Flash / Toast messages (mandatory UX) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 963c868..5f12f0a 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -300,6 +300,81 @@ defmodule MvWeb.CoreComponents do defp badge_style_class("outline"), do: "badge-outline" defp badge_style_class(_), do: nil + @doc """ + Renders a visually empty table cell with screen-reader-only text (WCAG). + + Use when a table cell has no value so that: + - The cell appears empty (no dash, no "n/a"). + - Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment"). + + See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6. + + ## Examples + + <.empty_cell sr_text={gettext("No cycle")} /> + <.empty_cell sr_text={gettext("No group assignment")} /> + <.empty_cell sr_text={gettext("Not specified")} /> + """ + attr :sr_text, :string, + required: true, + doc: "Text read by screen readers when the cell is visually empty" + + def empty_cell(assigns) do + ~H""" + {@sr_text} + """ + end + + @doc """ + Renders content when value is present, otherwise an accessible empty cell. + + Use in table cells for optional fields: when `value` is blank, only the + screen-reader text is shown (visually empty). Otherwise the inner block is rendered. + + Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty. + + See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6. + + ## Examples + + <.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}> + {member.membership_fee_type.name} + + <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> + <%= for g <- member.groups do %> + <.badge variant="primary" style="outline">{g.name} + <% end %> + + """ + attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered" + + attr :empty_sr_text, :string, + default: nil, + doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")" + + slot :inner_block, required: true + + def maybe_value(assigns) do + empty_sr = assigns.empty_sr_text || gettext("Not specified") + assigns = assign(assigns, :empty_sr_text, empty_sr) + assigns = assign(assigns, :blank?, value_blank?(assigns.value)) + + ~H""" + <%= if @blank? do %> + <.empty_cell sr_text={@empty_sr_text} /> + <% else %> + {render_slot(@inner_block)} + <% end %> + """ + end + + defp value_blank?(nil), do: true + defp value_blank?(false), do: true + defp value_blank?([]), do: true + defp value_blank?(%Ash.NotLoaded{}), do: true + defp value_blank?(v) when is_binary(v), do: String.trim(v) == "" + defp value_blank?(_), do: false + @doc """ Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, or status badges that need explanation (Design Guidelines §8.2). diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index c4002bc..abb19df 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -126,7 +126,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

{gettext( - "All datafield values will be permanently deleted when you delete this datfield." + "All datafield values will be permanently deleted when you delete this datafield." )}

diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index ff22b91..3aef8fb 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do {group.name} <:col :let={group} label={gettext("Description")}> - <%= if group.description do %> + <.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}> {group.description} - <% else %> - - <% end %> + <:col :let={group} label={gettext("Members")} class="text-right"> {group.member_count || 0} diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index bea6399..a700cd5 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -304,16 +304,14 @@ defmodule MvWeb.GroupLive.Show do <%= if can?(@current_user, :update, @group) do %> <%= for {col_key, _header_key} <- cols do %> - + <% end %> <% end %> @@ -1157,7 +1163,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end) end - defp format_receipt_cell(:amount, nil), do: "—" + # Screen-reader text for empty receipt table cells (visually empty, A11y) + defp receipt_empty_sr_text(:status), do: gettext("Not set") + defp receipt_empty_sr_text(_), do: gettext("Not specified") + + defp format_receipt_cell(:amount, nil), do: nil defp format_receipt_cell(:amount, val) when is_number(val) do case Decimal.cast(val) do @@ -1175,7 +1185,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_receipt_cell(:amount, val), do: to_string(val) - defp format_receipt_cell(:status, nil), do: "—" + defp format_receipt_cell(:status, nil), do: nil defp format_receipt_cell(:status, val) when is_binary(val) do translate_receipt_status(val) @@ -1183,7 +1193,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val)) - defp format_receipt_cell(:receiptType, nil), do: "—" + defp format_receipt_cell(:receiptType, nil), do: nil defp format_receipt_cell(:receiptType, val) when is_binary(val) do translate_receipt_type(val) @@ -1192,7 +1202,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val)) defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], - do: "—" + do: nil defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do format_receipt_date(val) @@ -1253,7 +1263,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp translate_receipt_status("draft"), do: gettext("Draft") defp translate_receipt_status("incompleted"), do: gettext("Incompleted") defp translate_receipt_status("completed"), do: gettext("Completed") - defp translate_receipt_status("empty"), do: "—" + defp translate_receipt_status("empty"), do: nil defp translate_receipt_status(other), do: other # Translate API receipt type values (extend as API returns more values) diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index 94e64c7..db044b5 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -329,7 +329,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do <:col :let={mft} label={gettext("Members")}> - <.badge variant="neutral">{get_member_count(mft, @member_counts)} + {get_member_count(mft, @member_counts)} <:action :let={mft}> diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 94d1fc6..bb61bb1 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -53,7 +53,7 @@ <:col :let={role} label={gettext("Users")}> - <.badge variant="neutral">{get_user_count(role, @user_counts)} + {get_user_count(role, @user_counts)} diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 91a5485..c84f258 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -38,25 +38,25 @@ {user.role.name} <:col :let={user} label={gettext("Linked Member")}> - <%= if user.member do %> + <.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}> {MvWeb.Helpers.MemberHelpers.display_name(user.member)} - <% else %> - {gettext("No member linked")} - <% end %> + <:col :let={user} label={gettext("Password")}> - <%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %> + <.maybe_value + value={MvWeb.Helpers.UserHelpers.has_password?(user)} + empty_sr_text={gettext("Not set")} + > {gettext("Enabled")} - <% else %> - - <% end %> + <:col :let={user} label={gettext("OIDC")}> - <%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %> + <.maybe_value + value={MvWeb.Helpers.UserHelpers.has_oidc?(user)} + empty_sr_text={gettext("Not set")} + > {gettext("Linked")} - <% else %> - - <% end %> + diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 00d5541..32409ea 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -591,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} member has a value assigned for this custom field." -msgid_plural "%{count} members have values assigned for this custom field." -msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen." -msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen." - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "All custom field values will be permanently deleted when you delete this custom field." -msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht." - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -788,6 +776,7 @@ msgstr "Beitragsdaten" msgid "Payments" msgstr "Zahlungen" +#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -1386,6 +1375,8 @@ msgid "None (no default)" msgstr "Keine (kein Standard)" #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Not set" msgstr "Nicht gesetzt" @@ -2918,11 +2909,6 @@ msgstr "Client-ID" msgid "Client Secret" msgstr "Client-Geheimnis" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom fields" -msgstr "Benutzerdefinierte Felder" - #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format @@ -2964,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI" msgid "Groups claim" msgstr "Gruppenclaim" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format -msgid "Member fields" -msgstr "Mitgliedsfelder" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee settings" @@ -3225,128 +3206,32 @@ msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege msgid "Manage users and their permissions." msgstr "Verwalte Benutzer*innen und ihre Berechtigungen." -#~ #: lib/mv_web/live/member_field_live/form_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Back to Settings" -#~ msgstr "Zurück zu den Einstellungen" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} member has a value assigned for this datafield." +msgid_plural "%{count} members have values assigned for this datafield." +msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen." +msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen." -#~ #: lib/mv_web/live/role_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Cannot delete system role" -#~ msgstr "System-Rolle kann nicht gelöscht werden" +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Individual Datafields" +msgstr "Individuelle Datenfelder" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Click for custom field details" -#~ msgstr "Klicke für Datenfeld-Details" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "No group assignment" +msgstr "Keine Gruppenzuordnung" -#~ #: lib/mv_web/live/member_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Click for datafield details" -#~ msgstr "Klicke für Datenfeld-Details" +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Not specified" +msgstr "Nicht angegeben" -#~ #: lib/mv_web/live/member_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Coming soon" -#~ msgstr "Demnächst verfügbar" - -#~ #: lib/mv_web/live/membership_fee_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Configure global settings and fee types for membership fees." -#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." - -#~ #: lib/mv_web/live/datafields_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Configure member fields and custom data fields." -#~ msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren." - -#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom Field %{id}" -#~ msgstr "Benutzerdefiniertes Feld %{id}" - -#~ #: lib/mv_web/live/import_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Datei auswählen" -#~ msgstr "Datei auswählen" - -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #: lib/mv_web/live/member_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Edit datafield" -#~ msgstr "Datenfeld bearbeiten" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Edit user" -#~ msgstr "Benutzer*in bearbeiten" - -#~ #: lib/mv_web/live/statistics_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Overview from first membership to today" -#~ msgstr "Übersicht vom ersten Eintritt bis heute" - -#~ #: lib/mv_web/live/components/member_filter_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reset" -#~ msgstr "Zurücksetzen" - -#~ #: lib/mv_web/live/role_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Rolle bearbeiten" -#~ msgstr "Rolle bearbeiten" - -#~ #: lib/mv_web/live/role_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Save Role" -#~ msgstr "Rolle speichern" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Select all users" -#~ msgstr "Alle Benutzer*innen auswählen" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Select user" -#~ msgstr "Benutzer*in auswählen" - -#~ #: lib/mv_web/live/role_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "System roles cannot be deleted" -#~ msgstr "System-Rollen können nicht gelöscht werden" - -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "This is a user record from your database." -#~ msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank." - -#~ #: lib/mv_web/live/membership_fee_type_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Use this form to manage membership fee types in your database." -#~ msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten." - -#~ #: lib/mv_web/live/role_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Use this form to manage roles in your database." -#~ msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten." - -#~ #: lib/mv_web/live/user_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Use this form to manage user records in your database." -#~ msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten." - -#~ #: lib/mv_web/live/group_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "View" -#~ msgstr "Anzeigen" - -#~ #: lib/mv_web/live/member_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to access this member" -#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" - -#~ #: lib/mv_web/live/user_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to access this user" -#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "All datafield values will be permanently deleted when you delete this datafield." +msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ec97119..6b80e27 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -592,18 +592,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} member has a value assigned for this custom field." -msgid_plural "%{count} members have values assigned for this custom field." -msgstr[0] "" -msgstr[1] "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "All custom field values will be permanently deleted when you delete this custom field." -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -789,6 +777,7 @@ msgstr "" msgid "Payments" msgstr "" +#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -1387,6 +1376,8 @@ msgid "None (no default)" msgstr "" #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Not set" msgstr "" @@ -2918,11 +2909,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format -msgid "Custom fields" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format @@ -2964,11 +2950,6 @@ msgstr "" msgid "Groups claim" msgstr "" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format -msgid "Member fields" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Membership fee settings" @@ -3224,3 +3205,33 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Manage users and their permissions." msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} member has a value assigned for this datafield." +msgid_plural "%{count} members have values assigned for this datafield." +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Individual Datafields" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No group assignment" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Not specified" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "All datafield values will be permanently deleted when you delete this datafield." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a0c41cc..2ea6be5 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -592,18 +592,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} member has a value assigned for this custom field." -msgid_plural "%{count} members have values assigned for this custom field." -msgstr[0] "" -msgstr[1] "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "All custom field values will be permanently deleted when you delete this custom field." -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -789,6 +777,7 @@ msgstr "" msgid "Payments" msgstr "" +#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -1387,6 +1376,8 @@ msgid "None (no default)" msgstr "" #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Not set" msgstr "" @@ -2918,11 +2909,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom fields" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format @@ -2964,11 +2950,6 @@ msgstr "" msgid "Groups claim" msgstr "" -#: lib/mv_web/live/datafields_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member fields" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee settings" @@ -3225,123 +3206,32 @@ msgstr "" msgid "Manage users and their permissions." msgstr "" -#~ #: lib/mv_web/live/member_field_live/form_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Back to Settings" -#~ msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} member has a value assigned for this datafield." +msgid_plural "%{count} members have values assigned for this datafield." +msgstr[0] "" +msgstr[1] "" -#~ #: lib/mv_web/live/role_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Cannot delete system role" -#~ msgstr "" +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Individual Datafields" +msgstr "" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Click for custom field details" -#~ msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "No group assignment" +msgstr "" -#~ #: lib/mv_web/live/member_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Click for datafield details" -#~ msgstr "" +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Not specified" +msgstr "" -#~ #: lib/mv_web/live/member_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Coming soon" -#~ msgstr "" - -#~ #: lib/mv_web/live/membership_fee_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Configure global settings and fee types for membership fees." -#~ msgstr "" - -#~ #: lib/mv_web/live/datafields_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Configure member fields and custom data fields." -#~ msgstr "" - -#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom Field %{id}" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #: lib/mv_web/live/member_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Edit datafield" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Edit user" -#~ msgstr "" - -#~ #: lib/mv_web/live/statistics_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Overview from first membership to today" -#~ msgstr "" - -#~ #: lib/mv_web/live/components/member_filter_component.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reset" -#~ msgstr "" - -#~ #: lib/mv_web/live/role_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Rolle bearbeiten" -#~ msgstr "" - -#~ #: lib/mv_web/live/role_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Save Role" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Select all users" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Select user" -#~ msgstr "" - -#~ #: lib/mv_web/live/role_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "System roles cannot be deleted" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "This is a user record from your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/membership_fee_type_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage membership fee types in your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/role_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage roles in your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage user records in your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/group_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "View" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to access this member" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "You do not have permission to access this user" -#~ msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "All datafield values will be permanently deleted when you delete this datafield." +msgstr "" diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 759ca1d..e841120 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -76,7 +76,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do refute has_element?(view, "h2", "Custom fields") # Should show correct member count (1 member) - assert render(view) =~ "1 member has a value assigned for this custom field" + assert render(view) =~ "1 member has a value assigned for this datafield" # Should show the slug assert render(view) =~ custom_field.slug @@ -95,7 +95,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do open_delete_modal(view, custom_field) # Should show plural form - assert render(view) =~ "2 members have values assigned for this custom field" + assert render(view) =~ "2 members have values assigned for this datafield" end test "shows 0 members for custom field without values", %{conn: conn} do @@ -105,7 +105,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do open_delete_modal(view, custom_field) # Should show 0 members - assert render(view) =~ "0 members have values assigned for this custom field" + assert render(view) =~ "0 members have values assigned for this datafield" end end diff --git a/test/mv_web/member_live/index_groups_display_test.exs b/test/mv_web/member_live/index_groups_display_test.exs index b28b978..263ac2a 100644 --- a/test/mv_web/member_live/index_groups_display_test.exs +++ b/test/mv_web/member_live/index_groups_display_test.exs @@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do assert html =~ member3.first_name end + test "empty group cell is visually empty with sr-only text (no dash)", %{ + conn: conn, + member3: member3 + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ member3.first_name + # Screen reader gets a meaningful label for the empty cell + assert html =~ "sr-only" + assert html =~ "No group assignment" + # No visible dash as placeholder (Design Guidelines §8.6) + refute html =~ ~r/]*class="[^"]*text-base-content\/50[^"]*"[^>]*>—<\/span>/ + end + test "displays group name correctly in badge", %{conn: conn, group1: group1} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 596d02d..c0be795 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -210,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do end describe "Password column display" do - test "user without password shows em dash in Password column", %{conn: conn} do + test "user without password shows empty cell with sr-only text in Password column", %{ + conn: conn + } do # User created with hashed_password: nil (no password) - must not get default password user_no_pw = create_test_user(%{ @@ -223,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ "no-password@example.com" - # Password column must show "—" (em dash) for user without password, not "Enabled" + # Password column: visually empty, screen-reader gets "Not set" (Design Guidelines §8.6) row = view |> element("tr#row-#{user_no_pw.id}") |> render() - assert row =~ "—", "Password column should show em dash for user without password" + assert row =~ "sr-only", "Password column should have sr-only text for accessibility" + assert row =~ "Not set", "Screen reader should get 'Not set' for empty password" + + refute row =~ "—", + "Password column must not show dash (use empty cell + sr-only per CODE_GUIDELINES §8)" refute row =~ "Enabled", "Password column must not show Enabled when user has no password" From 5516c7fe626c767998014ad7fcd8b868ae163199 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 14:02:47 +0100 Subject: [PATCH 137/237] fix: remove + from name in email field --- lib/mv_web/live/member_live/index.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 1be35b4..da1b6cf 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = if any_selected? do format_selected_member_emails(members, selected_members) |> Enum.join(", ") |> URI.encode_www_form() + |> String.replace("+", "%20") else "" end From c71c7d6ed6d28517b90eeb9ef790c1a42ad050db Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 15:24:29 +0100 Subject: [PATCH 138/237] fix: color contrast dark mode and keyboard moadals --- CODE_GUIDELINES.md | 5 +- assets/css/app.css | 7 +- assets/js/app.js | 26 + lib/mv_web/components/core_components.ex | 2 +- .../live/custom_field_live/form_component.ex | 1 + .../live/custom_field_live/index_component.ex | 30 +- lib/mv_web/live/datafields_live.ex | 48 +- lib/mv_web/live/group_live/show.ex | 37 +- lib/mv_web/live/member_live/form.ex | 559 +++++++++-------- lib/mv_web/live/member_live/show.ex | 63 +- .../show/membership_fees_component.ex | 39 +- lib/mv_web/live/role_live/show.ex | 196 +++--- lib/mv_web/live/user_live/form.ex | 590 +++++++++--------- lib/mv_web/live/user_live/show.ex | 204 +++--- 14 files changed, 1067 insertions(+), 740 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 48e2e8e..b3f1c3f 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -3016,6 +3016,8 @@ end - [ ] Tables have proper structure (th, scope, caption) - [ ] ARIA labels used for icon-only buttons - [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) +- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`. +- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs. ### 8.11 Modals and Dialogs @@ -3043,7 +3045,8 @@ Use a consistent, keyboard-accessible pattern for all confirmation and form moda **Closing:** - Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). -- Optionally support Escape to close via `phx-window-keydown` on the LiveView/LiveComponent. +- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel). +- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button). **Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button). diff --git a/assets/css/app.css b/assets/css/app.css index 094c030..6f00298 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -538,9 +538,14 @@ /* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */ [data-theme="dark"] { - --color-primary-content: oklch(0.97 0.02 277); --color-error: oklch(55% 0.253 17.585); --color-error-content: oklch(98% 0 0); + + --color-primary: oklch(72% 0.17 45); + --color-primary-content: oklch(0.18 0.02 47); + + --color-secondary: oklch(48% 0.233 277.117); + --color-secondary-content: oklch(98% 0 0); } /* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js index c17e7b5..b7d1a45 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -94,6 +94,32 @@ Hooks.TableRowKeydown = { } } +// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) +Hooks.FocusRestore = { + mounted() { + this.handleEvent("focus_restore", ({id}) => { + const el = document.getElementById(id) + if (el) el.focus() + }) + } +} + +// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex) +Hooks.TabListKeydown = { + mounted() { + this.handleKeydown = (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault() + } + } + this.el.addEventListener('keydown', this.handleKeydown) + }, + + destroyed() { + this.el.removeEventListener('keydown', this.handleKeydown) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 5f12f0a..78b8bfb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -477,7 +477,7 @@ defmodule MvWeb.CoreComponents do tabindex="0" role="button" aria-haspopup="menu" - aria-expanded={@open} + aria-expanded={if @open, do: "true", else: "false"} aria-controls={@id} aria-label={@button_label} class={["btn"] ++ @button_focus_classes ++ [@button_class]} diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index aac67dc..3872e56 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do )}

<.button + id="delete-custom-field-trigger" type="button" variant="danger" phx-click="request_delete" diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index abb19df..f9dca11 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -106,6 +106,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do class="modal modal-open" role="dialog" aria-labelledby="delete-custom-field-modal-title" + phx-keydown="dialog_keydown" > <.form diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 221a210..abb29e3 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -215,14 +215,16 @@ defmodule MvWeb.MemberLive.Form do <.form_section title={gettext("Membership Fee")}>
- + + +
+ +
+ +
0} class="mb-2">
@@ -356,69 +433,21 @@ defmodule MvWeb.Components.MemberFilterComponent do @impl true def handle_event("update_filters", params, socket) do - # Parse payment filter - payment_filter = - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - - # Parse per-group filters (params keys "group_" => "all"|"in"|"not_in") - prefix_len = String.length(@group_filter_prefix) + payment_filter = parse_payment_filter(params) group_filters_parsed = - params - |> Enum.filter(fn {key, _} -> String.starts_with?(key, @group_filter_prefix) end) - |> Enum.reduce(%{}, fn {key, value_str}, acc -> - group_id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) - filter_value = parse_group_filter_value(value_str) - Map.put(acc, group_id_str, filter_value) - end) + parse_prefix_filters(params, @group_filter_prefix, &parse_group_filter_value/1) - # Parse boolean custom field filters (including nil values for "all") - custom_boolean_filters_parsed = - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc -> - filter_value = parse_tri_state(value_str) - Map.put(acc, custom_field_id_str, filter_value) - end) + fee_type_filters_parsed = + parse_prefix_filters(params, @fee_type_filter_prefix, &parse_fee_type_filter_value/1) - # Update payment filter if changed - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end + custom_boolean_filters_parsed = parse_custom_boolean_filters(params) - # Update group filters - send event for each changed group - current_group_filters = socket.assigns.group_filters - all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + dispatch_payment_filter_change(socket, payment_filter) + dispatch_group_filter_changes(socket, group_filters_parsed) + dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) + dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) - Enum.each(group_filters_parsed, fn {group_id_str, new_value} -> - in_set = MapSet.member?(all_group_ids, group_id_str) - current_value = Map.get(current_group_filters, group_id_str) - should_send = in_set and current_value != new_value - - if should_send do - send(self(), {:group_filter_changed, group_id_str, new_value}) - end - end) - - # Update boolean filters - send events for each changed filter - current_filters = socket.assigns.boolean_filters - - # Process all custom field filters from form (including those set to "all"/nil) - # Radio buttons in a group always send a value, so all active filters are in the form - Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} -> - current_value = Map.get(current_filters, custom_field_id_str) - - # Only send event if value actually changed - if current_value != new_value do - send(self(), {:boolean_filter_changed, custom_field_id_str, new_value}) - end - end) - - # Don't close dropdown - allow multiple filter changes {:noreply, socket} end @@ -426,7 +455,7 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}, %{}}) + send(self(), {:reset_all_filters, nil, %{}, %{}, %{}}) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -442,11 +471,82 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_group_filter_value("not_in"), do: :not_in defp parse_group_filter_value(_), do: nil + defp parse_fee_type_filter_value("in"), do: :in + defp parse_fee_type_filter_value("not_in"), do: :not_in + defp parse_fee_type_filter_value(_), do: nil + + defp parse_payment_filter(params) do + case Map.get(params, "payment_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + end + + defp parse_prefix_filters(params, prefix, parse_value_fn) do + prefix_len = String.length(prefix) + + params + |> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) + Map.put(acc, id_str, parse_value_fn.(value_str)) + end) + end + + defp parse_custom_boolean_filters(params) do + params + |> Map.get("custom_boolean", %{}) + |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> + Map.put(acc, id_str, parse_tri_state(value_str)) + end) + end + + defp dispatch_payment_filter_change(socket, payment_filter) do + if payment_filter != socket.assigns.cycle_status_filter do + send(self(), {:payment_filter_changed, payment_filter}) + end + end + + defp dispatch_group_filter_changes(socket, group_filters_parsed) do + current = socket.assigns.group_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + + Enum.each(group_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:group_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do + current = socket.assigns.fee_type_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) + + Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:fee_type_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do + current = socket.assigns.boolean_filters + + Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> + if Map.get(current, id_str) != new_value do + send(self(), {:boolean_filter_changed, id_str, new_value}) + end + end) + end + # Get display label for button defp button_label( cycle_status_filter, groups, group_filters, + fee_types, + fee_type_filters, boolean_custom_fields, boolean_filters ) do @@ -457,6 +557,9 @@ defmodule MvWeb.Components.MemberFilterComponent do map_size(group_filters) > 0 -> group_filters_label(groups, group_filters) + map_size(fee_type_filters) > 0 -> + fee_type_filters_label(fee_types, fee_type_filters) + map_size(boolean_filters) > 0 -> boolean_filter_label(boolean_custom_fields, boolean_filters) @@ -480,6 +583,21 @@ defmodule MvWeb.Components.MemberFilterComponent do truncate_label(label, 30) end + defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, + do: gettext("All") + + defp fee_type_filters_label(fee_types, fee_type_filters) do + fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) + + names = + fee_type_filters + |> Enum.map(fn {fee_type_id_str, _} -> Map.get(fee_types_by_id, fee_type_id_str) end) + |> Enum.reject(&is_nil/1) + + label = Enum.join(names, ", ") + truncate_label(label, 30) + end + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -586,6 +704,39 @@ defmodule MvWeb.Components.MemberFilterComponent do end end + # Get CSS classes for per-fee-type filter label based on current state + defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do + base_classes = "join-item btn btn-sm" + current_value = Map.get(fee_type_filters, to_string(fee_type_id)) + is_active = current_value == expected_value + + cond do + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + expected_value == :in -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + expected_value == :not_in -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end + # Get CSS classes for boolean filter label based on current state defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do base_classes = "join-item btn btn-sm" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6cf532d..c745a3d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -33,6 +33,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership alias Mv.Membership.Member, as: MemberResource + alias Mv.MembershipFees + alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility @@ -42,6 +44,7 @@ defmodule MvWeb.MemberLive.Index do @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() @group_filter_prefix "group_" + @fee_type_filter_prefix "fee_type_" # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -89,6 +92,12 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + # Load membership fee types for filter dropdown (sorted by name) + fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!(domain: MembershipFees, actor: actor) + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -121,6 +130,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:cycle_status_filter, nil) |> assign(:group_filters, %{}) |> assign(:groups, groups) + |> assign(:fee_type_filters, %{}) + |> assign(:fee_types, fee_types) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:selected_member_id, nil) @@ -218,7 +229,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], new_show_current, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -300,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -339,7 +352,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -367,7 +381,8 @@ defmodule MvWeb.MemberLive.Index do filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -401,7 +416,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - updated_filters + updated_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -437,7 +453,45 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, group_filters, socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + + @impl true + def handle_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do + normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str + + fee_type_filters = + if filter_value == nil do + Map.delete(socket.assigns.fee_type_filters, normalized_id) + else + Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value) + end + + socket = + socket + |> assign(:fee_type_filters, fee_type_filters) + |> load_members() + |> update_selection_assigns() + + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters, + fee_type_filters ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -450,17 +504,29 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do - handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket) + handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}, %{}}, socket) end def handle_info( {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters}, socket ) do + handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, %{}}, + socket + ) + end + + def handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, + fee_type_filters}, + socket + ) do socket = socket |> assign(:cycle_status_filter, cycle_status_filter) |> assign(:group_filters, group_filters) + |> assign(:fee_type_filters, fee_type_filters) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -473,7 +539,8 @@ defmodule MvWeb.MemberLive.Index do cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - boolean_filters + boolean_filters, + fee_type_filters ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -598,6 +665,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) |> maybe_update_group_filters(params) + |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) @@ -646,6 +714,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns[:group_filters], + socket.assigns[:fee_type_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -739,7 +808,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) @@ -758,15 +828,24 @@ defmodule MvWeb.MemberLive.Index do cycle_status_filter, group_filters, show_current_cycle, - boolean_filters + boolean_filters, + fee_type_filters ) do base_params = build_base_params(query, sort_field, sort_order) base_params = add_cycle_status_filter(base_params, cycle_status_filter) base_params = add_group_filters(base_params, group_filters) + base_params = add_fee_type_filters(base_params, fee_type_filters || %{}) base_params = add_show_current_cycle(base_params, show_current_cycle) add_boolean_filters(base_params, boolean_filters) end + defp add_fee_type_filters(params, fee_type_filters) do + Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc -> + param_value = if value == :in, do: "in", else: "not_in" + Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value) + end) + end + defp compute_final_field_selection(true, url_selection, socket) do only_url = FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) @@ -941,6 +1020,9 @@ defmodule MvWeb.MemberLive.Index do query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + query = + apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -1064,6 +1146,55 @@ defmodule MvWeb.MemberLive.Index do defp apply_one_group_filter(query, _, _), do: query + # Multiple fee type filters combine with AND: member must match all selected fee type conditions. + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = + fee_types + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + Enum.reduce(fee_type_filters, query, fn {fee_type_id_str, value}, q -> + member? = MapSet.member?(valid_ids, fee_type_id_str) + + if member? do + apply_one_fee_type_filter(q, fee_type_id_str, value) + else + q + end + end) + end + + defp apply_one_fee_type_filter(query, _fee_type_id_str, nil), do: query + + defp apply_one_fee_type_filter(query, fee_type_id_str, :in) do + case Ecto.UUID.cast(fee_type_id_str) do + {:ok, fee_type_uuid} -> + Ash.Query.filter(query, expr(membership_fee_type_id == ^fee_type_uuid)) + + _ -> + query + end + end + + defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do + case Ecto.UUID.cast(fee_type_id_str) do + {:ok, fee_type_uuid} -> + Ash.Query.filter( + query, + expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + query + end + end + + defp apply_one_fee_type_filter(query, _, _), do: query + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) @@ -1397,6 +1528,52 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_group_filters(socket, _), do: socket + defp maybe_update_fee_type_filters(socket, params) when is_map(params) do + prefix = @fee_type_filter_prefix + prefix_len = String.length(prefix) + + fee_type_param_entries = + params + |> Enum.filter(fn {key, _} -> + key_str = to_string(key) + String.starts_with?(key_str, prefix) + end) + + filters = + Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc -> + add_fee_type_filter_entry(acc, key, value_str, prefix_len) + end) + + assign(socket, :fee_type_filters, filters) + end + + defp maybe_update_fee_type_filters(socket, _), do: socket + + defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do + key_str = to_string(key) + raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) + fee_type_id_str = normalize_uuid_string(raw_id) + valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length + + if valid_id? do + case parse_fee_type_filter_value(value_str) do + nil -> acc + value -> Map.put(acc, fee_type_id_str, value) + end + else + acc + end + end + + defp parse_fee_type_filter_value("in"), do: :in + defp parse_fee_type_filter_value("not_in"), do: :not_in + + defp parse_fee_type_filter_value(val) when is_binary(val) do + parse_fee_type_filter_value(String.trim(val)) + end + + defp parse_fee_type_filter_value(_), do: nil + defp add_group_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 84167c4..b35d426 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -50,6 +50,8 @@ cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} + fee_types={@fee_types} + fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} From 3af52f2829f5b49ddc564442f23ae1303f6fe4ee Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Mar 2026 20:46:42 +0100 Subject: [PATCH 177/237] Update gettext: extract and merge after fee type filter strings --- 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 insertions(+) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 982798d..0f07068 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3232,3 +3232,8 @@ msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee types" +msgstr "Beitragsart" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5b6ef4c..3ca889b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3232,3 +3232,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Fee types" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a566be0..6c6069a 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3232,3 +3232,8 @@ msgstr "Default type: Assigned to new members; can be changed per member." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee types" +msgstr "" From ae07e3efc2c8330ac3a8ad4264c8957f22b8f446 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 14:28:31 +0100 Subject: [PATCH 178/237] Add filter prefix constants and shared FilterParams module - Mv.Constants: group_filter_prefix/0, fee_type_filter_prefix/0 - MvWeb.MemberLive.Index.FilterParams: parse_in_not_in_value/1 for URL param parsing --- lib/mv/constants.ex | 14 ++++++++++++ .../live/member_live/index/filter_params.ex | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/filter_params.ex diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bb6274..517ad2f 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -22,6 +22,10 @@ defmodule Mv.Constants do @boolean_filter_prefix "bf_" + @group_filter_prefix "group_" + + @fee_type_filter_prefix "fee_type_" + @max_boolean_filters 50 @max_uuid_length 36 @@ -70,6 +74,16 @@ defmodule Mv.Constants do """ def boolean_filter_prefix, do: @boolean_filter_prefix + @doc """ + Returns the prefix for group filter URL parameters (e.g. group_=in|not_in). + """ + def group_filter_prefix, do: @group_filter_prefix + + @doc """ + Returns the prefix for fee type filter URL parameters (e.g. fee_type_=in|not_in). + """ + def fee_type_filter_prefix, do: @fee_type_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex new file mode 100644 index 0000000..9b5e800 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -0,0 +1,22 @@ +defmodule MvWeb.MemberLive.Index.FilterParams do + @moduledoc """ + Shared parsing helpers for member list filter URL/params (in/not_in style). + Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs. + """ + @doc """ + Parses a value for group or fee-type filter params. + Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. + """ + def parse_in_not_in_value("in"), do: :in + def parse_in_not_in_value("not_in"), do: :not_in + + def parse_in_not_in_value(val) when is_binary(val) do + case String.trim(val) do + "in" -> :in + "not_in" -> :not_in + _ -> nil + end + end + + def parse_in_not_in_value(_), do: nil +end From 8da22b3d8835754d77b3eb8bd998d5272b21cb17 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 14:28:50 +0100 Subject: [PATCH 179/237] Apply review feedback and fix Credo in fee type filter - Index: use FilterParams and constants; fix parse recursion; validate fee type/group IDs; OR semantics for :in; build_query_params/reset_all_filters map-based API; alias order (Credo); Map.take list deprecation fix - MemberFilterComponent: use FilterParams and constants; fee_type_filter_part helper (Credo nesting); in_not_in_filter_label_class; reset_all_filters map; button label for :not_in and combined filter count; fieldset borders - Gettext: Fee types, filter count plural, 'without %{name}' (en/de) --- .../components/member_filter_component.ex | 170 ++++++----- lib/mv_web/live/member_live/index.ex | 264 ++++++++---------- priv/gettext/de/LC_MESSAGES/default.po | 16 +- priv/gettext/default.pot | 12 + priv/gettext/en/LC_MESSAGES/default.po | 16 +- 5 files changed, 233 insertions(+), 245 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 56c5666..ddd3538 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -34,8 +34,10 @@ defmodule MvWeb.Components.MemberFilterComponent do """ use MvWeb, :live_component - @group_filter_prefix "group_" - @fee_type_filter_prefix "fee_type_" + alias MvWeb.MemberLive.Index.FilterParams + + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() @impl true def mount(socket) do @@ -201,7 +203,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{group.name} @@ -268,7 +270,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{fee_type.name} @@ -335,7 +337,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{custom_field.name} @@ -436,10 +438,10 @@ defmodule MvWeb.Components.MemberFilterComponent do payment_filter = parse_payment_filter(params) group_filters_parsed = - parse_prefix_filters(params, @group_filter_prefix, &parse_group_filter_value/1) + parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) fee_type_filters_parsed = - parse_prefix_filters(params, @fee_type_filter_prefix, &parse_fee_type_filter_value/1) + parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) custom_boolean_filters_parsed = parse_custom_boolean_filters(params) @@ -455,7 +457,16 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}, %{}, %{}}) + send( + self(), + {:reset_all_filters, + %{ + cycle_status_filter: nil, + boolean_filters: %{}, + group_filters: %{}, + fee_type_filters: %{} + }} + ) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -467,14 +478,6 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_tri_state("all"), do: nil defp parse_tri_state(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - defp parse_group_filter_value(_), do: nil - - defp parse_fee_type_filter_value("in"), do: :in - defp parse_fee_type_filter_value("not_in"), do: :not_in - defp parse_fee_type_filter_value(_), do: nil - defp parse_payment_filter(params) do case Map.get(params, "payment_filter") do "paid" -> :paid @@ -550,24 +553,53 @@ defmodule MvWeb.Components.MemberFilterComponent do boolean_custom_fields, boolean_filters ) do - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) + active_count = + count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) + if active_count >= 2 do + ngettext("%{count} filter active", "%{count} filters active", active_count, + count: active_count + ) + else + cond do + cycle_status_filter -> + payment_filter_label(cycle_status_filter) - map_size(fee_type_filters) > 0 -> - fee_type_filters_label(fee_types, fee_type_filters) + map_size(group_filters) > 0 -> + group_filters_label(groups, group_filters) - map_size(boolean_filters) > 0 -> - boolean_filter_label(boolean_custom_fields, boolean_filters) + map_size(fee_type_filters) > 0 -> + fee_type_filters_label(fee_types, fee_type_filters) - true -> - gettext("Apply filters") + map_size(boolean_filters) > 0 -> + boolean_filter_label(boolean_custom_fields, boolean_filters) + + true -> + gettext("Apply filters") + end end end + defp count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) do + [ + cycle_status_filter, + map_size(group_filters) > 0, + map_size(fee_type_filters) > 0, + map_size(boolean_filters) > 0 + ] + |> Enum.count(& &1) + end + defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, do: gettext("All") @@ -589,15 +621,22 @@ defmodule MvWeb.Components.MemberFilterComponent do defp fee_type_filters_label(fee_types, fee_type_filters) do fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) - names = + parts = fee_type_filters - |> Enum.map(fn {fee_type_id_str, _} -> Map.get(fee_types_by_id, fee_type_id_str) end) + |> Enum.map(fn {fee_type_id_str, value} -> + fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) + end) |> Enum.reject(&is_nil/1) - label = Enum.join(names, ", ") + label = Enum.join(parts, ", ") truncate_label(label, 30) end + defp fee_type_filter_part(nil, _value), do: nil + + defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) + defp fee_type_filter_part(name, _), do: name + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -671,70 +710,27 @@ defmodule MvWeb.Components.MemberFilterComponent do end end - # Get CSS classes for per-group filter label based on current state - defp group_filter_label_class(group_filters, group_id, expected_value) do + # Shared CSS classes for in/not_in filter labels (groups and fee types) + defp in_not_in_filter_label_class(filters, id, expected_value) do base_classes = "join-item btn btn-sm" - current_value = Map.get(group_filters, to_string(group_id)) + current_value = Map.get(filters, to_string(id)) is_active = current_value == expected_value - cond do - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - expected_value == :in -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - expected_value == :not_in -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" + case {expected_value, is_active} do + {_, false} -> "#{base_classes} btn" + {nil, true} -> "#{base_classes} btn-active" + {:in, true} -> "#{base_classes} btn-success btn-active" + {:not_in, true} -> "#{base_classes} btn-error btn-active" + _ -> "#{base_classes} btn-outline" end end - # Get CSS classes for per-fee-type filter label based on current state + defp group_filter_label_class(group_filters, group_id, expected_value) do + in_not_in_filter_label_class(group_filters, group_id, expected_value) + end + defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(fee_type_filters, to_string(fee_type_id)) - is_active = current_value == expected_value - - cond do - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - expected_value == :in -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - expected_value == :not_in -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end + in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) end # Get CSS classes for boolean filter label based on current state diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c745a3d..e2e037d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -38,13 +38,14 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility + alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() - @group_filter_prefix "group_" - @fee_type_filter_prefix "fee_type_" + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -222,16 +223,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - new_show_current, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -306,14 +298,10 @@ defmodule MvWeb.MemberLive.Index do # URL sync - push_patch happens synchronously in the event handler query_params = build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] + opts_for_query_params(socket, %{ + sort_field: export_sort_field(socket.assigns.sort_field), + sort_order: export_sort_order(socket.assigns.sort_order) + }) ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -345,16 +333,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - q, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{query: q})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -374,16 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -409,16 +379,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - updated_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -446,16 +407,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - group_filters, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{group_filters: group_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -483,16 +435,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - fee_type_filters - ) + build_query_params(opts_for_query_params(socket, %{fee_type_filters: fee_type_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -502,9 +445,18 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end - @impl true + # Backward compatibility: tuple form delegates to map form def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do - handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}, %{}}, socket) + handle_info( + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: %{}, + fee_type_filters: %{} + }}, + socket + ) end def handle_info( @@ -512,7 +464,13 @@ defmodule MvWeb.MemberLive.Index do socket ) do handle_info( - {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, %{}}, + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: %{} + }}, socket ) end @@ -522,26 +480,30 @@ defmodule MvWeb.MemberLive.Index do fee_type_filters}, socket ) do + handle_info( + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: fee_type_filters + }}, + socket + ) + end + + def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, cycle_status_filter) - |> assign(:group_filters, group_filters) - |> assign(:fee_type_filters, fee_type_filters) - |> assign(:boolean_custom_field_filters, boolean_filters) + |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:group_filters, Map.get(opts, :group_filters, %{})) + |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) + |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> load_members() |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - boolean_filters, - fee_type_filters - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -801,16 +763,7 @@ defmodule MvWeb.MemberLive.Index do defp push_field_selection_url(socket) do query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" @@ -821,22 +774,27 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :user_field_selection, selection) end - defp build_query_params( - query, - sort_field, - sort_order, - cycle_status_filter, - group_filters, - show_current_cycle, - boolean_filters, - fee_type_filters - ) do - base_params = build_base_params(query, sort_field, sort_order) - base_params = add_cycle_status_filter(base_params, cycle_status_filter) - base_params = add_group_filters(base_params, group_filters) - base_params = add_fee_type_filters(base_params, fee_type_filters || %{}) - base_params = add_show_current_cycle(base_params, show_current_cycle) - add_boolean_filters(base_params, boolean_filters) + defp build_query_params(opts) when is_map(opts) do + base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) + base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) + base_params = add_group_filters(base_params, opts.group_filters || %{}) + base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) + base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + add_boolean_filters(base_params, opts.boolean_filters || %{}) + end + + defp opts_for_query_params(socket, overrides \\ %{}) do + %{ + query: socket.assigns.query, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + cycle_status_filter: socket.assigns.cycle_status_filter, + group_filters: socket.assigns[:group_filters] || %{}, + show_current_cycle: socket.assigns.show_current_cycle, + boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, + fee_type_filters: socket.assigns[:fee_type_filters] || %{} + } + |> Map.merge(overrides) end defp add_fee_type_filters(params, fee_type_filters) do @@ -1146,7 +1104,8 @@ defmodule MvWeb.MemberLive.Index do defp apply_one_group_filter(query, _, _), do: query - # Multiple fee type filters combine with AND: member must match all selected fee type conditions. + # Fee type filters: :in selections combine with OR (member has any of the selected types); + # :not_in selections combine with AND (member must not have type A and not have type B). defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, do: query @@ -1157,27 +1116,28 @@ defmodule MvWeb.MemberLive.Index do |> Enum.reject(&is_nil/1) |> MapSet.new() - Enum.reduce(fee_type_filters, query, fn {fee_type_id_str, value}, q -> - member? = MapSet.member?(valid_ids, fee_type_id_str) + {in_id_strs, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) - if member? do - apply_one_fee_type_filter(q, fee_type_id_str, value) - else - q - end - end) - end + in_uuids = + in_id_strs + |> Enum.map(fn {id_str, _} -> id_str end) + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) - defp apply_one_fee_type_filter(query, _fee_type_id_str, nil), do: query - - defp apply_one_fee_type_filter(query, fee_type_id_str, :in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter(query, expr(membership_fee_type_id == ^fee_type_uuid)) - - _ -> + query = + if in_uuids == [] do query - end + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> + apply_one_fee_type_filter(q, fee_type_id_str, :not_in) + end) end defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do @@ -1523,7 +1483,14 @@ defmodule MvWeb.MemberLive.Index do add_group_filter_entry(acc, key, value_str, prefix_len) end) - assign(socket, :group_filters, filters) + valid_group_ids = + socket.assigns.groups + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :group_filters, Map.take(filters, valid_group_ids)) end defp maybe_update_group_filters(socket, _), do: socket @@ -1544,7 +1511,14 @@ defmodule MvWeb.MemberLive.Index do add_fee_type_filter_entry(acc, key, value_str, prefix_len) end) - assign(socket, :fee_type_filters, filters) + valid_fee_type_ids = + socket.assigns.fee_types + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) end defp maybe_update_fee_type_filters(socket, _), do: socket @@ -1556,7 +1530,7 @@ defmodule MvWeb.MemberLive.Index do valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length if valid_id? do - case parse_fee_type_filter_value(value_str) do + case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, fee_type_id_str, value) end @@ -1565,15 +1539,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp parse_fee_type_filter_value("in"), do: :in - defp parse_fee_type_filter_value("not_in"), do: :not_in - - defp parse_fee_type_filter_value(val) when is_binary(val) do - parse_fee_type_filter_value(String.trim(val)) - end - - defp parse_fee_type_filter_value(_), do: nil - defp add_group_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) @@ -1581,7 +1546,7 @@ defmodule MvWeb.MemberLive.Index do valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length if valid_id? do - case parse_group_filter_value(value_str) do + case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, group_id_str, value) end @@ -1600,15 +1565,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - - defp parse_group_filter_value(val) when is_binary(val) do - parse_group_filter_value(String.trim(val)) - end - - defp parse_group_filter_value(_), do: nil - defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0f07068..ec26b39 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3234,6 +3234,18 @@ msgid "Include joining cycle: When active, members pay from their joining cycle; msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." #: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee types" -msgstr "Beitragsart" +msgstr "Beitragsarten" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} Filter aktiv" +msgstr[1] "%{count} Filter aktiv" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "ohne %{name}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3ca889b..d5efdd8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3237,3 +3237,15 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee types" msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6c6069a..9a76cc8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3234,6 +3234,18 @@ msgid "Include joining cycle: When active, members pay from their joining cycle; msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." #: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee types" -msgstr "" +msgstr "Fee types" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} filter active" +msgstr[1] "%{count} filters active" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "without %{name}" From 2515a679b87068e2fe42050ca8ec3c1f12521fb5 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 14:44:45 +0100 Subject: [PATCH 180/237] feat: add join request resource --- CODE_GUIDELINES.md | 2 + docs/development-progress-log.md | 8 ++ lib/membership/join_request.ex | 133 ++++++++++++++++++ .../join_request/changes/confirm_request.ex | 17 +++ .../changes/set_confirmation_token.ex | 32 +++++ lib/membership/membership.ex | 52 +++++++ lib/mv/authorization/checks/actor_is_nil.ex | 17 +++ .../20260309141437_add_join_requests.exs | 53 +++++++ test/membership/join_request_test.exs | 14 +- 9 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 lib/membership/join_request.ex create mode 100644 lib/membership/join_request/changes/confirm_request.ex create mode 100644 lib/membership/join_request/changes/set_confirmation_token.ex create mode 100644 lib/mv/authorization/checks/actor_is_nil.ex create mode 100644 priv/repo/migrations/20260309141437_add_join_requests.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index c3de14b..18036db 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -85,6 +85,8 @@ lib/ ├── membership/ # Membership domain │ ├── membership.ex # Domain definition │ ├── member.ex # Member resource +│ ├── join_request.ex # JoinRequest (public join form, double opt-in) +│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest) │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource │ ├── setting.ex # Global settings (singleton resource) diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 97c586b..84687d1 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -792,6 +792,14 @@ defmodule MvWeb.Components.SearchBarTest do end ``` +### Onboarding / Join (Issue #308, TDD) + +**Subtask 1 – JoinRequest resource and public policies (done):** +- Resource: `Mv.Membership.JoinRequest` with attributes (status, email, first_name, last_name, form_data, schema_version, confirmation_token_hash, confirmation_token_expires_at, submitted_at, etc.), actions `submit` (create), `get_by_confirmation_token_hash` (read), `confirm` (update). Migration: `20260309141437_add_join_requests.exs`. +- Policies: Public actions allowed with `actor: nil` via `Mv.Authorization.Checks.ActorIsNil` (submit, get_by_confirmation_token_hash, confirm); default read remains Forbidden for unauthenticated. +- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing and lookup in domain). +- Test file: `test/membership/join_request_test.exs` – all tests pass; policy test (read with actor nil → Forbidden) unskipped. Expired-token test still skipped (fixture for expired token to be added in Subtask 2 or later). + ### Test Data Management **Seed Data:** diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex new file mode 100644 index 0000000..d42aa1e --- /dev/null +++ b/lib/membership/join_request.ex @@ -0,0 +1,133 @@ +defmodule Mv.Membership.JoinRequest do + @moduledoc """ + Ash resource for public join requests (onboarding, double opt-in). + + A JoinRequest is created on form submit with status `pending_confirmation`, then + updated to `submitted` when the user clicks the confirmation link. No User or + Member is created in this flow; promotion happens in a later approval step. + + ## Public actions (actor: nil) + - `submit` (create) – create with token hash and expiry + - `get_by_confirmation_token_hash` (read) – lookup by token hash for confirm flow + - `confirm` (update) – set status to submitted and invalidate token + + ## Schema + Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb). + Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc. + """ + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table "join_requests" + repo Mv.Repo + end + + actions do + defaults [:read, :destroy] + + create :submit do + description "Create a join request (public form submit); stores token hash and expiry" + primary? true + + argument :confirmation_token, :string, allow_nil?: false + + accept [:email, :first_name, :last_name, :form_data, :schema_version] + + change Mv.Membership.JoinRequest.Changes.SetConfirmationToken + end + + read :get_by_confirmation_token_hash do + description "Find a join request by confirmation token hash (for confirm flow only)" + argument :confirmation_token_hash, :string, allow_nil?: false + + filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash)) + + prepare build(sort: [inserted_at: :desc], limit: 1) + end + + update :confirm do + description "Mark join request as submitted and invalidate token (after link click)" + primary? true + require_atomic? false + + change Mv.Membership.JoinRequest.Changes.ConfirmRequest + end + end + + policies do + policy action(:submit) do + description "Allow unauthenticated submit (public join form)" + authorize_if Mv.Authorization.Checks.ActorIsNil + end + + policy action(:get_by_confirmation_token_hash) do + description "Allow unauthenticated lookup by token hash for confirm" + authorize_if Mv.Authorization.Checks.ActorIsNil + end + + policy action(:confirm) do + description "Allow unauthenticated confirm (confirmation link click)" + authorize_if Mv.Authorization.Checks.ActorIsNil + end + + # Default read/destroy: no policy for actor nil → Forbidden + end + + validations do + validate present(:email), on: [:create] + end + + attributes do + uuid_primary_key :id + + attribute :status, :atom do + description "pending_confirmation | submitted | approved | rejected" + default :pending_confirmation + constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected] + allow_nil? false + end + + attribute :email, :string do + description "Email address (required for join form)" + allow_nil? false + end + + attribute :first_name, :string + attribute :last_name, :string + + attribute :form_data, :map do + description "Additional form fields (jsonb)" + end + + attribute :schema_version, :integer do + description "Version of join form / member_fields for form_data" + end + + attribute :confirmation_token_hash, :string do + description "SHA256 hash of confirmation token; raw token only in email link" + end + + attribute :confirmation_token_expires_at, :utc_datetime_usec do + description "When the confirmation link expires (e.g. 24h)" + end + + attribute :confirmation_sent_at, :utc_datetime_usec do + description "When the confirmation email was sent" + end + + attribute :submitted_at, :utc_datetime_usec do + description "When the user confirmed (clicked the link)" + end + + attribute :approved_at, :utc_datetime_usec + attribute :rejected_at, :utc_datetime_usec + attribute :reviewed_by_user_id, :uuid + attribute :source, :string + + create_timestamp :inserted_at + update_timestamp :updated_at + end +end diff --git a/lib/membership/join_request/changes/confirm_request.ex b/lib/membership/join_request/changes/confirm_request.ex new file mode 100644 index 0000000..477561f --- /dev/null +++ b/lib/membership/join_request/changes/confirm_request.ex @@ -0,0 +1,17 @@ +defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do + @moduledoc """ + Sets the join request to submitted (confirmation link clicked). + + Used by the confirm action after the user clicks the confirmation link. + Token hash is kept so that a second click (idempotent) can still find the record + and return success without changing it. + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + changeset + |> Ash.Changeset.force_change_attribute(:status, :submitted) + |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + end +end diff --git a/lib/membership/join_request/changes/set_confirmation_token.ex b/lib/membership/join_request/changes/set_confirmation_token.ex new file mode 100644 index 0000000..b052799 --- /dev/null +++ b/lib/membership/join_request/changes/set_confirmation_token.ex @@ -0,0 +1,32 @@ +defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do + @moduledoc """ + Hashes the confirmation token and sets expiry for the join request (submit flow). + + Reads the :confirmation_token argument, stores only its SHA256 hash and sets + confirmation_token_expires_at (e.g. 24h). Raw token is never persisted. + """ + use Ash.Resource.Change + + @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 = token_hash(token) + expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour) + + changeset + |> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash) + |> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at) + |> Ash.Changeset.force_change_attribute(:status, :pending_confirmation) + else + changeset + end + end + + defp token_hash(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2583718..66e2f9b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -9,6 +9,7 @@ defmodule Mv.Membership do - `Setting` - Global application settings (singleton) - `Group` - Groups that members can belong to - `MemberGroup` - Join table for many-to-many relationship between Members and Groups + - `JoinRequest` - Public join form submissions (pending_confirmation → submitted after email confirm) ## Public API The domain exposes these main actions: @@ -27,6 +28,8 @@ defmodule Mv.Membership do require Ash.Query import Ash.Expr + alias Ash.Error.Query.NotFound, as: NotFoundError + alias Mv.Membership.JoinRequest admin do show? true @@ -80,6 +83,10 @@ defmodule Mv.Membership do define :list_member_groups, action: :read define :destroy_member_group, action: :destroy end + + resource Mv.Membership.JoinRequest do + define :submit_join_request, action: :submit + end end # Singleton pattern: Get the single settings record @@ -342,4 +349,49 @@ defmodule Mv.Membership do |> Keyword.put_new(:domain, __MODULE__) |> then(&Ash.read_one(query, &1)) end + + @doc """ + Confirms a join request by token (public confirmation link). + + Hashes the token, finds the JoinRequest by confirmation_token_hash, then updates + to status :submitted and invalidates the token. Idempotent: if already submitted, + returns the existing record without changing it. + + ## Options + - `:actor` - Must be nil for public confirm (policy allows only unauthenticated). + + ## Returns + - `{:ok, request}` - Updated or already-submitted JoinRequest + - `{:error, error}` - Token unknown/invalid or authorization error + """ + def confirm_join_request(token, opts \\ []) when is_binary(token) do + hash = confirmation_token_hash(token) + actor = Keyword.get(opts, :actor) + + query = + Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{ + confirmation_token_hash: hash + }) + + case Ash.read_one(query, actor: actor, domain: __MODULE__) do + {:ok, nil} -> + {:error, NotFoundError.exception(resource: JoinRequest)} + + {:ok, request} -> + if request.status == :submitted do + {:ok, request} + else + request + |> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__) + |> Ash.update(domain: __MODULE__, actor: actor) + end + + {:error, error} -> + {:error, error} + end + end + + defp confirmation_token_hash(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end end diff --git a/lib/mv/authorization/checks/actor_is_nil.ex b/lib/mv/authorization/checks/actor_is_nil.ex new file mode 100644 index 0000000..ed8474e --- /dev/null +++ b/lib/mv/authorization/checks/actor_is_nil.ex @@ -0,0 +1,17 @@ +defmodule Mv.Authorization.Checks.ActorIsNil do + @moduledoc """ + Policy check: true only when the actor is nil (unauthenticated). + + Used for the public join flow so that submit and confirm actions are allowed + only when called without an authenticated user (e.g. from the public /join form + and confirmation link). See docs/onboarding-join-concept.md. + """ + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "actor is nil (unauthenticated)" + + @impl true + def match?(nil, _context, _opts), do: true + def match?(_actor, _context, _opts), do: false +end diff --git a/priv/repo/migrations/20260309141437_add_join_requests.exs b/priv/repo/migrations/20260309141437_add_join_requests.exs new file mode 100644 index 0000000..e4921ca --- /dev/null +++ b/priv/repo/migrations/20260309141437_add_join_requests.exs @@ -0,0 +1,53 @@ +defmodule Mv.Repo.Migrations.AddJoinRequests do + @moduledoc """ + Creates join_requests table for the public join flow (onboarding, double opt-in). + + Stores join form submissions with status pending_confirmation → submitted (after email confirm). + Token stored as hash only; 24h retention for unconfirmed records (cleanup via scheduled job). + """ + + use Ecto.Migration + + def up do + create table(:join_requests, primary_key: false) do + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true + + add :status, :text, null: false, default: "pending_confirmation" + add :email, :text, null: false + add :first_name, :text + add :last_name, :text + add :form_data, :map + add :schema_version, :integer + + add :confirmation_token_hash, :text + add :confirmation_token_expires_at, :utc_datetime_usec + add :confirmation_sent_at, :utc_datetime_usec + + add :submitted_at, :utc_datetime_usec + add :approved_at, :utc_datetime_usec + add :rejected_at, :utc_datetime_usec + add :reviewed_by_user_id, :uuid + add :source, :text + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + create unique_index(:join_requests, [:confirmation_token_hash], + name: "join_requests_confirmation_token_hash_unique", + where: "confirmation_token_hash IS NOT NULL" + ) + + create index(:join_requests, [:email]) + create index(:join_requests, [:status]) + end + + def down do + drop table(:join_requests) + end +end diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 1d8ff95..2123730 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -21,7 +21,12 @@ defmodule Mv.Membership.JoinRequestTest do describe "submit_join_request/2 (create with actor: nil)" do test "creates JoinRequest in pending_confirmation with valid attributes and actor nil" do - attrs = Map.put(@valid_submit_attrs, :confirmation_token, "test-token-#{System.unique_integer([:positive])}") + attrs = + Map.put( + @valid_submit_attrs, + :confirmation_token, + "test-token-#{System.unique_integer([:positive])}" + ) assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil) @@ -98,10 +103,9 @@ defmodule Mv.Membership.JoinRequestTest do end describe "policies (actor: nil)" do - @tag :skip - test "read with actor nil returns Forbidden (unskip and add: Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) -> expect Forbidden)" do - # When JoinRequest resource exists: assert {:error, %Ash.Error.Forbidden{}} = Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) - flunk("Add JoinRequest resource, then unskip and replace this with the Ash.read assertion") + test "read with actor nil returns Forbidden" do + assert {:error, %Ash.Error.Forbidden{}} = + Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) end end From d032f1ca0c70301daddd46da65cd4bce3ed19af0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 15:10:50 +0100 Subject: [PATCH 181/237] Run bootstrap seeds in production; add RUN_DEV_SEEDS support --- docs/admin-bootstrap-and-oidc-role-sync.md | 8 +++-- lib/mv/release.ex | 36 ++++++++++++++++++++++ priv/repo/seeds.exs | 3 ++ priv/repo/seeds_bootstrap.exs | 11 ++++++- rel/overlays/bin/docker-entrypoint.sh | 3 ++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index abbd03f..5e26c85 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -2,24 +2,26 @@ ## Overview -- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before server). Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. +- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. - **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in. ## Admin Bootstrap (Part A) ### Environment Variables +- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run. - `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. - `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change). - `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). -### Release Task +### Release Tasks +- `Mv.Release.run_seeds/0` – Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent. - `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent. ### Entrypoint -- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs seed_admin(), then starts the server. +- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs run_seeds(), then seed_admin(), then starts the server. ### Seeds (Dev/Test) diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 54bc245..00dcadf 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -6,6 +6,8 @@ defmodule Mv.Release do ## Tasks - `migrate/0` - Runs all pending Ecto migrations. + - `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings). + In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). - `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell to update the admin password without redeploying. @@ -26,6 +28,40 @@ defmodule Mv.Release do end end + @doc """ + Runs seed scripts so the database has required bootstrap data (and optionally dev data). + + - Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings). + - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data). + + Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent. + """ + def run_seeds do + case Application.ensure_all_started(@app) do + {:ok, _} -> :ok + {:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}" + end + + priv = :code.priv_dir(@app) + bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") + dev_path = Path.join(priv, "repo/seeds_dev.exs") + + prev = Code.compiler_options() + Code.compiler_options(ignore_module_conflict: true) + + try do + Code.eval_file(bootstrap_path) + IO.puts("✅ Bootstrap seeds completed.") + + if System.get_env("RUN_DEV_SEEDS") == "true" do + Code.eval_file(dev_path) + IO.puts("✅ Dev seeds completed.") + end + after + Code.compiler_options(prev) + end + end + def rollback(repo, version) do load_app() {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 44df447..7257f8b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,6 +5,9 @@ # Bootstrap runs in all environments. Dev seeds (members, groups, sample data) # run only in dev and test. # +# In production (release): seeds are run via Mv.Release.run_seeds/0 from the +# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there. +# # Compiler option ignore_module_conflict is set only during seed evaluation # so that eval_file of bootstrap/dev does not emit "redefining module" warnings; # it is always restored in `after` to avoid hiding real conflicts elsewhere. diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs index 94b8cc0..7aafaac 100644 --- a/priv/repo/seeds_bootstrap.exs +++ b/priv/repo/seeds_bootstrap.exs @@ -1,6 +1,15 @@ # Bootstrap seeds: run in all environments (dev, test, prod). # Creates only data required for system startup: fee types, custom fields, # roles, admin user, system user, global settings. No members, no groups. +# +# Safe to run from release (no Mix): env is taken from MIX_ENV when Mix.env/0 is not available. + +mix_env = + try do + Mix.env() + rescue + UndefinedFunctionError -> (System.get_env("MIX_ENV") || "prod") |> String.to_atom() + end alias Mv.Accounts alias Mv.Membership @@ -121,7 +130,7 @@ end admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" System.put_env("ADMIN_EMAIL", admin_email) -if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and +if mix_env in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do System.put_env("ADMIN_PASSWORD", "testpassword") end diff --git a/rel/overlays/bin/docker-entrypoint.sh b/rel/overlays/bin/docker-entrypoint.sh index caa389a..fbe345d 100755 --- a/rel/overlays/bin/docker-entrypoint.sh +++ b/rel/overlays/bin/docker-entrypoint.sh @@ -4,6 +4,9 @@ set -e echo "==> Running database migrations..." /app/bin/migrate +echo "==> Running seeds (bootstrap; dev if RUN_DEV_SEEDS=true)..." +/app/bin/mv eval "Mv.Release.run_seeds()" + echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..." /app/bin/mv eval "Mv.Release.seed_admin()" From a41d8498ac9d5ef9dfcdfd245a7df9534efed1dd Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 15:36:19 +0100 Subject: [PATCH 182/237] refactor: apply review changes to joinrequest --- docs/development-progress-log.md | 4 +- lib/membership/join_request.ex | 14 +++++++ .../join_request/changes/confirm_request.ex | 18 ++++++--- .../changes/set_confirmation_token.ex | 10 ++--- lib/membership/membership.ex | 37 ++++++++++++------- .../20260309141437_add_join_requests.exs | 1 + test/membership/join_request_test.exs | 16 +++++++- 7 files changed, 72 insertions(+), 28 deletions(-) diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 84687d1..5d777ff 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -797,8 +797,8 @@ end **Subtask 1 – JoinRequest resource and public policies (done):** - Resource: `Mv.Membership.JoinRequest` with attributes (status, email, first_name, last_name, form_data, schema_version, confirmation_token_hash, confirmation_token_expires_at, submitted_at, etc.), actions `submit` (create), `get_by_confirmation_token_hash` (read), `confirm` (update). Migration: `20260309141437_add_join_requests.exs`. - Policies: Public actions allowed with `actor: nil` via `Mv.Authorization.Checks.ActorIsNil` (submit, get_by_confirmation_token_hash, confirm); default read remains Forbidden for unauthenticated. -- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing and lookup in domain). -- Test file: `test/membership/join_request_test.exs` – all tests pass; policy test (read with actor nil → Forbidden) unskipped. Expired-token test still skipped (fixture for expired token to be added in Subtask 2 or later). +- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing via `JoinRequest.hash_confirmation_token/1`, lookup, expiry check, idempotency for :submitted/:approved/:rejected). +- Test file: `test/membership/join_request_test.exs` – all tests pass; policy test and expired-token test implemented. ### Test Data Management diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index d42aa1e..2519089 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -80,6 +80,7 @@ defmodule Mv.Membership.JoinRequest do validate present(:email), on: [:create] end + # Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql attributes do uuid_primary_key :id @@ -130,4 +131,17 @@ defmodule Mv.Membership.JoinRequest do create_timestamp :inserted_at update_timestamp :updated_at end + + # Public helpers (used by SetConfirmationToken change and domain confirm_join_request) + + @doc """ + Returns the SHA256 hash of the confirmation token (lowercase hex). + + Used when creating a join request (submit) and when confirming by token. + Only one implementation ensures algorithm changes stay in sync. + """ + @spec hash_confirmation_token(String.t()) :: String.t() + def hash_confirmation_token(token) when is_binary(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end end diff --git a/lib/membership/join_request/changes/confirm_request.ex b/lib/membership/join_request/changes/confirm_request.ex index 477561f..a9ff047 100644 --- a/lib/membership/join_request/changes/confirm_request.ex +++ b/lib/membership/join_request/changes/confirm_request.ex @@ -3,15 +3,23 @@ defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do Sets the join request to submitted (confirmation link clicked). Used by the confirm action after the user clicks the confirmation link. - Token hash is kept so that a second click (idempotent) can still find the record - and return success without changing it. + Only applies when the current status is `:pending_confirmation`, so that + direct calls to the confirm action are idempotent and never overwrite + :submitted, :approved, or :rejected. Token hash is kept so a second click + can still find the record and return success without changing it. """ use Ash.Resource.Change @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() def change(changeset, _opts, _context) do - changeset - |> Ash.Changeset.force_change_attribute(:status, :submitted) - |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + current_status = Ash.Changeset.get_data(changeset, :status) + + if current_status == :pending_confirmation do + changeset + |> Ash.Changeset.force_change_attribute(:status, :submitted) + |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + else + changeset + end end end diff --git a/lib/membership/join_request/changes/set_confirmation_token.ex b/lib/membership/join_request/changes/set_confirmation_token.ex index b052799..cce7b3a 100644 --- a/lib/membership/join_request/changes/set_confirmation_token.ex +++ b/lib/membership/join_request/changes/set_confirmation_token.ex @@ -2,11 +2,15 @@ defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do @moduledoc """ Hashes the confirmation token and sets expiry for the join request (submit flow). + Uses `JoinRequest.hash_confirmation_token/1` so hashing logic lives in one place. + Reads the :confirmation_token argument, stores only its SHA256 hash and sets confirmation_token_expires_at (e.g. 24h). Raw token is never persisted. """ use Ash.Resource.Change + alias Mv.Membership.JoinRequest + @confirmation_validity_hours 24 @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() @@ -14,7 +18,7 @@ defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do token = Ash.Changeset.get_argument(changeset, :confirmation_token) if is_binary(token) and token != "" do - hash = token_hash(token) + hash = JoinRequest.hash_confirmation_token(token) expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour) changeset @@ -25,8 +29,4 @@ defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do changeset end end - - defp token_hash(token) do - :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) - end end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 66e2f9b..d967b38 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -353,19 +353,20 @@ defmodule Mv.Membership do @doc """ Confirms a join request by token (public confirmation link). - Hashes the token, finds the JoinRequest by confirmation_token_hash, then updates - to status :submitted and invalidates the token. Idempotent: if already submitted, - returns the existing record without changing it. + Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that + the token has not expired, then updates to status :submitted. Idempotent: if + already submitted, approved, or rejected, returns the existing record without changing it. ## Options - `:actor` - Must be nil for public confirm (policy allows only unauthenticated). ## Returns - - `{:ok, request}` - Updated or already-submitted JoinRequest + - `{:ok, request}` - Updated or already-processed JoinRequest + - `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past - `{:error, error}` - Token unknown/invalid or authorization error """ def confirm_join_request(token, opts \\ []) when is_binary(token) do - hash = confirmation_token_hash(token) + hash = JoinRequest.hash_confirmation_token(token) actor = Keyword.get(opts, :actor) query = @@ -378,20 +379,28 @@ defmodule Mv.Membership do {:error, NotFoundError.exception(resource: JoinRequest)} {:ok, request} -> - if request.status == :submitted do - {:ok, request} - else - request - |> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__) - |> Ash.update(domain: __MODULE__, actor: actor) - end + do_confirm_request(request, actor) {:error, error} -> {:error, error} end end - defp confirmation_token_hash(token) do - :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + defp do_confirm_request(request, _actor) + when request.status in [:submitted, :approved, :rejected] do + {:ok, request} end + + defp do_confirm_request(request, actor) do + if expired?(request.confirmation_token_expires_at) do + {:error, :token_expired} + else + request + |> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__) + |> Ash.update(domain: __MODULE__, actor: actor) + end + end + + defp expired?(nil), do: true + defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt end diff --git a/priv/repo/migrations/20260309141437_add_join_requests.exs b/priv/repo/migrations/20260309141437_add_join_requests.exs index e4921ca..966e4ea 100644 --- a/priv/repo/migrations/20260309141437_add_join_requests.exs +++ b/priv/repo/migrations/20260309141437_add_join_requests.exs @@ -9,6 +9,7 @@ defmodule Mv.Repo.Migrations.AddJoinRequests do use Ecto.Migration def up do + # uuid_generate_v7() is provided by initialize_extensions migration (custom function) create table(:join_requests, primary_key: false) do add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 2123730..f40c9ec 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -98,8 +98,20 @@ defmodule Mv.Membership.JoinRequestTest do assert {:error, _} = Membership.confirm_join_request("nonexistent-token", actor: nil) end - @tag :skip - test "returns error when token is expired (requires fixture for expired token)" + test "returns error when token is expired" do + token = "expired-token-#{System.unique_integer([:positive])}" + attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) + + {:ok, request} = Membership.submit_join_request(attrs, actor: nil) + past = DateTime.add(DateTime.utc_now(), -1, :hour) + id_binary = Ecto.UUID.dump!(request.id) + + from(j in "join_requests", where: fragment("id = ?", ^id_binary)) + |> Repo.update_all(set: [confirmation_token_expires_at: past]) + + assert {:error, :token_expired} = + Membership.confirm_join_request(token, actor: nil) + end end describe "policies (actor: nil)" do From f60155052686831b6bd288f7da8f209a0afb6de7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 16:44:04 +0100 Subject: [PATCH 183/237] fix translations --- CODE_GUIDELINES.md | 2 + .../live/member_field_live/index_component.ex | 2 +- .../show/membership_fees_component.ex | 2 +- priv/gettext/de/LC_MESSAGES/auth.po | 20 +-- priv/gettext/de/LC_MESSAGES/default.po | 168 +++++++++--------- priv/gettext/de/LC_MESSAGES/errors.po | 2 +- priv/gettext/default.pot | 20 +-- priv/gettext/en/LC_MESSAGES/auth.po | 2 +- priv/gettext/en/LC_MESSAGES/default.po | 20 +-- 9 files changed, 120 insertions(+), 118 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index c3de14b..647bd0c 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1285,6 +1285,8 @@ end **German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”. +**Terminology (DE):** Use consistent terms in translations: “Benutzer*in” / “Benutzer*innen” (not “Nutzer*in”), “E-Mail” (with hyphen, capital M), “CSV-Datei” / “CSV-Import” (compound with hyphen). Keep placeholders (e.g. `%{count}`, `%{reason}`) in msgstr identical to msgid where applicable. + **Define Translations:** ```elixir diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 2285d90..b2cff24 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -27,7 +27,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do

{gettext( - "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." + "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." )}

diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 185de88..79ce317 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -1294,7 +1294,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp translate_receipt_status("open"), do: gettext("Open") defp translate_receipt_status("cancelled"), do: gettext("Cancelled") defp translate_receipt_status("draft"), do: gettext("Draft") - defp translate_receipt_status("incompleted"), do: gettext("Incompleted") + defp translate_receipt_status("incompleted"), do: gettext("Incomplete") defp translate_receipt_status("completed"), do: gettext("Completed") defp translate_receipt_status("empty"), do: nil defp translate_receipt_status(other), do: other diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 049cf0f..2aa5e6a 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -18,19 +18,19 @@ msgid "Already have an account?" msgstr "Bereit zum Anmelden?" msgid "Email or password was incorrect" -msgstr "Email oder Passwort nicht korrekt" +msgstr "E-Mail oder Passwort nicht korrekt" msgid "Email" -msgstr "Email" +msgstr "E-Mail" msgid "Forgot your password?" msgstr "Passwort vergessen?" msgid "If this user exists in our database you will contacted with a sign-in link shortly." -msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet." +msgstr "Falls diese*r Benutzer*in bekannt ist, wird dir in Kürze eine E-Mail mit Anmelde-Link zugesendet." msgid "If this user exists in our system, you will be contacted with reset instructions shortly." -msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet." +msgstr "Falls diese*r Benutzer*in bekannt ist, wird dir in Kürze eine E-Mail mit Anleitung zum Zurücksetzen zugesendet." msgid "Need an account?" msgstr "Konto anlegen?" @@ -41,7 +41,7 @@ msgid "Password" msgstr "Passwort" msgid "Password Confirmation" -msgstr "Passwort Wiederholung" +msgstr "Passwort bestätigen" msgid "Request magic link" msgstr "Magischen Link anfordern" @@ -50,7 +50,7 @@ msgid "Request password reset token" msgstr "Passwort zurücksetzen" msgid "Requesting ..." -msgstr "Anfrage låuft..." +msgstr "Anfrage wird gesendet..." msgid "Reset password with token" msgstr "Neues Passwort setzen" @@ -61,13 +61,13 @@ msgid "Sign in" msgstr "Anmelden" msgid "Sign in with Oidc" -msgstr "Single Sign On" +msgstr "Single Sign-On" msgid "Signing in ..." msgstr "Anmelden..." msgid "Your password has successfully been reset" -msgstr "Das Passwort wurde erfolgreich zurückgesetzt" +msgstr "Dein Passwort wurde erfolgreich zurückgesetzt." #: lib/mv_web/live/auth/link_oidc_account_live.ex #, elixir-autogen, elixir-format @@ -127,12 +127,12 @@ msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuche es erneut oder ko #: lib/mv_web/live/auth/link_oidc_account_live.ex #, elixir-autogen, elixir-format msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." -msgstr "Die E-Mail-Adresse aus deinem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider oder kontaktiere den Support." +msgstr "Die E-Mail-Adresse aus deinem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identitätsanbieter oder kontaktiere den Support." #: lib/mv_web/live/auth/link_oidc_account_live.ex #, elixir-autogen, elixir-format msgid "This OIDC account is already linked to another user. Please contact support." -msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support." +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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index ec26b39..0625451 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -538,7 +538,7 @@ msgstr "Benutzer*innen" #: lib/mv_web/live/components/sort_header_component.ex #, elixir-autogen, elixir-format msgid "Click to sort" -msgstr "Klicke um zu sortieren" +msgstr "Klicke, um zu sortieren" #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format @@ -548,7 +548,7 @@ msgstr "Vorname" #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." -msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Passwort, um dein OIDC-Konto zu verknüpfen." +msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format @@ -563,7 +563,7 @@ msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuch #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Authentication configuration error. Please contact the administrator." -msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator." +msgstr "Authentifizierungskonfigurationsfehler. Bitte wende dich an die Administration." #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format @@ -578,7 +578,7 @@ msgstr "Authentifizierung fehlgeschlagen. Bitte versuche es erneut." #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." -msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider." +msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identitätsanbieter." #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format @@ -594,7 +594,7 @@ msgstr "Benutzerdefinierte Felder" #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" -msgstr "Obigen Text zur Bestätigung eingeben" +msgstr "Den obigen Text zur Bestätigung eingeben" #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -607,7 +607,7 @@ msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Show in overview" -msgstr "In Übersicht anzeigen" +msgstr "In der Übersicht anzeigen" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -622,7 +622,7 @@ msgstr "Vereinsdaten" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Manage global settings for the association." -msgstr "Passe übergreifende Einstellungen für den Verein an." +msgstr "Verwalte die globalen Einstellungen des Vereins." #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -653,7 +653,7 @@ msgstr "Fehler beim Verlinken des Mitglieds: %{error}" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." +msgstr "Die Verknüpfung des Mitglieds wird beim Speichern aufgehoben. Ein neues Mitglied kann erst nach dem Speichern ausgewählt werden." #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format @@ -668,7 +668,7 @@ msgstr "Nach einem Mitglied zum Verknüpfen suchen..." #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Search for member to link" -msgstr "Nach Mitglied zum Verknüpfen suchen" +msgstr "Nach einem Mitglied zum Verknüpfen suchen" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format @@ -678,12 +678,12 @@ msgstr "Ausgewählt" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Unlink Member" -msgstr "Mitglied entverknüpfen" +msgstr "Verknüpfung aufheben" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Unlinking scheduled" -msgstr "Entverknüpfung geplant" +msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format @@ -720,7 +720,7 @@ msgstr "Im E-Mail-Programm öffnen" #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" -msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität" +msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -855,7 +855,7 @@ msgstr "Mitglied zahlt für das Beitrittsjahr" #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the joining month" -msgstr "Mitglied zahlt ab Beitrittsmonat" +msgstr "Mitglied zahlt ab dem Beitrittsmonat" #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format @@ -922,7 +922,7 @@ msgstr "Unbezahlt" #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "Yearly" -msgstr "jährlich" +msgstr "Jährlich" #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format @@ -952,7 +952,7 @@ msgstr "Keine auswählen" #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." -msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen." +msgstr "Der eingegebene Text stimmt nicht überein. Vorgang abgebrochen." #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -997,11 +997,6 @@ msgstr "Ja/Nein-Auswahl" msgid "Optional" msgstr "Optional" -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." -msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." - #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" @@ -1010,7 +1005,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}" #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "A cycle for this period already exists" -msgstr "Ein Zyklus für diesen Zeitraum existiert bereits" +msgstr "Ein Zyklus für diesen Zeitraum existiert bereits." #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1069,7 +1064,7 @@ msgstr "Änderung bestätigen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Confirmation text does not match" -msgstr "Bestätigungstext stimmt nicht überein" +msgstr "Bestätigungstext stimmt nicht überein." #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy @@ -1333,12 +1328,12 @@ msgstr "Keine Zyklen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "No cycles to delete" -msgstr "Keine Zyklen" +msgstr "Keine Zyklen zum Löschen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." -msgstr "Keine Mitgliedsbeitragszylen gefunden. Zyklen werden automatisch generiert, wenn eine Mitgliedsbeitragsart zugewiesen wird." +msgstr "Keine Mitgliedsbeitragszyklen gefunden. Zyklen werden automatisch generiert, wenn eine Mitgliedsbeitragsart zugewiesen wird." #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -1463,7 +1458,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are about to delete all %{count} cycles for this member." -msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen." +msgstr "Du bist dabei, alle %{count} Zyklen für dieses Mitglied zu löschen." #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -1491,7 +1486,7 @@ msgstr "Zurück zu den Einstellungen" #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field %{action} successfully" -msgstr "Datenfeld erfolgreich %{action}" +msgstr "Datenfeld wurde erfolgreich %{action}" #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -1527,7 +1522,7 @@ msgstr "Datenfeld speichern" #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Back to roles list" -msgstr "Zurück zur Rollen-Liste" +msgstr "Zurück zur Rollenliste" #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format @@ -1663,22 +1658,22 @@ msgstr "Benutzer*innen-Menü" #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "admin - Unrestricted access" -msgstr "admin - Uneingeschränkter Zugriff" +msgstr "admin – Uneingeschränkter Zugriff" #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "normal_user - Create/Read/Update access" -msgstr "normal_user - Erstellen/Lesen/Aktualisieren Zugriff" +msgstr "normal_user – Zugriff auf Erstellen, Lesen und Aktualisieren" #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "own_data - Access only to own data" -msgstr "own_data - Zugriff nur auf eigene Daten" +msgstr "own_data – Zugriff nur auf eigene Daten" #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "read_only - Read access to all data" -msgstr "read_only - Lesezugriff auf alle Daten" +msgstr "read_only – Lesezugriff auf alle Daten" #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format @@ -1732,19 +1727,19 @@ msgstr "Benutzer*in nicht gefunden" #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" -msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" +msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen." #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" -msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen" +msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen." #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" -msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen" +msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen." #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format, fuzzy @@ -1778,7 +1773,7 @@ msgstr "Mitglied nicht gefunden" #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" -msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen" +msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen." #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format @@ -1868,12 +1863,12 @@ msgstr " (Datenfeld: %{field})" #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "CSV File" -msgstr "CSV Datei" +msgstr "CSV-Datei" #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Download CSV templates:" -msgstr "CSV Vorlagen herunterladen:" +msgstr "CSV-Vorlagen herunterladen:" #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format @@ -1883,7 +1878,7 @@ msgstr "Englische Vorlage" #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Error list truncated to %{count} entries" -msgstr "Liste der Fehler auf %{count} Einträge reduziert" +msgstr "Liste der Fehler auf %{count} Einträge reduziert." #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format @@ -1893,12 +1888,12 @@ msgstr "Fehler" #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Failed to prepare CSV import: %{reason}" -msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}" +msgstr "Die Vorbereitung des CSV-Imports ist fehlgeschlagen: %{reason}" #: lib/mv/membership/import/import_runner.ex #, elixir-autogen, elixir-format msgid "Failed to process chunk %{idx}: %{reason}" -msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}" +msgstr "Verarbeitung von Chunk %{idx} fehlgeschlagen: %{reason}" #: lib/mv/membership/import/import_runner.ex #, elixir-autogen, elixir-format, fuzzy @@ -1993,7 +1988,7 @@ msgstr "Warnungen" #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format, fuzzy msgid "Validation failed" -msgstr "Validierung fehlgeschlagen: %{message}" +msgstr "Validierung fehlgeschlagen" #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format, fuzzy @@ -2008,7 +2003,7 @@ msgstr "E-Mail %{email} wurde bereits verwendet" #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete this group? This action cannot be undone." -msgstr "Möchtest du diese Gruppe wirklich löschen?" +msgstr "Möchtest du diese Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -2040,7 +2035,7 @@ msgstr "Gruppe bearbeiten" #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Enter the group name to confirm" -msgstr "Gebe zur Bestätigung den Gruppennamen ein" +msgstr "Gib zur Bestätigung den Gruppennamen ein" #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -2051,17 +2046,17 @@ msgstr "Gruppe konnte nicht gelöscht werden: %{error}" #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Failed to load group." -msgstr "Gruppe konnte nicht geladen werden" +msgstr "Gruppe konnte nicht geladen werden." #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Group deleted successfully." -msgstr "Gruppe erfolgreich gelöscht" +msgstr "Gruppe erfolgreich gelöscht." #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Group name does not match." -msgstr "Gruppenname stimmt nicht überein" +msgstr "Gruppenname stimmt nicht überein." #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex @@ -2117,12 +2112,12 @@ msgstr[1] "Insgesamt: %{count} Mitglieder" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "This user cannot be edited." -msgstr "Dieser Benutzer kann nicht bearbeitet werden." +msgstr "Diese*r Benutzer*in kann nicht bearbeitet werden." #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "This user cannot be viewed." -msgstr "Dieser Benutzer kann nicht angezeigt werden." +msgstr "Diese*r Benutzer*in kann nicht angezeigt werden." #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format @@ -2197,7 +2192,7 @@ msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}" #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "CSV files only, maximum %{size} MB" -msgstr "Nur CSV Dateien, maximal %{size} MB" +msgstr "Nur CSV-Dateien, maximal %{size} MB" #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format @@ -2242,7 +2237,7 @@ msgstr "Mitgliederdaten verwalten" #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" -msgstr "Mitglieder importieren (CSV)" +msgstr "Mitglieder als CSV exportieren" #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format @@ -2252,7 +2247,7 @@ msgstr "alle" #: lib/mv/membership/member/validations/email_change_permission.ex #, elixir-autogen, elixir-format msgid "Only administrators or the linked user can change the email for members linked to users" -msgstr "Nur Administrator*innen oder die verknüpften Nutzer*innen können die Email Adresse für Mitglieder verknüpfter Nutzer*innen ändern." +msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können die E-Mail-Adresse für Mitglieder verknüpfter Benutzer*innen ändern." #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format, fuzzy @@ -2262,7 +2257,7 @@ msgstr "Rolle auswählen..." #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." -msgstr "Du hast keine Berechtigungen diese Aktion auszuführen." +msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format, fuzzy @@ -2285,17 +2280,17 @@ msgstr "OIDC" #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Not linked" -msgstr "Nichtverknüpft" +msgstr "Nicht verknüpft" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "SSO / OIDC user" -msgstr "SSO / OIDC Nutzer*in" +msgstr "SSO / OIDC Benutzer*in" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "Diese*r Nutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in Eurem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT-Abteilung Ihrer Organisation." +msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format @@ -2362,7 +2357,7 @@ msgstr "Jahr" #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%" -msgstr "Beiträge Kreis: bezahlt %{paid}%, unbezahlt %{unpaid}%, pausiert %{suspended}%" +msgstr "Beiträge (Kreisdiagramm): bezahlt %{paid}%, unbezahlt %{unpaid}%, pausiert %{suspended}%" #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -2427,7 +2422,7 @@ msgstr "Export" #: lib/mv_web/controllers/member_pdf_export_controller.ex #, elixir-autogen, elixir-format msgid "Export contains %{count} rows, maximum is %{max}" -msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}" +msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}." #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy @@ -2437,7 +2432,7 @@ msgstr "Mitglieder als PDF exportieren" #: lib/mv_web/controllers/member_pdf_export_controller.ex #, elixir-autogen, elixir-format msgid "Failed to generate PDF export" -msgstr "Erstellen des PDF Exports ist gescheitert" +msgstr "Erstellen des PDF-Exports ist fehlgeschlagen" #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format @@ -2646,7 +2641,7 @@ msgstr "(gesetzt)" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Leave blank to keep current" -msgstr "Leer lassen, um den aktuellen Wert beizubehalten" +msgstr "Leer lassen, um den aktuellen Wert beizubehalten." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -2775,11 +2770,6 @@ msgstr "Feb." msgid "Income" msgstr "Einnahme" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Incompleted" -msgstr "Unvollständig" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Jan." @@ -2829,17 +2819,17 @@ msgstr "Beitragsart" #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Import members from CSV files." -msgstr "Miglieder aus CSV Dateien importieren." +msgstr "Mitglieder aus CSV-Dateien importieren." #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und der Beitragsstatus kann nicht importiert werden." +msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden." #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Choose CSV file" -msgstr "CSV Datei auswählen" +msgstr "CSV-Datei auswählen" #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -2950,7 +2940,7 @@ msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Clear filters" -msgstr "Filter zurücksetzen“" +msgstr "Filter zurücksetzen" #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format @@ -2961,7 +2951,7 @@ msgstr "Filter auswählen" #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete %{name}? This action cannot be undone." -msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." +msgstr "Möchtest du %{name} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex @@ -2991,7 +2981,7 @@ msgstr "Mitglied %{name} löschen" #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." -msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht." +msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z. B. Mitgliedsbeitragszyklen) werden gelöscht." #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -3031,18 +3021,18 @@ msgstr "Klicke für Benutzer*innen-Details" #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Members table" -msgstr "Mitglieder" +msgstr "Mitgliedertabelle" #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." -msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." +msgstr "Möchtest du die Rolle %{name} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." -msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." +msgstr "Möchtest du diese*n Benutzer*in %{email} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -3074,13 +3064,13 @@ msgstr "Rolle löschen" #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete role %{name}" -msgstr "Mitglied %{name} löschen" +msgstr "Rolle %{name} löschen" #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete user" -msgstr "Löschen" +msgstr "Benutzer*in löschen" #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex @@ -3092,24 +3082,24 @@ msgstr "Benutzer*in %{email} löschen" #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." -msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglieds-Gruppen Zugehörigkeiten werden gelöscht." +msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglied-Gruppen-Zuordnungen werden dauerhaft entfernt." #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." -msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*inen die dieser Rolle zugewiesen wurden, müssen zuerst einer anderen Rolle zugewiesen werden." +msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*innen, die dieser Rolle zugewiesen sind, müssen zuerst einer anderen Rolle zugewiesen werden." #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." -msgstr "Das Löschen kann nicht rückgängig gemacht werden. Der Account und Verlinkungen zu Mitgliedern werden entfernt." +msgstr "Das Löschen diese*r Benutzer*in kann nicht rückgängig gemacht werden. Das Konto und die Verknüpfung zum Mitglied werden betroffen sein." #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "System user cannot be deleted." -msgstr "System-Rollen können nicht gelöscht werden." +msgstr "Der*die System-Benutzer*in kann nicht gelöscht werden." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -3119,7 +3109,7 @@ msgstr "Speichern" #: lib/mv_web/live/components/field_visibility_dropdown_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Datafield %{id}" -msgstr "Datenfelder" +msgstr "Datenfeld %{id}" #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -3129,7 +3119,7 @@ msgstr "Benutzerdefiniertes Feld und alle Werte löschen" #: lib/mv_web/live/custom_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." -msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle " +msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle Datenfeldwerte für dieses Feld werden dauerhaft gelöscht." #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format @@ -3161,7 +3151,7 @@ msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge." #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Configure which data you want to save for your members. Define individual datafields." -msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an." +msgstr "Lege fest, welche Daten du für die Mitglieder speichern möchtest. Definiere individuelle Datenfelder." #: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy @@ -3201,7 +3191,7 @@ msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerd #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Click to edit membership fee type" -msgstr "Klicken zum Bearbeiten der Mitgliedsbeitragsart" +msgstr "Klicke zum Bearbeiten der Mitgliedsbeitragsart" #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format @@ -3231,7 +3221,7 @@ msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar." #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." -msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." +msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab dem Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format @@ -3249,3 +3239,13 @@ msgstr[1] "%{count} Filter aktiv" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "ohne %{name}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Incomplete" +msgstr "Unvollständig" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." +msgstr "Diese Datenfelder sind für MILA notwendig, um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index b1bdeea..8236e18 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -126,7 +126,7 @@ msgid "User already has a member. Remove existing member first." msgstr "Benutzer*in hat bereits ein Mitglied. Entferne zuerst das vorhandene Mitglied." msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" -msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten" +msgstr "OIDC user_info darf kein leeres 'sub'- oder 'id'-Feld enthalten" ## Custom validation messages from Mv.Membership.Member msgid "User is already linked to another member" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d5efdd8..cc22b08 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -998,11 +998,6 @@ msgstr "" msgid "Optional" msgstr "" -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." -msgstr "" - #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" @@ -2775,11 +2770,6 @@ msgstr "" msgid "Income" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Incompleted" -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Jan." @@ -3249,3 +3239,13 @@ msgstr[1] "" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incomplete" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index f01e226..764ea1d 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -24,7 +24,7 @@ msgid "Forgot your password?" msgstr "" msgid "If this user exists in our database you will contacted with a sign-in link shortly." -msgstr "" +msgstr "If this user exists in our database you will be contacted with a sign-in link shortly." msgid "If this user exists in our system, you will be contacted with reset instructions shortly." msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9a76cc8..e710660 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -998,11 +998,6 @@ msgstr "" msgid "Optional" msgstr "" -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." -msgstr "" - #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Member field %{action} successfully" @@ -2775,11 +2770,6 @@ msgstr "" msgid "Income" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Incompleted" -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Jan." @@ -3249,3 +3239,13 @@ msgstr[1] "%{count} filters active" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "without %{name}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Incomplete" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." +msgstr "" From 3672ef0d032217621f61f401583497a56b013d3f Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 17:02:30 +0100 Subject: [PATCH 184/237] test: add tests for join mail confirmation --- docs/page-permission-route-coverage.md | 2 + .../controllers/join_confirm_controller.ex | 45 +++++++++ lib/mv_web/router.ex | 3 + .../join_request_submit_email_test.exs | 34 +++++++ .../join_confirm_controller_test.exs | 93 +++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 lib/mv_web/controllers/join_confirm_controller.ex create mode 100644 test/membership/join_request_submit_email_test.exs create mode 100644 test/mv_web/controllers/join_confirm_controller_test.exs diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index b8eafbd..f91ee0c 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -38,6 +38,8 @@ This document lists all protected routes, which permission set may access them, - `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale` +The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration). + ## Test Coverage **File:** `test/mv_web/plugs/check_page_permission_test.exs` diff --git a/lib/mv_web/controllers/join_confirm_controller.ex b/lib/mv_web/controllers/join_confirm_controller.ex new file mode 100644 index 0000000..a1247f3 --- /dev/null +++ b/lib/mv_web/controllers/join_confirm_controller.ex @@ -0,0 +1,45 @@ +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. + """ + use MvWeb, :controller + + def confirm(conn, %{"token" => token}) when is_binary(token) do + callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership) + + case callback.confirm_join_request(token, actor: nil) do + {:ok, _request} -> + success_response(conn) + + {:error, :token_expired} -> + expired_response(conn) + + {:error, _} -> + invalid_response(conn) + end + end + + def confirm(conn, _params), do: invalid_response(conn) + + defp success_response(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, gettext("Thank you, we have received your request.")) + 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.")) + end + + defp invalid_response(conn) do + conn + |> put_resp_content_type("text/html") + |> put_status(404) + |> send_resp(404, gettext("Invalid or expired link.")) + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 8a4e6c0..3ab264f 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -126,6 +126,9 @@ defmodule MvWeb.Router do overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], gettext_backend: {MvWeb.Gettext, "auth"} + # Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission + get "/confirm_join/:token", JoinConfirmController, :confirm + # Remove this if you do not use the magic link strategy. # magic_sign_in_route(Mv.Accounts.User, :magic_link, # auth_routes_prefix: "/auth", diff --git a/test/membership/join_request_submit_email_test.exs b/test/membership/join_request_submit_email_test.exs new file mode 100644 index 0000000..87c989a --- /dev/null +++ b/test/membership/join_request_submit_email_test.exs @@ -0,0 +1,34 @@ +defmodule Mv.Membership.JoinRequestSubmitEmailTest do + @moduledoc """ + Unit tests for join request confirmation email on submit (Subtask 2). + + Asserts that submit_join_request triggers sending exactly one confirmation email + (to the request email, with confirm link). Uses Swoosh.Adapters.Test; no integration. + Sender is wired in implementation; test fails until then (TDD). + """ + use Mv.DataCase, async: true + + import Swoosh.TestAssertions + + alias Mv.Membership + + @valid_submit_attrs %{ + email: "join#{System.unique_integer([:positive])}@example.com" + } + + describe "submit_join_request/2 sends confirmation email" do + test "sends exactly one email to the request email with confirm link" do + token = "email-test-token-#{System.unique_integer([:positive])}" + attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) + email = attrs.email + + assert {:ok, _request} = Membership.submit_join_request(attrs, actor: nil) + + assert_email_sent(fn email_sent -> + to_addresses = Enum.map(email_sent.to, &elem(&1, 1)) + to_string(email) in to_addresses and + (email_sent.html_body =~ "/confirm_join/" or email_sent.text_body =~ "/confirm_join/") + end) + end + end +end diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs new file mode 100644 index 0000000..a8e4334 --- /dev/null +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -0,0 +1,93 @@ +defmodule MvWeb.JoinConfirmControllerTest do + @moduledoc """ + Unit tests for JoinConfirmController (Subtask 2). + + Stubs the join-confirm callback via Application config so no DB or domain is used. + Uses unauthenticated conn; route is public (/confirm*). + """ + use MvWeb.ConnCase, async: false + + # Stub modules for configurable callback (unit test: no real Membership calls) + defmodule JoinConfirmValidStub do + def confirm_join_request(_token, _opts), do: {:ok, %{}} + end + + defmodule JoinConfirmExpiredStub do + def confirm_join_request(_token, _opts), do: {:error, :token_expired} + end + + defmodule JoinConfirmInvalidStub do + def confirm_join_request(_token, _opts) do + {:error, Ash.Error.Query.NotFound.exception(resource: Mv.Membership.JoinRequest)} + end + end + + setup %{conn: conn} do + # Restore callback after each test so env does not leak + on_exit(fn -> + Application.delete_env(:mv, :join_confirm_callback) + end) + + # Build unauthenticated conn for public confirm route + unauth_conn = + build_conn() + |> init_test_session(%{}) + |> fetch_flash() + |> Plug.Conn.put_private(:ecto_sandbox, conn.private[:ecto_sandbox]) + + {:ok, conn: unauth_conn} + end + + describe "GET /confirm_join/:token" do + @tag role: :unauthenticated + test "valid token returns 200 and success message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + conn = get(conn, "/confirm_join/any-valid-token") + + assert response(conn, 200) =~ "received your request" + end + + @tag role: :unauthenticated + test "second request with same token still returns 200 (idempotent)", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + first = get(conn, "/confirm_join/same-token") + second = get(conn, "/confirm_join/same-token") + + assert response(first, 200) =~ "received your request" + assert response(second, 200) =~ "received your request" + end + + @tag role: :unauthenticated + test "expired token returns 200 with expired message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub) + + conn = get(conn, "/confirm_join/expired-token") + + assert response(conn, 200) =~ "expired" + assert response(conn, 200) =~ "submit" + end + + @tag role: :unauthenticated + test "unknown or invalid token returns 404 with error message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmInvalidStub) + + conn = get(conn, "/confirm_join/nonexistent-token") + + assert response(conn, 404) =~ "Invalid" + end + + @tag role: :unauthenticated + test "route is public (unauthenticated request returns 200, not redirect to sign-in)", %{ + conn: conn + } do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + conn = get(conn, "/confirm_join/public-test-token") + + assert conn.status == 200 + refute redirected_to(conn) =~ "/sign-in" + end + end +end From 6385fbc831ee61c69597d346bfdabf9ab66f697f Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 18:15:12 +0100 Subject: [PATCH 185/237] feat: add join confirmation and mail templating --- CODE_GUIDELINES.md | 44 +++++------ config/config.exs | 4 + config/runtime.exs | 4 + docs/development-progress-log.md | 8 ++ docs/email-layout-mockup.md | 26 +++++++ lib/membership/membership.ex | 44 ++++++++++- .../tasks/join_requests.cleanup_expired.ex | 56 ++++++++++++++ .../send_new_user_confirmation_email.ex | 31 ++++---- .../user/senders/send_password_reset_email.ex | 33 ++++---- lib/mv/mailer.ex | 16 ++++ lib/mv_web/emails/email_layout_view.ex | 12 +++ lib/mv_web/emails/emails_view.ex | 13 ++++ lib/mv_web/emails/join_confirmation_email.ex | 33 ++++++++ .../emails/join_confirmation.html.heex | 18 +++++ .../templates/emails/layouts/layout.html.heex | 33 ++++++++ .../templates/emails/password_reset.html.heex | 18 +++++ .../emails/user_confirmation.html.heex | 16 ++++ mix.exs | 1 + mix.lock | 1 + priv/gettext/de/LC_MESSAGES/default.po | 75 +++++++++++++++++++ priv/gettext/default.pot | 75 +++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 75 +++++++++++++++++++ .../join_request_submit_email_test.exs | 1 + .../join_confirm_controller_test.exs | 1 - 24 files changed, 585 insertions(+), 53 deletions(-) create mode 100644 docs/email-layout-mockup.md create mode 100644 lib/mix/tasks/join_requests.cleanup_expired.ex create mode 100644 lib/mv_web/emails/email_layout_view.ex create mode 100644 lib/mv_web/emails/emails_view.ex create mode 100644 lib/mv_web/emails/join_confirmation_email.ex create mode 100644 lib/mv_web/templates/emails/join_confirmation.html.heex create mode 100644 lib/mv_web/templates/emails/layouts/layout.html.heex create mode 100644 lib/mv_web/templates/emails/password_reset.html.heex create mode 100644 lib/mv_web/templates/emails/user_confirmation.html.heex diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 18036db..53662e2 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1255,32 +1255,32 @@ mix deps.update phoenix mix hex.outdated ``` -### 3.11 Email: Swoosh +### 3.11 Email: Swoosh and Phoenix.Swoosh -**Mailer Configuration:** +**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; override in runtime.exs from ENV for production. + +**Unified layout (transactional emails):** + +- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). +- Templates live under `lib/mv_web/templates/emails/` (bodies) and `lib/mv_web/templates/emails/layouts/` (layout). Use Gettext in templates for i18n. +- See `MvWeb.Emails.JoinConfirmationEmail`, `Mv.Accounts.User.Senders.SendNewUserConfirmationEmail`, `SendPasswordResetEmail` for the pattern; see `docs/email-layout-mockup.md` for layout structure. + +**Sending with layout:** ```elixir -defmodule Mv.Mailer do - use Swoosh.Mailer, otp_app: :mv -end -``` +use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"} -**Sending Emails:** - -```elixir -defmodule Mv.Accounts.WelcomeEmail do - use Phoenix.Swoosh, template_root: "lib/mv_web/templates" - import Swoosh.Email - - def send(user) do - new() - |> to({user.name, user.email}) - |> from({"Mila", "noreply@mila.example.com"}) - |> subject("Welcome to Mila!") - |> render_body("welcome.html", %{user: user}) - |> Mv.Mailer.deliver() - end -end +new() +|> from(Mailer.mail_from()) +|> to(email_address) +|> subject(gettext("Subject")) +|> put_view(MvWeb.EmailsView) +|> put_layout({MvWeb.EmailLayoutView, "layout.html"}) +|> render_body("template_name.html", %{assigns}) +|> Mailer.deliver!() ``` ### 3.12 Internationalization: Gettext diff --git a/config/config.exs b/config/config.exs index d4de2c2..323f5cd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -89,6 +89,10 @@ config :mv, MvWeb.Endpoint, # at the `config/runtime.exs`. config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local +# 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"} + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/runtime.exs b/config/runtime.exs index 93df5bb..d502cfa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -219,6 +219,10 @@ if config_env() == :prod do # ## Configuring the mailer # + # Transactional emails (join confirmation, user confirmation, password reset) use + # the sender from config :mv, :mail_from. Override in production if needed: + # config :mv, :mail_from, {System.get_env("MAIL_FROM_NAME", "Mila"), System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} + # # In production you need to configure the mailer to use a different adapter. # Also, you may need to configure the Swoosh API client of your choice if you # are not using SMTP. Here is an example of the configuration: diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 5d777ff..4e95ff4 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -800,6 +800,14 @@ end - Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing via `JoinRequest.hash_confirmation_token/1`, lookup, expiry check, idempotency for :submitted/:approved/:rejected). - Test file: `test/membership/join_request_test.exs` – all tests pass; policy test and expired-token test implemented. +**Subtask 2 – Submit and confirm flow (done):** +- **Unified email layout:** phoenix_swoosh with `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). All transactional emails (join confirmation, user confirmation, password reset) use the same layout. Config: `config :mv, :mail_from, {name, email}` (default `{"Mila", "noreply@example.com"}`); override in runtime.exs. +- **Join confirmation:** Domain wrapper `submit_join_request/2` generates token (or uses optional `:confirmation_token` in attrs for tests), creates JoinRequest via action `:submit`, then sends one email via `MvWeb.Emails.JoinConfirmationEmail`. Route `GET /confirm_join/:token` (JoinConfirmController) updates to `submitted`; idempotent; expired/invalid handled. +- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. +- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban. +- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po. +- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. + ### Test Data Management **Seed Data:** diff --git a/docs/email-layout-mockup.md b/docs/email-layout-mockup.md new file mode 100644 index 0000000..0c1d53b --- /dev/null +++ b/docs/email-layout-mockup.md @@ -0,0 +1,26 @@ +# Unified Email Layout – ASCII Mockup + +All transactional emails (join confirmation, user confirmation, password reset) use the same layout. + +``` ++------------------------------------------------------------------+ +| [Logo or app name – e.g. "Mila" or club name] | ++------------------------------------------------------------------+ +| | +| [Subject / heading line – e.g. "Confirm your email address"] | +| | +| [Body content – paragraph and CTA link] | +| e.g. "Please click the link below to confirm your request." | +| "Confirm my request" (button or link) | +| | +| [Optional: short note – e.g. "If you didn't request this, | +| you can ignore this email."] | +| | ++------------------------------------------------------------------+ +| [Footer – one line, e.g. "© 2025 Mila · Mitgliederverwaltung"] | ++------------------------------------------------------------------+ +``` + +- **Header:** Single line (app/club name), subtle. +- **Main:** Heading + body text + primary CTA (link/button). +- **Footer:** Single line, small text (copyright / product name). diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index d967b38..ffca356 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -30,6 +30,7 @@ defmodule Mv.Membership do import Ash.Expr alias Ash.Error.Query.NotFound, as: NotFoundError alias Mv.Membership.JoinRequest + alias MvWeb.Emails.JoinConfirmationEmail admin do show? true @@ -85,7 +86,7 @@ defmodule Mv.Membership do end resource Mv.Membership.JoinRequest do - define :submit_join_request, action: :submit + # submit_join_request/2 implemented as custom function below (create + send email) end end @@ -350,6 +351,47 @@ defmodule Mv.Membership do |> then(&Ash.read_one(query, &1)) end + @doc """ + Creates a join request (submit flow) and sends the confirmation email. + + Generates a confirmation token if not provided in attrs (e.g. for tests, pass + `:confirmation_token` to get a known token). On success, sends one email with + the confirm link to the request email. + + ## Options + - `:actor` - Must be nil for public submit (policy allows only unauthenticated). + + ## Returns + - `{:ok, request}` - Created JoinRequest in status pending_confirmation + - `{: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() + + attrs_with_token = + attrs |> Map.drop([:confirmation_token]) |> Map.put(:confirmation_token, token) + + case Ash.create(JoinRequest, attrs_with_token, + action: :submit, + actor: actor, + domain: __MODULE__ + ) do + {:ok, request} -> + JoinConfirmationEmail.send(request.email, token) + {:ok, request} + + error -> + error + end + end + + defp generate_confirmation_token do + 32 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + @doc """ Confirms a join request by token (public confirmation link). diff --git a/lib/mix/tasks/join_requests.cleanup_expired.ex b/lib/mix/tasks/join_requests.cleanup_expired.ex new file mode 100644 index 0000000..bc9ea2a --- /dev/null +++ b/lib/mix/tasks/join_requests.cleanup_expired.ex @@ -0,0 +1,56 @@ +defmodule Mix.Tasks.JoinRequests.CleanupExpired do + @moduledoc """ + Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired. + + Retention: records with `confirmation_token_expires_at` older than now are deleted. + Intended for cron or Oban (e.g. every hour). See docs/onboarding-join-concept.md. + + ## Usage + + mix join_requests.cleanup_expired + + ## Examples + + $ mix join_requests.cleanup_expired + Deleted 3 expired join request(s). + """ + use Mix.Task + + require Ash.Query + + alias Mv.Membership.JoinRequest + + @shortdoc "Deletes join requests in pending_confirmation with expired confirmation token" + + @impl Mix.Task + def run(_args) do + Mix.Task.run("app.start") + + now = DateTime.utc_now() + + query = + JoinRequest + |> Ash.Query.filter(status == :pending_confirmation) + |> Ash.Query.filter(confirmation_token_expires_at < ^now) + + # Bypass authorization: cleanup is a system maintenance task (cron/Oban). + case Ash.read(query, domain: Mv.Membership, authorize?: false) do + {:ok, requests} -> + count = delete_expired_requests(requests) + Mix.shell().info("Deleted #{count} expired join request(s).") + count + + {:error, error} -> + Mix.raise("Failed to list expired join requests: #{inspect(error)}") + end + end + + defp delete_expired_requests(requests) do + Enum.reduce_while(requests, 0, fn request, acc -> + case Ash.destroy(request, domain: Mv.Membership, authorize?: false) do + :ok -> {:cont, acc + 1} + {:error, _} -> {:halt, acc} + end + end) + end +end 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 2135465..7c5fa0c 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 @@ -1,12 +1,20 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do @moduledoc """ Sends an email for a new user to confirm their email address. + + Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and + central mail from config (Mv.Mailer.mail_from/0). """ use AshAuthentication.Sender - use MvWeb, :verified_routes + 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 @@ -26,21 +34,16 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do """ @impl true def send(user, token, _) do + confirm_url = url(~p"/confirm_new_user/#{token}") + subject = gettext("Confirm your email address") + new() - # Replace with email from env - |> from({"noreply", "noreply@example.com"}) + |> from(Mailer.mail_from()) |> to(to_string(user.email)) - |> subject("Confirm your email address") - |> html_body(body(token: token)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) + |> render_body("user_confirmation.html", %{confirm_url: confirm_url, subject: subject}) |> Mailer.deliver!() end - - defp body(params) do - url = url(~p"/confirm_new_user/#{params[:token]}") - - """ -

Click this link to confirm your email:

-

#{url}

- """ - 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 bcf4e75..a4bb489 100644 --- a/lib/mv/accounts/user/senders/send_password_reset_email.ex +++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex @@ -1,12 +1,20 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do @moduledoc """ - Sends a password reset email + Sends a password reset email. + + Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and + central mail from config (Mv.Mailer.mail_from/0). """ use AshAuthentication.Sender - use MvWeb, :verified_routes + 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 @@ -26,21 +34,16 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do """ @impl true def send(user, token, _) do + reset_url = url(~p"/password-reset/#{token}") + subject = gettext("Reset your password") + new() - # Replace with email from env - |> from({"noreply", "noreply@example.com"}) + |> from(Mailer.mail_from()) |> to(to_string(user.email)) - |> subject("Reset your password") - |> html_body(body(token: token)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) + |> render_body("password_reset.html", %{reset_url: reset_url, subject: subject}) |> Mailer.deliver!() end - - defp body(params) do - url = url(~p"/password-reset/#{params[:token]}") - - """ -

Click this link to reset your password:

-

#{url}

- """ - end end diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index 43d5c39..3d83636 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -1,3 +1,19 @@ defmodule Mv.Mailer do + @moduledoc """ + Swoosh mailer for transactional emails. + + Use `mail_from/0` for the configured sender address (join confirmation, + user confirmation, password reset). + """ use Swoosh.Mailer, otp_app: :mv + + @doc """ + Returns the configured "from" address for transactional emails. + + Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`. + Default: `{"Mila", "noreply@example.com"}`. + """ + def mail_from do + Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"}) + end end diff --git a/lib/mv_web/emails/email_layout_view.ex b/lib/mv_web/emails/email_layout_view.ex new file mode 100644 index 0000000..a0cf03f --- /dev/null +++ b/lib/mv_web/emails/email_layout_view.ex @@ -0,0 +1,12 @@ +defmodule MvWeb.EmailLayoutView do + @moduledoc """ + Layout view for transactional emails (join confirmation, user confirmation, password reset). + + Renders a single layout template that wraps all email body content. + See docs/email-layout-mockup.md for the layout structure. + """ + use Phoenix.View, + root: "lib/mv_web", + path: "templates/emails/layouts", + namespace: MvWeb +end diff --git a/lib/mv_web/emails/emails_view.ex b/lib/mv_web/emails/emails_view.ex new file mode 100644 index 0000000..87bb29d --- /dev/null +++ b/lib/mv_web/emails/emails_view.ex @@ -0,0 +1,13 @@ +defmodule MvWeb.EmailsView do + @moduledoc """ + View for transactional email body templates. + + Templates are rendered inside EmailLayoutView layout when sent via Phoenix.Swoosh. + """ + use Phoenix.View, + root: "lib/mv_web", + path: "templates/emails", + namespace: MvWeb + + use Gettext, backend: MvWeb.Gettext, otp_app: :mv +end diff --git a/lib/mv_web/emails/join_confirmation_email.ex b/lib/mv_web/emails/join_confirmation_email.ex new file mode 100644 index 0000000..f72ec45 --- /dev/null +++ b/lib/mv_web/emails/join_confirmation_email.ex @@ -0,0 +1,33 @@ +defmodule MvWeb.Emails.JoinConfirmationEmail do + @moduledoc """ + Sends the join request confirmation email (double opt-in) using the unified email layout. + """ + use Phoenix.Swoosh, + view: MvWeb.EmailsView, + layout: {MvWeb.EmailLayoutView, :layout} + + use MvWeb, :verified_routes + import Swoosh.Email + use Gettext, backend: MvWeb.Gettext, otp_app: :mv + + alias Mv.Mailer + + @doc """ + Sends the join confirmation email to the given address with the confirmation link. + + Called from the domain after a JoinRequest is created (submit flow). + """ + def send(email_address, token) when is_binary(email_address) and is_binary(token) do + confirm_url = url(~p"/confirm_join/#{token}") + subject = gettext("Confirm your membership request") + + new() + |> from(Mailer.mail_from()) + |> to(email_address) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) + |> render_body("join_confirmation.html", %{confirm_url: confirm_url, subject: subject}) + |> Mailer.deliver!() + end +end diff --git a/lib/mv_web/templates/emails/join_confirmation.html.heex b/lib/mv_web/templates/emails/join_confirmation.html.heex new file mode 100644 index 0000000..b8344eb --- /dev/null +++ b/lib/mv_web/templates/emails/join_confirmation.html.heex @@ -0,0 +1,18 @@ +
+

+ {gettext( + "We have received your membership request. To complete it, please click the link below." + )} +

+

+ + {gettext("Confirm my request")} + +

+

+ {gettext("If you did not submit this request, you can ignore this email.")} +

+
diff --git a/lib/mv_web/templates/emails/layouts/layout.html.heex b/lib/mv_web/templates/emails/layouts/layout.html.heex new file mode 100644 index 0000000..63bc5c7 --- /dev/null +++ b/lib/mv_web/templates/emails/layouts/layout.html.heex @@ -0,0 +1,33 @@ + + + + + + {assigns[:subject] || "Mila"} + + +
+ <%= if col_idx == 0 && @row_click && @row_tooltip do %> + {@row_tooltip} + <% end %> {render_slot(col, @row_item.(row))} - gettext("All") + gettext("Apply filters") end end @@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do # Get boolean filter label (comma-separated list of active filter names) defp boolean_filter_label(_boolean_custom_fields, boolean_filters) when map_size(boolean_filters) == 0 do - gettext("Apply filters") + gettext("All") end defp boolean_filter_label(boolean_custom_fields, boolean_filters) do diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a944c85..ebc4930 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) end } + row_tooltip={gettext("Click for dataield details")} > <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index 70358e0..76663fd 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -62,6 +62,7 @@ defmodule MvWeb.GroupLive.Index do rows={@groups} row_id={fn group -> "group-#{group.id}" end} row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end} + row_tooltip={gettext("Click for group details")} > <:col :let={group} label={gettext("Name")}> {group.name} diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 1e8cf05..419b585 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -57,6 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) end } + row_tooltip={gettext("Click for datafield details")} > <:col :let={{_field_name, field_data}} label={gettext("Name")}> {MemberFields.label(field_data.field)} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4309611..1be35b4 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -122,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) + |> assign(:selected_member_id, nil) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) @@ -160,6 +161,12 @@ defmodule MvWeb.MemberLive.Index do - `"select_all"` - Toggles selection of all visible members - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ + @impl true + def handle_event("select_row_and_navigate", %{"id" => id}, socket) do + # Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected. + {:noreply, push_navigate(socket, to: ~p"/members/#{id}")} + end + @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = @@ -599,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) @@ -798,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do end end + # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid. + defp parse_highlight_param(nil), do: nil + defp parse_highlight_param(""), do: nil + + defp parse_highlight_param(id) when is_binary(id) do + if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)), + do: id, + else: nil + end + + defp parse_highlight_param(_), do: nil + defp merge_fields_param_from_uri(params, nil), do: params defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index a696b00..eec49de 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -59,6 +59,7 @@ variant="secondary" class={["gap-2", @show_current_cycle && "btn-active"]} phx-click="toggle_cycle_view" + data-testid="toggle-cycle-view" aria-label={ if(@show_current_cycle, do: gettext("Current Cycle Payment Status"), @@ -93,7 +94,9 @@ id="members" rows={@members} row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + row_tooltip={gettext("Click for member details")} + row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} sort_field={@sort_field} sort_order={@sort_order} diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index ae69c30..6757646 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -34,7 +34,7 @@ defmodule MvWeb.MemberLive.Show do {MvWeb.Helpers.MemberHelpers.display_name(@member)} <:actions> <.button - navigate={~p"/members"} + navigate={~p"/members?highlight=#{@member.id}"} variant="neutral" aria-label={gettext("Back to members list")} > diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 5829bca..5947472 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -17,6 +17,7 @@ id="roles" rows={@roles} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} + row_tooltip={gettext("Click for role details")} > <:col :let={role} label={gettext("Name")}>
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 8b5b1b2..dd2c4f2 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -174,8 +174,8 @@ defmodule MvWeb.RoleLive.Show do {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> - <.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")} + <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid=role-edit"> + {gettext("Edit role")} <% end %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 364e5a4..858e784 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -15,6 +15,7 @@ rows={@users} row_id={fn user -> "row-#{user.id}" end} row_click={fn user -> JS.navigate(~p"/users/#{user}") end} + row_tooltip={gettext("Click for user details")} sort_field={@sort_field} sort_order={@sort_order} > diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 49fbe83..4561f24 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -11,13 +11,11 @@ msgstr "" "Language: de\n" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -42,7 +40,6 @@ msgstr "Stadt" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -50,15 +47,8 @@ msgstr "Stadt" msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeiten" @@ -277,7 +267,6 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -790,19 +779,18 @@ msgstr "Alle" msgid "Address" msgstr "Adresse" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "Zurück" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "Demnächst verfügbar" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -820,7 +808,6 @@ msgid "Payment Data" msgstr "Beitragsdaten" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "Zahlungen" @@ -834,6 +821,7 @@ msgstr "Persönliche Daten" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" msgstr "Speichern" @@ -851,11 +839,6 @@ msgstr "Mitglied erstellen" msgid "Amount" msgstr "Betrag" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "Zurück zu den Einstellungen" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1575,6 +1558,7 @@ msgid "Show/Hide Columns" msgstr "Spalten ein-/ausblenden" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Back to settings" msgstr "Zurück zu den Einstellungen" @@ -1630,11 +1614,6 @@ msgstr "System-Rolle kann nicht gelöscht werden" msgid "Custom" msgstr "Benutzerdefiniert" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Edit Role" -msgstr "Rolle bearbeiten" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format @@ -1734,11 +1713,6 @@ msgstr "Rolle nicht gefunden." msgid "Role saved successfully." msgstr "Rolle erfolgreich gespeichert." -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Role" -msgstr "Rolle speichern" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1890,22 +1864,17 @@ msgstr "aktualisiert" msgid "Unknown error" msgstr "Unbekannter Fehler" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "Mitglied nicht gefunden" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen" @@ -1990,11 +1959,6 @@ msgstr "Mitgliedsfilter" msgid "Payment Status" msgstr "Bezahlstatus" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "Zurücksetzen" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,125 @@ msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "Filter zurücksetzen“" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "Filter auswählen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "Gefahrenzone" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete member" +msgstr "Mitglied löschen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "Mitglied %{name} löschen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht." + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit datafield" +msgstr "Datenfeld bearbeiten" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit group" +msgstr "Gruppe bearbeiten" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit member" +msgstr "Mitglied bearbeiten" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit role" +msgstr "Rolle bearbeiten" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit user" +msgstr "Benutzer*in bearbeiten" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "Rolle bearbeiten" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "Klicke für Gruppen-Details" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "Klicke für Mitglieds-Details" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "Klicke für Rollen-Details" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "Klicke für Benutzer*innen-Details" + +#~ #: lib/mv_web/live/member_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Back to Settings" +#~ msgstr "Zurück zu den Einstellungen" + +#~ #: lib/mv_web/live/member_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Coming soon" +#~ msgstr "Demnächst verfügbar" + +#~ #: lib/mv_web/live/components/member_filter_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Reset" +#~ msgstr "Zurücksetzen" + +#~ #: lib/mv_web/live/role_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Save Role" +#~ msgstr "Rolle speichern" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this member" +#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ea8e976..cea7991 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12,13 +12,11 @@ msgid "" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -43,7 +41,6 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -51,15 +48,8 @@ msgstr "" msgid "Delete" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -278,7 +268,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -791,19 +780,18 @@ msgstr "" msgid "Address" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -821,7 +809,6 @@ msgid "Payment Data" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "" @@ -835,6 +822,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" msgstr "" @@ -852,11 +840,6 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1576,6 +1559,7 @@ msgid "Show/Hide Columns" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format msgid "Back to settings" msgstr "" @@ -1631,11 +1615,6 @@ msgstr "" msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Edit Role" -msgstr "" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format @@ -1735,11 +1714,6 @@ msgstr "" msgid "Role saved successfully." msgstr "" -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Role" -msgstr "" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1891,22 +1865,17 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1991,11 +1960,6 @@ msgstr "" msgid "Payment Status" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,100 @@ msgstr "" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Edit datafield" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Edit group" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Edit member" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Edit role" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Edit user" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 915fc52..9f38efe 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12,13 +12,11 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -43,7 +41,6 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -51,15 +48,8 @@ msgstr "" msgid "Delete" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -278,7 +268,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -791,19 +780,18 @@ msgstr "" msgid "Address" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -821,7 +809,6 @@ msgid "Payment Data" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "" @@ -835,6 +822,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save" msgstr "" @@ -852,11 +840,6 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1576,6 +1559,7 @@ msgid "Show/Hide Columns" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Back to settings" msgstr "" @@ -1631,11 +1615,6 @@ msgstr "" msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit Role" -msgstr "" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -1735,11 +1714,6 @@ msgstr "" msgid "Role saved successfully." msgstr "" -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Save Role" -msgstr "" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1891,22 +1865,17 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1991,11 +1960,6 @@ msgstr "" msgid "Payment Status" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,125 @@ msgstr "" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit datafield" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit group" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit member" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit role" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit user" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "" + +#~ #: lib/mv_web/live/member_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Back to Settings" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Coming soon" +#~ msgstr "" + +#~ #: lib/mv_web/live/components/member_filter_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Reset" +#~ msgstr "" + +#~ #: lib/mv_web/live/role_live/form.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Save Role" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this member" +#~ msgstr "" diff --git a/test/mv_web/components/core_components_table_test.exs b/test/mv_web/components/core_components_table_test.exs new file mode 100644 index 0000000..931b42a --- /dev/null +++ b/test/mv_web/components/core_components_table_test.exs @@ -0,0 +1,154 @@ +defmodule MvWeb.Components.CoreComponentsTableTest do + @moduledoc """ + Tests for the CoreComponents table: row hover/focus and selected styling. + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias MvWeb.CoreComponents + + describe "table row_click styling" do + test "when row_click is set, table rows have hover and focus-within ring classes" do + rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || item["name"] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ "hover:ring-2" + assert html =~ "focus-within:ring-2" + assert html =~ "hover:ring-base-content/10" + end + + test "when row_click is nil, table rows do not have hover ring classes" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + refute html =~ "hover:ring-2" + refute html =~ "focus-within:ring-2" + end + end + + describe "table selected_row_id styling" do + test "when selected_row_id matches a row id, that row has data-selected and ring-primary" do + rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + selected_row_id: "two", + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(id="row-two") + assert html =~ ~s(data-selected="true") + assert html =~ "ring-primary" + end + + test "when selected_row_id is nil, no row has data-selected" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + selected_row_id: nil, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + refute html =~ ~s(data-selected="true") + end + + test "when row_selected? is set, multiple rows can have data-selected and ring-primary" do + rows = [%{id: "a", name: "Alice"}, %{id: "b", name: "Bob"}, %{id: "c", name: "Claire"}] + selected_ids = MapSet.new(["a", "c"]) + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + row_selected?: fn item -> MapSet.member?(selected_ids, item.id) end, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + # Two rows selected (a and c), one not (b) + assert html =~ ~s(id="row-a") + assert html =~ ~s(id="row-b") + assert html =~ ~s(id="row-c") + # data-selected appears twice (for row a and row c) + assert String.contains?(html, ~s(data-selected="true")) + assert html =~ "ring-primary" + end + end +end diff --git a/test/mv_web/live/member_live_authorization_test.exs b/test/mv_web/live/member_live_authorization_test.exs index 9a23019..c5db9d6 100644 --- a/test/mv_web/live/member_live_authorization_test.exs +++ b/test/mv_web/live/member_live_authorization_test.exs @@ -24,6 +24,7 @@ defmodule MvWeb.MemberLiveAuthorizationTest do {:ok, view, _html} = live(conn, "/members") + # Index table has no Edit/Delete per row (only sr-only Show link); ensure they are not present refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]") refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]") end @@ -31,17 +32,18 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Kassenwart (normal_user)" do @tag role: :normal_user - test "sees New Member and Edit buttons", %{conn: conn} do + test "sees New Member and Show link in row", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") + # Index table action column has sr-only Show link only (Edit is on member show page) + assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]") end @tag role: :normal_user - test "does not see Delete button", %{conn: conn} do + test "does not see Delete button in table", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") @@ -52,14 +54,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Admin" do @tag role: :admin - test "sees New Member, Edit and Delete buttons", %{conn: conn} do + test "sees New Member and Show link in row", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]") + # Index table action column has sr-only Show link only (Edit/Delete are on member show page) + assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]") end end diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index bbd9159..add2fba 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -107,9 +107,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do {:ok, view, _html} = live(conn, "/members") - # Toggle to current cycle (use the button in the header, not the one in the column) + # Toggle to current cycle (use the button in the header) view - |> element("button[phx-click='toggle_cycle_view'].btn.gap-2") + |> element("[data-testid=toggle-cycle-view]") |> render_click() html = render(view) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index d8846ea..1c8328f 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -304,6 +304,44 @@ defmodule MvWeb.MemberLive.IndexTest do assert_redirect(view, ~p"/members/#{member}") end + describe "table row outline (hover and selected)" do + @describetag :ui + + test "clickable rows have hover and focus-within ring classes", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _member} = + Mv.Membership.create_member( + %{first_name: "Hover", last_name: "Test", email: "hover@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # CoreComponents table adds hover and focus-within ring when row_click is set + assert html =~ "hover:ring-2" + assert html =~ "focus-within:ring-2" + assert html =~ "hover:ring-base-content/10" + end + + test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?highlight=#{member.id}") + + # Outline is only for checkbox selection; highlight param does not set data-selected + refute has_element?(view, "tr#row-#{member.id}[data-selected='true']") + end + end + describe "copy_emails feature" do setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() From e5a6003ace579d3d4154ae044ffe061bcf6832a9 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 14:16:43 +0100 Subject: [PATCH 124/237] feat: sticky memberstable header --- DESIGN_DUIDELINES.md | 5 + lib/mv_web/components/core_components.ex | 23 +- lib/mv_web/live/member_live/index.html.heex | 589 ++++++++++---------- lib/mv_web/live/role_live/show.ex | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 31 +- priv/gettext/default.pot | 21 +- priv/gettext/en/LC_MESSAGES/default.po | 31 +- test/mv_web/member_live/index_test.exs | 29 + 8 files changed, 411 insertions(+), 324 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 98e43db..37428a3 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -272,6 +272,11 @@ Notes: - **MUST:** Truncate long values consistently (same max widths for name/email-like fields). - **MUST:** Tooltip reveals full value when truncated. +### 8.5 Loading/Lists/Tables: keep filters visible on desktop +- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling. +- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space. +- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `
{col[:label]} + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -760,7 +765,7 @@ defmodule MvWeb.CoreComponents do sort_order={@sort_order} /> + {gettext("Actions")}
- <%= if member.email do %> + <.maybe_value value={member.email} empty_sr_text={gettext("No email")}> {member.email} - <% else %> - - <% end %> + diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index c49e343..0ef541e 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -356,11 +356,9 @@ """ } > - <%= if member.membership_fee_type do %> + <.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}> {member.membership_fee_type.name} - <% else %> - - <% end %> + <:col :let={member} @@ -375,7 +373,7 @@ {badge.label} <% else %> - <.badge variant="neutral">{gettext("No cycle")} + <.empty_cell sr_text={gettext("No cycle")} /> <% end %> <:col @@ -394,18 +392,17 @@ """ } > - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> + <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> + <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> + <:action :let={member}>
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 6645051..deb6cf0 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -559,7 +559,11 @@ defmodule MvWeb.MemberLive.Show do <%= if @inner_block != [] do %> {render_slot(@inner_block)} <% else %> - {display_value(@value)} + <%= if value_blank?(@value) do %> + <.empty_cell sr_text={gettext("Not set")} /> + <% else %> + {@value} + <% end %> <% end %> @@ -593,9 +597,9 @@ defmodule MvWeb.MemberLive.Show do # Helper Functions # ----------------------------------------------------------------- - defp display_value(nil), do: render_empty_value() - defp display_value(""), do: render_empty_value() - defp display_value(value), do: value + defp value_blank?(nil), do: true + defp value_blank?(v) when is_binary(v), do: String.trim(v) == "" + defp value_blank?(_), do: false defp format_status_label(:paid), do: gettext("Paid") defp format_status_label(:unpaid), do: gettext("Unpaid") @@ -684,10 +688,10 @@ defmodule MvWeb.MemberLive.Show do if String.trim(value) == "" do render_empty_value() else - assigns = %{email: value} + assigns = %{email: value, display: value} ~H""" - <.mailto_link email={@email} display={@email} /> + <.mailto_link email={@email} display={@display} /> """ end end @@ -702,17 +706,10 @@ defmodule MvWeb.MemberLive.Show do defp format_custom_field_value(value, _type), do: to_string(value) - # Renders accessible placeholder for empty values - # Uses translated text for screen readers while maintaining visual consistency - # The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers + # Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6). + # Returns safe HTML so it can be used from helpers without LiveView assigns. defp render_empty_value do - assigns = %{text: gettext("Not set")} - - ~H""" - - - {@text} - - """ + text = gettext("Not set") + {:safe, ["", Phoenix.HTML.Engine.html_escape(text), ""]} end end diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 1db11e3..7ed5fbe 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <%= for r <- receipts do %>
{format_receipt_cell(col_key, r[col_key])} + <%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %> + {cell_content} + <% else %> + <.empty_cell sr_text={receipt_empty_sr_text(col_key)} /> + <% end %> +
+ + + + + + + + + +
+
Mila
+
+ {@inner_content} +
+ © {DateTime.utc_now().year} Mila · Mitgliederverwaltung +
+ + diff --git a/lib/mv_web/templates/emails/password_reset.html.heex b/lib/mv_web/templates/emails/password_reset.html.heex new file mode 100644 index 0000000..8dece57 --- /dev/null +++ b/lib/mv_web/templates/emails/password_reset.html.heex @@ -0,0 +1,18 @@ +
+

+ {gettext("You requested a password reset. Click the link below to set a new password.")} +

+

+ + {gettext("Reset password")} + +

+

+ {gettext( + "If you did not request this, you can ignore this email. Your password will remain unchanged." + )} +

+
diff --git a/lib/mv_web/templates/emails/user_confirmation.html.heex b/lib/mv_web/templates/emails/user_confirmation.html.heex new file mode 100644 index 0000000..2ba9702 --- /dev/null +++ b/lib/mv_web/templates/emails/user_confirmation.html.heex @@ -0,0 +1,16 @@ +
+

+ {gettext("Please confirm your email address by clicking the link below.")} +

+

+ + {gettext("Confirm my email")} + +

+

+ {gettext("If you did not create an account, you can ignore this email.")} +

+
diff --git a/mix.exs b/mix.exs index 19c598c..0d1d4f1 100644 --- a/mix.exs +++ b/mix.exs @@ -65,6 +65,7 @@ defmodule Mv.MixProject do app: false, compile: false, depth: 1}, + {:phoenix_swoosh, "~> 1.0"}, {:swoosh, "~> 1.16"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 7e96c47..849dfd5 100644 --- a/mix.lock +++ b/mix.lock @@ -65,6 +65,7 @@ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.26", "306af67d6557cc01f880107cc459f1fa0acbaab60bc8c027a368ba16b3544473", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0ec34b24c69aa70c4f25a8901effe3462bee6c8ca80a9a4a7685215e3a0ac34e"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index ec26b39..ac959b7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3249,3 +3249,78 @@ msgstr[1] "%{count} Filter aktiv" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "ohne %{name}" + +#: lib/mv_web/templates/emails/user_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my email" +msgstr "E-Mail bestätigen" + +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my request" +msgstr "Anfrage bestätigen" + +#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your email address" +msgstr "E-Mail-Adresse bestätigen" + +#: lib/mv_web/emails/join_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your membership request" +msgstr "Mitgliedschaftsanfrage bestätigen" + +#: 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." + +#: 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." + +#: 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." + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link." +msgstr "Ungültiger oder abgelaufener Link." + +#: 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." + +#: lib/mv_web/templates/emails/password_reset.html.heex +#, elixir-autogen, elixir-format +msgid "Reset password" +msgstr "Passwort zurücksetzen" + +#: lib/mv/accounts/user/senders/send_password_reset_email.ex +#, elixir-autogen, elixir-format +msgid "Reset your password" +msgstr "Passwort zurücksetzen" + +#: 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." + +#: 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." + +#: 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." + +#: 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." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d5efdd8..5748796 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3249,3 +3249,78 @@ msgstr[1] "" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "" + +#: lib/mv_web/templates/emails/user_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my email" +msgstr "" + +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my request" +msgstr "" + +#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your email address" +msgstr "" + +#: lib/mv_web/emails/join_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your membership request" +msgstr "" + +#: 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 "" + +#: 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 "" + +#: 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 "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link." +msgstr "" + +#: 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 "" + +#: lib/mv_web/templates/emails/password_reset.html.heex +#, elixir-autogen, elixir-format +msgid "Reset password" +msgstr "" + +#: lib/mv/accounts/user/senders/send_password_reset_email.ex +#, elixir-autogen, elixir-format +msgid "Reset your password" +msgstr "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Thank you, we have received your request." +msgstr "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "This link has expired. Please submit the form again." +msgstr "" + +#: 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 "" + +#: 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9a76cc8..f8932f5 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3249,3 +3249,78 @@ msgstr[1] "%{count} filters active" #, elixir-autogen, elixir-format msgid "without %{name}" msgstr "without %{name}" + +#: lib/mv_web/templates/emails/user_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my email" +msgstr "" + +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "Confirm my request" +msgstr "" + +#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your email address" +msgstr "" + +#: lib/mv_web/emails/join_confirmation_email.ex +#, elixir-autogen, elixir-format +msgid "Confirm your membership request" +msgstr "" + +#: 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 "" + +#: 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 "" + +#: 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 "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link." +msgstr "" + +#: 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 "" + +#: lib/mv_web/templates/emails/password_reset.html.heex +#, elixir-autogen, elixir-format +msgid "Reset password" +msgstr "" + +#: lib/mv/accounts/user/senders/send_password_reset_email.ex +#, elixir-autogen, elixir-format +msgid "Reset your password" +msgstr "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Thank you, we have received your request." +msgstr "" + +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "This link has expired. Please submit the form again." +msgstr "" + +#: 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 "" + +#: 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 "" diff --git a/test/membership/join_request_submit_email_test.exs b/test/membership/join_request_submit_email_test.exs index 87c989a..5f648d4 100644 --- a/test/membership/join_request_submit_email_test.exs +++ b/test/membership/join_request_submit_email_test.exs @@ -26,6 +26,7 @@ defmodule Mv.Membership.JoinRequestSubmitEmailTest do assert_email_sent(fn email_sent -> to_addresses = Enum.map(email_sent.to, &elem(&1, 1)) + to_string(email) in to_addresses and (email_sent.html_body =~ "/confirm_join/" or email_sent.text_body =~ "/confirm_join/") end) diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs index a8e4334..d1e9117 100644 --- a/test/mv_web/controllers/join_confirm_controller_test.exs +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -87,7 +87,6 @@ defmodule MvWeb.JoinConfirmControllerTest do conn = get(conn, "/confirm_join/public-test-token") assert conn.status == 200 - refute redirected_to(conn) =~ "/sign-in" end end end From 5deb102e459b6f735f368e9b979a1057558abfcc Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 18:54:40 +0100 Subject: [PATCH 186/237] refactor: adress review comments --- CODE_GUIDELINES.md | 2 +- config/runtime.exs | 14 +++--- docs/development-progress-log.md | 3 +- lib/membership/membership.ex | 20 +++++++-- .../tasks/join_requests.cleanup_expired.ex | 43 +++++++++++++------ .../send_new_user_confirmation_email.ex | 10 ++++- .../user/senders/send_password_reset_email.ex | 10 ++++- lib/mv_web/emails/email_layout_view.ex | 3 ++ lib/mv_web/emails/join_confirmation_email.ex | 18 ++++++-- .../templates/emails/layouts/layout.html.heex | 10 +++-- priv/gettext/en/LC_MESSAGES/default.po | 30 ++++++------- 11 files changed, 111 insertions(+), 52 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 77098d9..66a93a5 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1260,7 +1260,7 @@ 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; override in runtime.exs from ENV for production. +- 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`). **Unified layout (transactional emails):** diff --git a/config/runtime.exs b/config/runtime.exs index d502cfa..b8570d8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -217,13 +217,13 @@ if config_env() == :prod do # # Check `Plug.SSL` for all available options in `force_ssl`. - # ## Configuring the mailer - # - # Transactional emails (join confirmation, user confirmation, password reset) use - # the sender from config :mv, :mail_from. Override in production if needed: - # config :mv, :mail_from, {System.get_env("MAIL_FROM_NAME", "Mila"), System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} - # - # In production you need to configure the mailer to use a different adapter. + # Transactional emails use the sender from config :mv, :mail_from (overridable via ENV). + config :mv, + :mail_from, + {System.get_env("MAIL_FROM_NAME", "Mila"), + System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} + + # 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: # diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 4e95ff4..a86efe6 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -805,7 +805,8 @@ end - **Join confirmation:** Domain wrapper `submit_join_request/2` generates token (or uses optional `:confirmation_token` in attrs for tests), creates JoinRequest via action `:submit`, then sends one email via `MvWeb.Emails.JoinConfirmationEmail`. Route `GET /confirm_join/:token` (JoinConfirmController) updates to `submitted`; idempotent; expired/invalid handled. - **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. - **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban. -- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po. +- **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. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. ### Test Data Management diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index ffca356..5e01a6a 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -31,6 +31,7 @@ defmodule Mv.Membership do alias Ash.Error.Query.NotFound, as: NotFoundError alias Mv.Membership.JoinRequest alias MvWeb.Emails.JoinConfirmationEmail + require Logger admin do show? true @@ -369,8 +370,9 @@ defmodule Mv.Membership do actor = Keyword.get(opts, :actor) token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() - attrs_with_token = - attrs |> Map.drop([:confirmation_token]) |> Map.put(:confirmation_token, token) + # 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. + attrs_with_token = Map.put(attrs, :confirmation_token, token) case Ash.create(JoinRequest, attrs_with_token, action: :submit, @@ -378,8 +380,18 @@ defmodule Mv.Membership do domain: __MODULE__ ) do {:ok, request} -> - JoinConfirmationEmail.send(request.email, token) - {:ok, request} + case JoinConfirmationEmail.send(request.email, token) do + {:ok, _email} -> + {:ok, request} + + {:error, reason} -> + Logger.error( + "Join confirmation email failed for #{request.email}: #{inspect(reason)}" + ) + + # Request was created; return success so the user sees the confirmation message + {:ok, request} + end error -> error diff --git a/lib/mix/tasks/join_requests.cleanup_expired.ex b/lib/mix/tasks/join_requests.cleanup_expired.ex index bc9ea2a..0d1823c 100644 --- a/lib/mix/tasks/join_requests.cleanup_expired.ex +++ b/lib/mix/tasks/join_requests.cleanup_expired.ex @@ -17,6 +17,7 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do use Mix.Task require Ash.Query + require Logger alias Mv.Membership.JoinRequest @@ -34,23 +35,41 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do |> Ash.Query.filter(confirmation_token_expires_at < ^now) # Bypass authorization: cleanup is a system maintenance task (cron/Oban). - case Ash.read(query, domain: Mv.Membership, authorize?: false) do - {:ok, requests} -> - count = delete_expired_requests(requests) + # Use bulk_destroy so the data layer can delete in one pass when supported. + opts = [domain: Mv.Membership, authorize?: false] + + count = + case Ash.count(query, opts) do + {:ok, n} -> n + {:error, _} -> 0 + end + + do_run(query, opts, count) + end + + defp do_run(_query, _opts, 0) do + Mix.shell().info("No expired join requests to delete.") + 0 + end + + defp do_run(query, opts, count) do + case Ash.bulk_destroy(query, :destroy, %{}, opts) do + %{status: status, errors: errors} when status in [:success, :partial_success] -> + maybe_log_errors(errors) Mix.shell().info("Deleted #{count} expired join request(s).") count - {:error, error} -> - Mix.raise("Failed to list expired join requests: #{inspect(error)}") + %{status: :error, errors: errors} -> + Mix.raise("Failed to delete expired join requests: #{inspect(errors)}") end end - defp delete_expired_requests(requests) do - Enum.reduce_while(requests, 0, fn request, acc -> - case Ash.destroy(request, domain: Mv.Membership, authorize?: false) do - :ok -> {:cont, acc + 1} - {:error, _} -> {:halt, acc} - end - end) + defp maybe_log_errors(nil), do: :ok + defp maybe_log_errors([]), do: :ok + + defp maybe_log_errors(errors) do + Logger.warning( + "Join requests cleanup: #{length(errors)} error(s) while deleting expired requests: #{inspect(errors)}" + ) end end 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 7c5fa0c..393a220 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 @@ -37,13 +37,19 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do confirm_url = url(~p"/confirm_new_user/#{token}") subject = gettext("Confirm your email address") + assigns = %{ + confirm_url: confirm_url, + subject: subject, + app_name: Mailer.mail_from() |> elem(0), + locale: Gettext.get_locale(MvWeb.Gettext) + } + new() |> from(Mailer.mail_from()) |> to(to_string(user.email)) |> subject(subject) |> put_view(MvWeb.EmailsView) - |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) - |> render_body("user_confirmation.html", %{confirm_url: confirm_url, subject: subject}) + |> render_body("user_confirmation.html", assigns) |> Mailer.deliver!() 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 a4bb489..74d5d47 100644 --- a/lib/mv/accounts/user/senders/send_password_reset_email.ex +++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex @@ -37,13 +37,19 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do reset_url = url(~p"/password-reset/#{token}") subject = gettext("Reset your password") + assigns = %{ + reset_url: reset_url, + subject: subject, + app_name: Mailer.mail_from() |> elem(0), + locale: Gettext.get_locale(MvWeb.Gettext) + } + new() |> from(Mailer.mail_from()) |> to(to_string(user.email)) |> subject(subject) |> put_view(MvWeb.EmailsView) - |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) - |> render_body("password_reset.html", %{reset_url: reset_url, subject: subject}) + |> render_body("password_reset.html", assigns) |> Mailer.deliver!() end end diff --git a/lib/mv_web/emails/email_layout_view.ex b/lib/mv_web/emails/email_layout_view.ex index a0cf03f..da76656 100644 --- a/lib/mv_web/emails/email_layout_view.ex +++ b/lib/mv_web/emails/email_layout_view.ex @@ -4,6 +4,9 @@ defmodule MvWeb.EmailLayoutView do Renders a single layout template that wraps all email body content. See docs/email-layout-mockup.md for the layout structure. + + Uses Phoenix.View (legacy API) for compatibility with phoenix_swoosh email rendering. + Layout expects assigns :app_name and :locale (passed from each email sender). """ use Phoenix.View, root: "lib/mv_web", diff --git a/lib/mv_web/emails/join_confirmation_email.ex b/lib/mv_web/emails/join_confirmation_email.ex index f72ec45..781a205 100644 --- a/lib/mv_web/emails/join_confirmation_email.ex +++ b/lib/mv_web/emails/join_confirmation_email.ex @@ -4,7 +4,7 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do """ use Phoenix.Swoosh, view: MvWeb.EmailsView, - layout: {MvWeb.EmailLayoutView, :layout} + layout: {MvWeb.EmailLayoutView, "layout.html"} use MvWeb, :verified_routes import Swoosh.Email @@ -16,18 +16,28 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do Sends the join confirmation email to the given address with the confirmation link. 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}") subject = gettext("Confirm your membership request") + assigns = %{ + confirm_url: confirm_url, + subject: subject, + app_name: Mailer.mail_from() |> elem(0), + locale: Gettext.get_locale(MvWeb.Gettext) + } + new() |> from(Mailer.mail_from()) |> to(email_address) |> subject(subject) |> put_view(MvWeb.EmailsView) - |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) - |> render_body("join_confirmation.html", %{confirm_url: confirm_url, subject: subject}) - |> Mailer.deliver!() + |> render_body("join_confirmation.html", assigns) + |> Mailer.deliver() end end diff --git a/lib/mv_web/templates/emails/layouts/layout.html.heex b/lib/mv_web/templates/emails/layouts/layout.html.heex index 63bc5c7..4b5535f 100644 --- a/lib/mv_web/templates/emails/layouts/layout.html.heex +++ b/lib/mv_web/templates/emails/layouts/layout.html.heex @@ -1,9 +1,9 @@ - + - {assigns[:subject] || "Mila"} + {assigns[:subject] || assigns[:app_name] || "Mila"} @@ -25,7 +27,7 @@
-
Mila
+
+ {assigns[:app_name] || "Mila"} +
- © {DateTime.utc_now().year} Mila · Mitgliederverwaltung + © {DateTime.utc_now().year} {assigns[:app_name] || "Mila"} · Mitgliederverwaltung
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index bd9bb0c..7dc5068 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3243,77 +3243,77 @@ msgstr "without %{name}" #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "Confirm my email" -msgstr "" +msgstr "Confirm my email" #: lib/mv_web/templates/emails/join_confirmation.html.heex #, elixir-autogen, elixir-format msgid "Confirm my request" -msgstr "" +msgstr "Confirm my request" #: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex #, elixir-autogen, elixir-format msgid "Confirm your email address" -msgstr "" +msgstr "Confirm your email address" #: lib/mv_web/emails/join_confirmation_email.ex #, elixir-autogen, elixir-format msgid "Confirm your membership request" -msgstr "" +msgstr "Confirm your membership request" #: 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 "" +msgstr "If you did not create an account, you can ignore this email." #: 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 "" +msgstr "If you did not request this, you can ignore this email. Your password will remain unchanged." #: 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 "" +msgstr "If you did not submit this request, you can ignore this email." #: lib/mv_web/controllers/join_confirm_controller.ex #, elixir-autogen, elixir-format msgid "Invalid or expired link." -msgstr "" +msgstr "Invalid or expired link." #: 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 "" +msgstr "Please confirm your email address by clicking the link below." #: lib/mv_web/templates/emails/password_reset.html.heex #, elixir-autogen, elixir-format msgid "Reset password" -msgstr "" +msgstr "Reset password" #: lib/mv/accounts/user/senders/send_password_reset_email.ex #, elixir-autogen, elixir-format msgid "Reset your password" -msgstr "" +msgstr "Reset your password" #: lib/mv_web/controllers/join_confirm_controller.ex #, elixir-autogen, elixir-format msgid "Thank you, we have received your request." -msgstr "" +msgstr "Thank you, we have received your request." #: lib/mv_web/controllers/join_confirm_controller.ex #, elixir-autogen, elixir-format msgid "This link has expired. Please submit the form again." -msgstr "" +msgstr "This link has expired. Please submit the form again." #: 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 "" +msgstr "We have received your membership request. To complete it, please click the link below." #: 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 "" +msgstr "You requested a password reset. Click the link below to set a new password." #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format, fuzzy From b7a83d92985e767f7aa40119c537735e602fac66 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 12:18:36 +0100 Subject: [PATCH 187/237] test: add tests for join form settings --- docs/development-progress-log.md | 3 + test/membership/setting_join_form_test.exs | 281 +++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 test/membership/setting_join_form_test.exs diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index a86efe6..b2a814b 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -809,6 +809,9 @@ end - **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. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. +**Subtask 3 – Admin: Join form settings (TDD tests only):** +- Test file: `test/membership/setting_join_form_test.exs` – TDD tests for join form settings (persistence, validation, allowlist, defaults, robustness). Tests are red until Setting gains `join_form_enabled`, `join_form_field_ids`, `join_form_field_required` and `Mv.Membership.get_join_form_allowlist/0` is implemented. No functionality implemented yet. + ### Test Data Management **Seed Data:** diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs new file mode 100644 index 0000000..9b15ca4 --- /dev/null +++ b/test/membership/setting_join_form_test.exs @@ -0,0 +1,281 @@ +defmodule Mv.Membership.SettingJoinFormTest do + @moduledoc """ + TDD tests for Join Form Settings (onboarding-join-concept subtask 3). + + These tests define the expected API and behaviour for the "Onboarding / Join" section + in global settings. No functionality is implemented yet; tests are expected to fail + (red) until: + + - Setting resource has attributes: `join_form_enabled`, `join_form_field_ids`, + `join_form_field_required`, plus validations and accept in the update action. + - `Mv.Membership.get_join_form_allowlist/0` is implemented and returns the allowlist + for the public join form (subtask 4). + """ + use Mv.DataCase, async: false + + alias Mv.Membership + alias Mv.Helpers.SystemActor + alias Mv.Constants + + setup do + {:ok, settings} = Membership.get_settings() + saved_enabled = Map.get(settings, :join_form_enabled) + saved_ids = Map.get(settings, :join_form_field_ids) + saved_required = Map.get(settings, :join_form_field_required) + + on_exit(fn -> + {:ok, s} = Membership.get_settings() + attrs = %{} + attrs = if saved_enabled != nil, do: Map.put(attrs, :join_form_enabled, saved_enabled), else: attrs + attrs = if saved_ids != nil, do: Map.put(attrs, :join_form_field_ids, saved_ids || []), else: attrs + attrs = + if saved_required != nil, + do: Map.put(attrs, :join_form_field_required, saved_required || %{}), + else: attrs + + if attrs != %{} do + Membership.update_settings(s, attrs) + end + end) + + :ok + end + + defp update_join_form_settings(settings, attrs) do + Membership.update_settings(settings, attrs) + end + + defp error_message(errors, field) when is_atom(field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() || "" + end + + # ---- 1. Persistence and loading ---- + + describe "join form settings persistence and loading" do + test "save and load join_form_enabled plus field selection and required flags returns same config" do + {:ok, settings} = Membership.get_settings() + attrs = %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + } + + assert {:ok, updated} = update_join_form_settings(settings, attrs) + assert updated.join_form_enabled == true + assert updated.join_form_field_ids == ["email", "first_name"] + assert updated.join_form_field_required["email"] == true + assert updated.join_form_field_required["first_name"] == false + + {:ok, reloaded} = Membership.get_settings() + assert reloaded.join_form_enabled == true + assert reloaded.join_form_field_ids == ["email", "first_name"] + assert reloaded.join_form_field_required["email"] == true + assert reloaded.join_form_field_required["first_name"] == false + end + + test "repeated save with changed field list overwrites config without leftovers" do + {:ok, settings} = Membership.get_settings() + assert {:ok, _} = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + assert {:ok, updated} = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "last_name"], + join_form_field_required: %{"email" => true, "last_name" => false} + }) + + assert updated.join_form_field_ids == ["email", "last_name"] + assert Map.has_key?(updated.join_form_field_required, "last_name") + refute Map.has_key?(updated.join_form_field_required, "first_name") + end + end + + # ---- 2. Validation ---- + + describe "join form settings validation" do + test "only existing member fields or custom field ids are accepted; unknown field names rejected or sanitized" do + {:ok, settings} = Membership.get_settings() + + result = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "not_a_member_field"], + join_form_field_required: %{"email" => true, "not_a_member_field" => false} + }) + + # Until attributes exist we get NoSuchInput; once implemented we expect validation error + assert {:error, _} = result + end + + test "config without email is rejected or email is auto-added and required" do + {:ok, settings} = Membership.get_settings() + + result = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["first_name", "last_name"], + join_form_field_required: %{"first_name" => true, "last_name" => false} + }) + + # Either rejected or, when loaded, email must be present and required + case result do + {:error, _} -> + :ok + {:ok, updated} -> + assert "email" in updated.join_form_field_ids + assert updated.join_form_field_required["email"] == true + end + end + + test "required false for email is ignored or forced to true when saved" do + {:ok, settings} = Membership.get_settings() + + {:ok, updated} = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => false, "first_name" => false} + }) + + assert updated.join_form_field_required["email"] == true + end + + test "required flag for field not in join_form_field_ids is rejected or dropped" do + {:ok, settings} = Membership.get_settings() + + result = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email"], + join_form_field_required: %{"email" => true, "first_name" => true} + }) + + case result do + {:error, _} -> + :ok + {:ok, updated} -> + refute Map.has_key?(updated.join_form_field_required, "first_name") + end + end + end + + # ---- 3. Allowlist for join form ---- + + describe "join form allowlist" do + test "allowlist returns configured fields with required/optional when join form enabled" do + {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + allowlist = Membership.get_join_form_allowlist() + + assert length(allowlist) == 2 + email_entry = Enum.find(allowlist, &( &1.id == "email" )) + first_name_entry = Enum.find(allowlist, &( &1.id == "first_name" )) + assert email_entry.required == true + assert first_name_entry.required == false + assert email_entry.type == :member_field + assert first_name_entry.type == :member_field + end + + test "allowlist returns empty or defined default when join form disabled" do + {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ + join_form_enabled: false, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + allowlist = Membership.get_join_form_allowlist() + + assert allowlist == [] + end + + @tag :requires_custom_field + test "allowlist distinguishes member fields and custom field identifiers" do + {:ok, settings} = Membership.get_settings() + actor = SystemActor.get_system_actor() + {:ok, cf} = + Membership.create_custom_field( + %{name: "join_cf_#{System.unique_integer([:positive])}", value_type: :string}, + actor: actor + ) + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", cf.id], + join_form_field_required: %{"email" => true, cf.id => false} + }) + + allowlist = Membership.get_join_form_allowlist() + + email_entry = Enum.find(allowlist, &( &1.id == "email" )) + cf_entry = Enum.find(allowlist, &( &1.id == cf.id )) + assert email_entry.type == :member_field + assert cf_entry.type == :custom_field + end + end + + # ---- 4. Defaults and fallback ---- + + describe "join form defaults and fallback" do + test "when no join settings stored, allowlist returns defined default (e.g. disabled, empty list)" do + allowlist = Membership.get_join_form_allowlist() + # Default: join form disabled → empty allowlist + assert is_list(allowlist) + assert allowlist == [] || Enum.all?(allowlist, &(is_map(&1) and Map.has_key?(&1, :id))) + end + + test "existing settings without join keys load correctly; new join keys get defaults" do + {:ok, settings} = Membership.get_settings() + # Ensure other attributes still load + assert Map.has_key?(settings, :club_name) + # When join keys exist they have sensible defaults + join_enabled = Map.get(settings, :join_form_enabled) + join_ids = Map.get(settings, :join_form_field_ids) + if join_enabled != nil, do: assert(is_boolean(join_enabled)) + if join_ids != nil, do: assert(is_list(join_ids)) + end + end + + # ---- 5. Authorization (backend: settings update requires authorized actor when policy enforced) ---- + # Authorization for the settings page is covered by GlobalSettingsLive and page-permission tests. + # If the domain later requires an actor for update_settings, tests here would pass an actor. + + # ---- 6. Robustness / edge cases ---- + + describe "join form settings robustness" do + test "invalid or unexpected payload structure yields clean error or ignores unknown keys" do + {:ok, settings} = Membership.get_settings() + + result = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: "not_a_list", + join_form_field_required: %{} + }) + + assert match?({:error, _}, result) or + (match?({:ok, _}, result) && elem(result, 1).join_form_field_ids != "not_a_list") + end + + test "larger but reasonable number of fields saves and loads without error" do + {:ok, settings} = Membership.get_settings() + all_member = Constants.member_fields() |> Enum.map(&to_string/1) + required_map = Map.new(all_member, fn f -> {f, f == "email"} end) + + assert {:ok, updated} = update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: all_member, + join_form_field_required: required_map + }) + + assert length(updated.join_form_field_ids) == length(all_member) + {:ok, reloaded} = Membership.get_settings() + assert length(reloaded.join_form_field_ids) == length(all_member) + end + end +end From fa738aae88bf92eb26f4dfb1f45dd68136687e27 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 14:29:49 +0100 Subject: [PATCH 188/237] feat: add join form settings --- CODE_GUIDELINES.md | 3 +- docs/development-progress-log.md | 9 +- lib/membership/membership.ex | 50 +++ lib/membership/setting.ex | 76 +++- .../changes/normalize_join_form_settings.ex | 60 +++ lib/mv_web/components/core_components.ex | 2 +- lib/mv_web/live/global_settings_live.ex | 365 +++++++++++++++++- priv/gettext/de/LC_MESSAGES/default.po | 67 ++++ priv/gettext/default.pot | 57 +++ priv/gettext/en/LC_MESSAGES/default.po | 67 ++++ ...701_add_join_form_settings_to_settings.exs | 27 ++ test/membership/setting_join_form_test.exs | 117 +++--- 12 files changed, 846 insertions(+), 54 deletions(-) create mode 100644 lib/membership/setting/changes/normalize_join_form_settings.ex create mode 100644 priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 66a93a5..ed9f130 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -89,7 +89,8 @@ lib/ │ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest) │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource -│ ├── setting.ex # Global settings (singleton resource) +│ ├── setting.ex # Global settings (singleton resource; incl. join form config) +│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.) │ ├── group.ex # Group resource │ ├── member_group.ex # MemberGroup join table resource │ └── email.ex # Email custom type diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index b2a814b..a6297ba 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -809,8 +809,13 @@ end - **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. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. -**Subtask 3 – Admin: Join form settings (TDD tests only):** -- Test file: `test/membership/setting_join_form_test.exs` – TDD tests for join form settings (persistence, validation, allowlist, defaults, robustness). Tests are red until Setting gains `join_form_enabled`, `join_form_field_ids`, `join_form_field_required` and `Mv.Membership.get_join_form_allowlist/0` is implemented. No functionality implemented yet. +**Subtask 3 – Admin: Join form settings (done):** +- **Setting resource** (`lib/membership/setting.ex`): 3 new attributes `join_form_enabled` (boolean, default false), `join_form_field_ids` ({:array, :string} – ordered list of member field names or custom field UUIDs), `join_form_field_required` (:map – field ID → boolean). Added to `:create` and `:update` accept lists. Validation rejects field IDs that are neither valid member field names nor UUID format. Migration: `20260310114701_add_join_form_settings_to_settings.exs`. +- **NormalizeJoinFormSettings** (`lib/membership/setting/changes/normalize_join_form_settings.ex`): Change applied on create/update whenever join form attrs are changing. Ensures email is always in `join_form_field_ids`, forces `join_form_field_required["email"] = true`, drops required flags for fields not in `join_form_field_ids`. +- **Domain** (`lib/membership/membership.ex`): `Mv.Membership.get_join_form_allowlist/0` – returns `[]` when join form is disabled, otherwise a list of `%{id, required, type}` maps (type = `:member_field` or `:custom_field` based on ID format). +- **GlobalSettingsLive** (`lib/mv_web/live/global_settings_live.ex`): New "Join Form" / "Beitrittsformular" section between Club Settings and Vereinfacht. Checkbox to enable/disable, table of selected fields (with Required checkbox per field – email always checked/disabled, other fields can be toggled), "Add field" dropdown with all unselected member fields and custom fields, "Save Join Form Settings" button. State is managed locally (not via AshPhoenix.Form); saved on explicit save click. +- **Translations**: 14 new German strings in `priv/gettext/de/LC_MESSAGES/default.po` (Beitrittsformular, Felder im Beitrittsformular, Feld hinzufügen, etc.). +- Tests: All 13 tests in `test/membership/setting_join_form_test.exs` pass; full test suite 1900 tests, 0 failures. ### Test Data Management diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 5e01a6a..3f34903 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -455,6 +455,56 @@ defmodule Mv.Membership do end end + @doc """ + Returns the allowlist of fields configured for the public join form. + + Reads the current settings. When the join form is disabled (or no settings exist), + returns an empty list. When enabled, returns each configured field as a map with: + - `:id` - field identifier string (member field name or custom field UUID) + - `:required` - boolean; email is always true + - `:type` - `:member_field` or `:custom_field` + + This is the server-side allowlist used by the join form submit action (Subtask 4) + to enforce which fields are accepted from user input. + + ## Returns + + - `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]` + - `[]` when join form is disabled or settings are missing + + ## Examples + + iex> Mv.Membership.get_join_form_allowlist() + [%{id: "email", required: true, type: :member_field}, + %{id: "first_name", required: false, type: :member_field}] + + """ + def get_join_form_allowlist do + case get_settings() do + {:ok, settings} -> + if settings.join_form_enabled do + build_join_form_allowlist(settings) + else + [] + end + + {:error, _} -> + [] + end + end + + defp build_join_form_allowlist(settings) do + field_ids = settings.join_form_field_ids || [] + required_config = settings.join_form_field_required || %{} + member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + Enum.map(field_ids, fn id -> + type = if id in member_field_names, do: :member_field, else: :custom_field + required = Map.get(required_config, id, false) + %{id: id, required: required, type: type} + end) + end + defp expired?(nil), do: true defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 894725f..adf05b9 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,6 +15,12 @@ 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) + - `join_form_enabled` - Whether the public /join page is active (default: false) + - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is + either a member field name string (e.g. "email") or a custom field UUID. Email is always + included and always required; normalization enforces this automatically. + - `join_form_field_required` - Map of field ID => required boolean for the join form. + Email is always forced to true. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -86,8 +92,13 @@ defmodule Mv.Membership.Setting do :oidc_client_secret, :oidc_admin_group_name, :oidc_groups_claim, - :oidc_only + :oidc_only, + :join_form_enabled, + :join_form_field_ids, + :join_form_field_required ] + + change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings end update :update do @@ -110,8 +121,13 @@ defmodule Mv.Membership.Setting do :oidc_client_secret, :oidc_admin_group_name, :oidc_groups_claim, - :oidc_only + :oidc_only, + :join_form_enabled, + :join_form_field_ids, + :join_form_field_required ] + + change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings end update :update_member_field_visibility do @@ -232,6 +248,39 @@ defmodule Mv.Membership.Setting do end, on: [:create, :update] + # Validate join_form_field_ids: each entry must be a known member field name + # or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings + # change) runs before validations, so email is already present when this runs. + validate fn changeset, _context -> + field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids) + + if is_list(field_ids) and field_ids != [] do + valid_member_fields = + Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + uuid_pattern = + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + invalid_ids = + Enum.reject(field_ids, fn id -> + is_binary(id) and + (id in valid_member_fields or Regex.match?(uuid_pattern, id)) + end) + + if Enum.empty?(invalid_ids) do + :ok + else + {:error, + field: :join_form_field_ids, + message: + "Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."} + end + else + :ok + end + end, + on: [:create, :update] + # Validate default_membership_fee_type_id exists if set validate fn changeset, context -> fee_type_id = @@ -382,6 +431,29 @@ defmodule Mv.Membership.Setting do description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)" end + # Join form (Beitrittsformular) settings + attribute :join_form_enabled, :boolean do + allow_nil? false + default false + public? true + + description "When true, the public /join page is active and new members can submit a request." + end + + attribute :join_form_field_ids, {:array, :string} do + allow_nil? true + public? true + + description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization." + end + + attribute :join_form_field_required, :map do + allow_nil? true + public? true + + description "Map of field ID => required boolean for the join form. Email is always true after normalization." + end + timestamps() end diff --git a/lib/membership/setting/changes/normalize_join_form_settings.ex b/lib/membership/setting/changes/normalize_join_form_settings.ex new file mode 100644 index 0000000..d21434a --- /dev/null +++ b/lib/membership/setting/changes/normalize_join_form_settings.ex @@ -0,0 +1,60 @@ +defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do + @moduledoc """ + Ash change that normalizes join form field settings before persist. + + Applied on create and update actions whenever join form attributes are present. + + Rules enforced: + - Email is always added to join_form_field_ids if not already present. + - Email is always marked as required (true) in join_form_field_required. + - Keys in join_form_field_required that are not in join_form_field_ids are dropped. + + Only runs when join_form_field_ids is being changed; if only + join_form_field_required changes, normalization still uses the current + (possibly changed) field_ids to strip orphaned required flags. + """ + use Ash.Resource.Change + + def change(changeset, _opts, _context) do + changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids) + changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required) + + if changing_ids? or changing_required? do + normalize(changeset) + else + changeset + end + end + + defp normalize(changeset) do + field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids) + required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required) + + field_ids = normalize_field_ids(field_ids) + required_config = normalize_required(field_ids, required_config) + + changeset + |> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids) + |> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config) + end + + defp normalize_field_ids(nil), do: ["email"] + + defp normalize_field_ids(ids) when is_list(ids) do + if "email" in ids do + ids + else + ["email" | ids] + end + end + + defp normalize_field_ids(_), do: ["email"] + + defp normalize_required(field_ids, required_config) do + base = if is_map(required_config), do: required_config, else: %{} + + base + |> Map.filter(fn {key, _} -> key in field_ids end) + |> Map.put("email", true) + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index bb5529e..8a8ff0d 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -990,7 +990,7 @@ defmodule MvWeb.CoreComponents do /> - {gettext("Actions")} + {gettext("Actions")} diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 58eed2a..651afc0 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -4,16 +4,24 @@ defmodule MvWeb.GlobalSettingsLive do ## Features - Edit the association/club name + - Configure the public join form (Beitrittsformular) - Manage custom fields - Real-time form validation - Success/error feedback ## Settings - `club_name` - The name of the association/club (required) + - `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` - Real-time form validation - - `save` - Save settings changes + - `validate` / `save` - Club settings form + - `toggle_join_form_enabled` - Enable/disable the join form + - `add_join_form_field` / `remove_join_form_field` - Manage join form fields + - `toggle_join_form_field_required` - Toggle required flag per field + - `toggle_add_field_dropdown` / `hide_add_field_dropdown` - Dropdown visibility + - Join form changes (enable/disable, add/remove fields, required toggles) are persisted immediately ## Note Settings is a singleton resource - there is only one settings record. @@ -31,6 +39,7 @@ defmodule MvWeb.GlobalSettingsLive do alias Mv.Membership alias Mv.Membership.Member, as: MemberResource alias MvWeb.Helpers.MemberHelpers + alias MvWeb.Translations.MemberFields on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} @@ -42,6 +51,9 @@ defmodule MvWeb.GlobalSettingsLive do locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") Gettext.put_locale(MvWeb.Gettext, locale) + actor = MvWeb.LiveHelpers.current_actor(socket) + custom_fields = load_custom_fields(actor) + socket = socket |> assign(:page_title, gettext("Settings")) @@ -65,6 +77,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, present?(settings.oidc_client_secret)) + |> assign_join_form_state(settings, custom_fields) |> assign_form() {:ok, socket} @@ -103,6 +116,144 @@ defmodule MvWeb.GlobalSettingsLive do + <%!-- 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 --%> +
+ + +
+ + <%!-- Board approval (future feature) --%> +
+ + +
+ +
+ <%!-- 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) --%> +
+ <.table + id="join-form-fields-table" + rows={@join_form_fields} + row_id={fn field -> "join-field-#{field.id}" end} + > + <: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" /> + + + + +
+
+ <%!-- Vereinfacht Integration Section --%> <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %> @@ -426,6 +577,126 @@ defmodule MvWeb.GlobalSettingsLive do end end + # ---- Join form event handlers ---- + + @impl true + def handle_event("toggle_join_form_enabled", _params, socket) do + socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled) + {:noreply, persist_join_form_settings(socket)} + end + + @impl true + def handle_event("toggle_add_field_dropdown", _params, socket) do + {:noreply, + assign(socket, :show_add_field_dropdown, not socket.assigns.show_add_field_dropdown)} + end + + @impl true + def handle_event("hide_add_field_dropdown", _params, socket) do + {:noreply, assign(socket, :show_add_field_dropdown, false)} + end + + @impl true + def handle_event("add_join_form_field", %{"field_id" => field_id}, socket) do + member_avail = socket.assigns.available_join_form_member_fields + custom_avail = socket.assigns.available_join_form_custom_fields + current = socket.assigns.join_form_fields + + field_to_add = + Enum.find(member_avail, &(&1.id == field_id)) || + Enum.find(custom_avail, &(&1.id == field_id)) + + socket = + if field_to_add do + full_field = %{ + id: field_to_add.id, + label: field_to_add.label, + type: field_to_add.type, + required: false, + can_remove: field_to_add.id != "email", + can_toggle_required: field_to_add.id != "email" + } + + new_fields = current ++ [full_field] + new_member = Enum.reject(member_avail, &(&1.id == field_id)) + new_custom = Enum.reject(custom_avail, &(&1.id == field_id)) + + socket + |> assign(:join_form_fields, new_fields) + |> assign(:available_join_form_member_fields, new_member) + |> assign(:available_join_form_custom_fields, new_custom) + |> assign(:show_add_field_dropdown, false) + else + socket + end + + {:noreply, persist_join_form_settings(socket)} + end + + @impl true + def handle_event("remove_join_form_field", %{"field_id" => field_id}, socket) do + if field_id == "email" do + {:noreply, socket} + else + current = socket.assigns.join_form_fields + custom_fields = socket.assigns.join_form_custom_fields + new_fields = Enum.reject(current, &(&1.id == field_id)) + new_ids = Enum.map(new_fields, & &1.id) + %{member_fields: new_member, custom_fields: new_custom} = + build_available_join_form_fields(new_ids, custom_fields) + + socket = + socket + |> assign(:join_form_fields, new_fields) + |> assign(:available_join_form_member_fields, new_member) + |> assign(:available_join_form_custom_fields, new_custom) + |> persist_join_form_settings() + + {:noreply, socket} + end + end + + @impl true + def handle_event("toggle_join_form_field_required", %{"field_id" => "email"}, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("toggle_join_form_field_required", %{"field_id" => field_id}, socket) do + new_fields = + Enum.map(socket.assigns.join_form_fields, &toggle_required_if_matches(&1, field_id)) + + socket = assign(socket, :join_form_fields, new_fields) |> persist_join_form_settings() + {:noreply, socket} + end + + defp persist_join_form_settings(socket) do + settings = socket.assigns.settings + field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id) + + required_map = + socket.assigns.join_form_fields + |> Map.new(fn field -> {field.id, field.required} end) + + attrs = %{ + join_form_enabled: socket.assigns.join_form_enabled, + join_form_field_ids: field_ids, + join_form_field_required: required_map + } + + case Membership.update_settings(settings, attrs) do + {:ok, updated_settings} -> + custom_fields = socket.assigns.join_form_custom_fields + + socket + |> assign(:settings, updated_settings) + |> assign_join_form_state(updated_settings, custom_fields) + + {:error, _error} -> + put_flash(socket, :error, gettext("Could not save join form settings.")) + end + end + @vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url] defp vereinfacht_params?(params) when is_map(params) do @@ -709,4 +980,94 @@ defmodule MvWeb.GlobalSettingsLive do
""" end + + # ---- Join form helper functions ---- + + defp assign_join_form_state(socket, settings, custom_fields) do + enabled = settings.join_form_enabled || false + raw_ids = settings.join_form_field_ids || [] + field_ids = if "email" in raw_ids, do: raw_ids, else: ["email" | raw_ids] + required_config = settings.join_form_field_required || %{} + + join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields) + %{member_fields: member_avail, custom_fields: custom_avail} = + build_available_join_form_fields(field_ids, custom_fields) + + socket + |> assign(:join_form_enabled, enabled) + |> assign(:join_form_fields, join_form_fields) + |> assign(:available_join_form_member_fields, member_avail) + |> assign(:available_join_form_custom_fields, custom_avail) + |> assign(:show_add_field_dropdown, false) + |> assign(:join_form_custom_fields, custom_fields) + end + + defp build_join_form_fields(field_ids, required_config, custom_fields) do + Enum.map(field_ids, fn id -> + label = join_form_field_label(id, custom_fields) + required = if id == "email", do: true, else: Map.get(required_config, id, false) + type = if id in member_field_id_strings(), do: :member_field, else: :custom_field + + %{ + id: id, + label: label, + required: required, + can_remove: id != "email", + can_toggle_required: id != "email", + type: type + } + end) + end + + defp build_available_join_form_fields(selected_ids, custom_fields) do + member_fields = + Mv.Constants.member_fields() + |> Enum.reject(fn field -> Atom.to_string(field) in selected_ids end) + |> Enum.map(fn field -> + %{id: Atom.to_string(field), label: MemberFields.label(field), type: :member_field} + end) + + custom_field_entries = + custom_fields + |> Enum.reject(fn cf -> cf.id in selected_ids end) + |> Enum.map(fn cf -> + %{id: cf.id, label: cf.name, type: :custom_field} + end) + |> Enum.sort_by(& &1.label) + + %{member_fields: member_fields, custom_fields: custom_field_entries} + end + + defp join_form_field_label(id, custom_fields) do + if id in member_field_id_strings() do + MemberFields.label(String.to_existing_atom(id)) + else + case Enum.find(custom_fields, &(&1.id == id)) do + nil -> id + cf -> cf.name + end + end + end + + defp toggle_required_if_matches(%{id: id} = field, id), + do: Map.put(field, :required, not field.required) + + defp toggle_required_if_matches(field, _field_id), do: field + + defp member_field_id_strings do + Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + end + + defp load_custom_fields(nil), do: [] + + defp load_custom_fields(actor) do + case Ash.read(Mv.Membership.CustomField, + actor: actor, + domain: Mv.Membership, + authorize?: true + ) do + {:ok, fields} -> fields + {:error, _} -> [] + end + end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 7015d9c..01c49f6 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -357,6 +357,7 @@ msgstr "Passwort-Authentifizierung" #: 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 #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2149,6 +2150,7 @@ msgstr "Mitglied ist nicht in dieser Gruppe." msgid "No email" msgstr "Keine E-Mail" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,68 @@ msgstr "Unvollständig" #, elixir-autogen, elixir-format, fuzzy msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "Diese Datenfelder sind für MILA notwendig, um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "Feld hinzufügen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "Verfügbare Felder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "Konfiguriere das öffentliche Beitrittsformular, über das neue Mitglieder einen Beitrittsantrag stellen können." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "Feld" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "Felder im Beitrittsformular" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "Beitrittsformular" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "Beitrittsformular aktiv" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +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 "Personal data" +msgstr "Persönliche Daten" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Individual fields" +msgstr "Individuelle Felder" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 14db165..b5ab449 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -358,6 +358,7 @@ 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 #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2150,6 +2151,7 @@ msgstr "" msgid "No email" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,58 @@ msgstr "" #, elixir-autogen, elixir-format msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Remove field %{label}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Board approval required (in development)" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7dc5068..5556d10 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -358,6 +358,7 @@ 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 #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2150,6 +2151,7 @@ msgstr "" msgid "No email" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,68 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +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 "Personal data" +msgstr "Personal data" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Individual fields" +msgstr "Individual fields" diff --git a/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs b/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs new file mode 100644 index 0000000..225c05e --- /dev/null +++ b/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs @@ -0,0 +1,27 @@ +defmodule Mv.Repo.Migrations.AddJoinFormSettingsToSettings do + @moduledoc """ + Adds join form configuration columns to the settings table. + + - join_form_enabled: whether the public /join page is active + - join_form_field_ids: ordered list of field IDs shown on the join form (JSONB array) + - join_form_field_required: map of field ID => required boolean (JSONB) + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :join_form_enabled, :boolean, default: false, null: false + add :join_form_field_ids, {:array, :string} + add :join_form_field_required, :map + end + end + + def down do + alter table(:settings) do + remove :join_form_enabled + remove :join_form_field_ids + remove :join_form_field_required + end + end +end diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs index 9b15ca4..bcafe9f 100644 --- a/test/membership/setting_join_form_test.exs +++ b/test/membership/setting_join_form_test.exs @@ -26,8 +26,17 @@ defmodule Mv.Membership.SettingJoinFormTest do on_exit(fn -> {:ok, s} = Membership.get_settings() attrs = %{} - attrs = if saved_enabled != nil, do: Map.put(attrs, :join_form_enabled, saved_enabled), else: attrs - attrs = if saved_ids != nil, do: Map.put(attrs, :join_form_field_ids, saved_ids || []), else: attrs + + attrs = + if saved_enabled != nil, + do: Map.put(attrs, :join_form_enabled, saved_enabled), + else: attrs + + attrs = + if saved_ids != nil, + do: Map.put(attrs, :join_form_field_ids, saved_ids || []), + else: attrs + attrs = if saved_required != nil, do: Map.put(attrs, :join_form_field_required, saved_required || %{}), @@ -57,6 +66,7 @@ defmodule Mv.Membership.SettingJoinFormTest do describe "join form settings persistence and loading" do test "save and load join_form_enabled plus field selection and required flags returns same config" do {:ok, settings} = Membership.get_settings() + attrs = %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], @@ -78,17 +88,20 @@ defmodule Mv.Membership.SettingJoinFormTest do test "repeated save with changed field list overwrites config without leftovers" do {:ok, settings} = Membership.get_settings() - assert {:ok, _} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "first_name"], - join_form_field_required: %{"email" => true, "first_name" => false} - }) - assert {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "last_name"], - join_form_field_required: %{"email" => true, "last_name" => false} - }) + assert {:ok, _} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + assert {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "last_name"], + join_form_field_required: %{"email" => true, "last_name" => false} + }) assert updated.join_form_field_ids == ["email", "last_name"] assert Map.has_key?(updated.join_form_field_required, "last_name") @@ -102,11 +115,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "only existing member fields or custom field ids are accepted; unknown field names rejected or sanitized" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "not_a_member_field"], - join_form_field_required: %{"email" => true, "not_a_member_field" => false} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "not_a_member_field"], + join_form_field_required: %{"email" => true, "not_a_member_field" => false} + }) # Until attributes exist we get NoSuchInput; once implemented we expect validation error assert {:error, _} = result @@ -115,16 +129,18 @@ defmodule Mv.Membership.SettingJoinFormTest do test "config without email is rejected or email is auto-added and required" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["first_name", "last_name"], - join_form_field_required: %{"first_name" => true, "last_name" => false} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["first_name", "last_name"], + join_form_field_required: %{"first_name" => true, "last_name" => false} + }) # Either rejected or, when loaded, email must be present and required case result do {:error, _} -> :ok + {:ok, updated} -> assert "email" in updated.join_form_field_ids assert updated.join_form_field_required["email"] == true @@ -134,11 +150,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "required false for email is ignored or forced to true when saved" do {:ok, settings} = Membership.get_settings() - {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "first_name"], - join_form_field_required: %{"email" => false, "first_name" => false} - }) + {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => false, "first_name" => false} + }) assert updated.join_form_field_required["email"] == true end @@ -146,15 +163,17 @@ defmodule Mv.Membership.SettingJoinFormTest do test "required flag for field not in join_form_field_ids is rejected or dropped" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email"], - join_form_field_required: %{"email" => true, "first_name" => true} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email"], + join_form_field_required: %{"email" => true, "first_name" => true} + }) case result do {:error, _} -> :ok + {:ok, updated} -> refute Map.has_key?(updated.join_form_field_required, "first_name") end @@ -166,6 +185,7 @@ defmodule Mv.Membership.SettingJoinFormTest do describe "join form allowlist" do test "allowlist returns configured fields with required/optional when join form enabled" do {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], @@ -175,8 +195,8 @@ defmodule Mv.Membership.SettingJoinFormTest do allowlist = Membership.get_join_form_allowlist() assert length(allowlist) == 2 - email_entry = Enum.find(allowlist, &( &1.id == "email" )) - first_name_entry = Enum.find(allowlist, &( &1.id == "first_name" )) + email_entry = Enum.find(allowlist, &(&1.id == "email")) + first_name_entry = Enum.find(allowlist, &(&1.id == "first_name")) assert email_entry.required == true assert first_name_entry.required == false assert email_entry.type == :member_field @@ -185,6 +205,7 @@ defmodule Mv.Membership.SettingJoinFormTest do test "allowlist returns empty or defined default when join form disabled" do {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ join_form_enabled: false, join_form_field_ids: ["email", "first_name"], @@ -200,11 +221,13 @@ defmodule Mv.Membership.SettingJoinFormTest do test "allowlist distinguishes member fields and custom field identifiers" do {:ok, settings} = Membership.get_settings() actor = SystemActor.get_system_actor() + {:ok, cf} = Membership.create_custom_field( %{name: "join_cf_#{System.unique_integer([:positive])}", value_type: :string}, actor: actor ) + update_join_form_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", cf.id], @@ -213,8 +236,8 @@ defmodule Mv.Membership.SettingJoinFormTest do allowlist = Membership.get_join_form_allowlist() - email_entry = Enum.find(allowlist, &( &1.id == "email" )) - cf_entry = Enum.find(allowlist, &( &1.id == cf.id )) + email_entry = Enum.find(allowlist, &(&1.id == "email")) + cf_entry = Enum.find(allowlist, &(&1.id == cf.id)) assert email_entry.type == :member_field assert cf_entry.type == :custom_field end @@ -252,11 +275,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "invalid or unexpected payload structure yields clean error or ignores unknown keys" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: "not_a_list", - join_form_field_required: %{} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: "not_a_list", + join_form_field_required: %{} + }) assert match?({:error, _}, result) or (match?({:ok, _}, result) && elem(result, 1).join_form_field_ids != "not_a_list") @@ -267,11 +291,12 @@ defmodule Mv.Membership.SettingJoinFormTest do all_member = Constants.member_fields() |> Enum.map(&to_string/1) required_map = Map.new(all_member, fn f -> {f, f == "email"} end) - assert {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: all_member, - join_form_field_required: required_map - }) + assert {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: all_member, + join_form_field_required: required_map + }) assert length(updated.join_form_field_ids) == length(all_member) {:ok, reloaded} = Membership.get_settings() From 05e2a298febf5396ec53a490876fdb7730b7e1dc Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 15:40:28 +0100 Subject: [PATCH 189/237] feat: add accessible drag&drop table component --- assets/css/app.css | 31 +++++ assets/js/app.js | 136 +++++++++++++++++++++ assets/vendor/sortable.js | 2 + lib/mv_web/components/core_components.ex | 118 ++++++++++++++++++ lib/mv_web/live/global_settings_live.ex | 54 +++++++- priv/gettext/de/LC_MESSAGES/default.po | 18 ++- priv/gettext/default.pot | 20 +++ priv/gettext/en/LC_MESSAGES/default.po | 18 ++- test/membership/setting_join_form_test.exs | 4 +- 9 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 assets/vendor/sortable.js diff --git a/assets/css/app.css b/assets/css/app.css index 4b28fb7..28ea24b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -656,3 +656,34 @@ } /* This file is for your main application CSS */ + +/* ============================================ + SortableList: drag-and-drop table rows + ============================================ */ + +/* Ghost row: placeholder showing where the dragged item will be dropped. + Background fills the gap; text invisible so layout matches original row. */ +.sortable-ghost { + background-color: var(--color-base-300) !important; + opacity: 0.5; +} +.sortable-ghost td { + border-color: transparent !important; +} + +/* Chosen row: the row being actively dragged (follows the cursor). */ +.sortable-chosen { + background-color: var(--color-base-200); + box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18); + cursor: grabbing !important; +} + +/* Drag handle button: only grab cursor, no hover effect for mouse users. + Keyboard outline is handled via JS outline style. */ +[data-sortable-handle] button { + cursor: grab; +} +[data-sortable-handle] button:hover { + background-color: transparent !important; + color: inherit; +} diff --git a/assets/js/app.js b/assets/js/app.js index b7d1a45..3c4e0f9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,6 +21,7 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -120,6 +121,141 @@ Hooks.TabListKeydown = { } } +// SortableList hook: Accessible reorderable table/list. +// Mouse drag: SortableJS (smooth animation, ghost row, items push apart). +// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern. +// Container must have data-reorder-event and data-list-id. +// Each row (tr) must have data-row-index; locked rows have data-locked="true". +// Pushes event with { from_index, to_index } (both integers) on reorder. +Hooks.SortableList = { + mounted() { + this.reorderEvent = this.el.dataset.reorderEvent + this.listId = this.el.dataset.listId + // Keyboard state: store grabbed row id so it survives LiveView re-renders + this.grabbedRowId = null + + this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null + const announce = (msg) => { + if (!this.announcementEl) return + // Clear then re-set to force screen reader re-read + this.announcementEl.textContent = "" + setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50) + } + + const tbody = this.el.querySelector("tbody") + if (!tbody) return + + this.getRows = () => Array.from(tbody.querySelectorAll("tr")) + this.getRowIndex = (tr) => { + const idx = tr.getAttribute("data-row-index") + return idx != null ? parseInt(idx, 10) : -1 + } + this.isLocked = (tr) => tr.getAttribute("data-locked") === "true" + + // SortableJS for mouse drag-and-drop with animation + this.sortable = new Sortable(tbody, { + animation: 150, + handle: "[data-sortable-handle]", + // Disable sorting for locked rows (first row = email) + filter: "[data-locked='true']", + preventOnFilter: true, + // Ghost (placeholder showing where the item will land) + ghostClass: "sortable-ghost", + // The item being dragged + chosenClass: "sortable-chosen", + // Cursor while dragging + dragClass: "sortable-drag", + // Don't trigger on handle area clicks (only actual drag) + delay: 0, + onEnd: (e) => { + if (e.oldIndex === e.newIndex) return + this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex }) + announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`) + // LiveView will reconcile the DOM order after re-render + } + }) + + // Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel) + this.handleKeyDown = (e) => { + // Don't intercept Space on interactive elements (checkboxes, buttons, inputs) + const tag = e.target.tagName + if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return + + const tr = e.target.closest("tr") + if (!tr || this.isLocked(tr)) return + const rows = this.getRows() + const idx = this.getRowIndex(tr) + if (idx < 0) return + const total = rows.length + + if (e.key === " ") { + e.preventDefault() + const rowId = tr.id + if (this.grabbedRowId === rowId) { + // Drop + this.grabbedRowId = null + tr.style.outline = "" + announce(`Dropped. Position ${idx + 1} of ${total}.`) + } else { + // Grab + this.grabbedRowId = rowId + tr.style.outline = "2px solid var(--color-primary)" + tr.style.outlineOffset = "-2px" + announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`) + } + return + } + + if (e.key === "Escape") { + if (this.grabbedRowId != null) { + e.preventDefault() + const grabbedTr = document.getElementById(this.grabbedRowId) + if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" } + this.grabbedRowId = null + announce("Reorder cancelled.") + } + return + } + + if (this.grabbedRowId == null) return + + if (e.key === "ArrowUp" && idx > 0) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 }) + announce(`Position ${idx} of ${total}.`) + } else if (e.key === "ArrowDown" && idx < total - 1) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 }) + announce(`Position ${idx + 2} of ${total}.`) + } + } + + this.el.addEventListener("keydown", this.handleKeyDown, true) + }, + + updated() { + // Re-apply keyboard outline and restore focus after LiveView re-render. + // LiveView DOM patching loses focus; without explicit re-focus the next keypress + // goes to document.body (Space scrolls the page instead of triggering our handler). + if (this.grabbedRowId) { + const tr = document.getElementById(this.grabbedRowId) + if (tr) { + tr.style.outline = "2px solid var(--color-primary)" + tr.style.outlineOffset = "-2px" + tr.focus() + } else { + // Row no longer exists (removed while grabbed), clear state + this.grabbedRowId = null + } + } + }, + + destroyed() { + if (this.sortable) this.sortable.destroy() + this.el.removeEventListener("keydown", this.handleKeyDown, true) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/assets/vendor/sortable.js b/assets/vendor/sortable.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/assets/vendor/sortable.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY "join-field-\#{f.id}" end} + locked_ids={["join-field-email"]} + reorder_event="reorder_join_form_field" + > + <:col :let={field} label={gettext("Field")} class="min-w-[14rem]">{field.label} + <:col :let={field} label={gettext("Required")}>... + <:action :let={field}><.button>Remove + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, required: true + attr :locked_ids, :list, default: [] + attr :reorder_event, :string, required: true + attr :row_item, :any, default: &Function.identity/1 + + slot :col, required: true do + attr :label, :string, required: true + attr :class, :string + end + + slot :action + + def sortable_table(assigns) do + assigns = assign(assigns, :locked_set, MapSet.new(assigns.locked_ids)) + + ~H""" +
+ + + + + + + + + + + + + + + + +
{col[:label]}{gettext("Actions")}
+ + + Enum.join(" ")} + > + {render_slot(col, @row_item.(row))} + + <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + @doc """ Renders a data list. diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 651afc0..3c75fa8 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -160,7 +160,10 @@ defmodule MvWeb.GlobalSettingsLive do 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)} + 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)} > @@ -190,7 +193,15 @@ defmodule MvWeb.GlobalSettingsLive do {field.label}
-
+
{gettext("Individual fields")}
@@ -213,12 +224,13 @@ defmodule MvWeb.GlobalSettingsLive do {gettext("No fields selected. Add at least the email field.")}

- <%!-- Fields table (compact width) --%> + <%!-- Fields table (compact width, reorderable) --%>
- <.table + <.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} @@ -250,7 +262,10 @@ defmodule MvWeb.GlobalSettingsLive do - + +

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

@@ -642,6 +657,7 @@ defmodule MvWeb.GlobalSettingsLive do custom_fields = socket.assigns.join_form_custom_fields new_fields = Enum.reject(current, &(&1.id == field_id)) new_ids = Enum.map(new_fields, & &1.id) + %{member_fields: new_member, custom_fields: new_custom} = build_available_join_form_fields(new_ids, custom_fields) @@ -670,6 +686,27 @@ defmodule MvWeb.GlobalSettingsLive do {:noreply, socket} end + @impl true + def handle_event( + "reorder_join_form_field", + %{"from_index" => from_idx, "to_index" => to_idx}, + socket + ) + when is_integer(from_idx) and is_integer(to_idx) do + fields = socket.assigns.join_form_fields + new_fields = reorder_list(fields, from_idx, to_idx) + + socket = + socket + |> assign(:join_form_fields, new_fields) + |> persist_join_form_settings() + + {:noreply, socket} + end + + # Ignore malformed reorder events (e.g. nil indices from aborted drags) + def handle_event("reorder_join_form_field", _params, socket), do: {:noreply, socket} + defp persist_join_form_settings(socket) do settings = socket.assigns.settings field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id) @@ -990,6 +1027,7 @@ defmodule MvWeb.GlobalSettingsLive do required_config = settings.join_form_field_required || %{} join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields) + %{member_fields: member_avail, custom_fields: custom_avail} = build_available_join_form_fields(field_ids, custom_fields) @@ -1054,6 +1092,12 @@ defmodule MvWeb.GlobalSettingsLive do defp toggle_required_if_matches(field, _field_id), do: field + defp reorder_list(list, from_index, to_index) do + item = Enum.at(list, from_index) + rest = List.delete_at(list, from_index) + List.insert_at(rest, to_index, item) + end + defp member_field_id_strings do Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 01c49f6..90bedc5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3384,10 +3384,20 @@ msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Personal data" -msgstr "Persönliche Daten" +msgid "Individual fields" +msgstr "Individuelle Felder" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Individual fields" -msgstr "Individuelle Felder" +msgid "Personal data" +msgstr "Persönliche Daten" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "Umordnen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b5ab449..70bf233 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3381,3 +3381,23 @@ msgstr "" #, 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" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Personal data" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 5556d10..02160f9 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3384,10 +3384,20 @@ msgstr "Board approval required (in development)" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Personal data" -msgstr "Personal data" +msgid "Individual fields" +msgstr "Individual fields" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Individual fields" -msgstr "Individual fields" +msgid "Personal data" +msgstr "Personal data" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "Reorder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "The order of rows determines the field order in the join form." diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs index bcafe9f..26b5f33 100644 --- a/test/membership/setting_join_form_test.exs +++ b/test/membership/setting_join_form_test.exs @@ -13,9 +13,9 @@ defmodule Mv.Membership.SettingJoinFormTest do """ use Mv.DataCase, async: false - alias Mv.Membership - alias Mv.Helpers.SystemActor alias Mv.Constants + alias Mv.Helpers.SystemActor + alias Mv.Membership setup do {:ok, settings} = Membership.get_settings() From 21812542ad121039779faaf5665a395cedf1c97a Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 16:47:38 +0100 Subject: [PATCH 190/237] refactor: address review comments for join request settings --- CODE_GUIDELINES.md | 6 ++++++ assets/js/app.js | 19 +++++++++++++------ lib/membership/setting.ex | 13 ++++++------- .../changes/normalize_join_form_settings.ex | 2 +- lib/mv_web/components/core_components.ex | 2 +- test/membership/setting_join_form_test.exs | 2 ++ 6 files changed, 29 insertions(+), 15 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index ed9f130..47f589d 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1140,6 +1140,12 @@ let liveSocket = new LiveSocket("/live", Socket, { }) ``` +**Vendor assets (third-party JS):** + +Some JavaScript libraries are committed as vendored files in `assets/vendor/` (e.g. `topbar`, `sortable.js`) when they are not available as npm packages or we need a specific build. Document their origin and how to update them: + +- **Sortable.js** (`assets/vendor/sortable.js`): From [SortableJS](https://github.com/SortableJS/Sortable), version noted in the file header (e.g. `/*! Sortable 1.15.6 - MIT ... */`). To update: download the desired release from the repo and replace the file; keep the header comment for traceability. + ### 3.8 Code Quality: Credo **Static Code Analysis:** diff --git a/assets/js/app.js b/assets/js/app.js index 3c4e0f9..ee423eb 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -219,14 +219,21 @@ Hooks.SortableList = { if (this.grabbedRowId == null) return + // Do not move into a locked row (e.g. email always first) if (e.key === "ArrowUp" && idx > 0) { - e.preventDefault() - this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 }) - announce(`Position ${idx} of ${total}.`) + const targetRow = rows[idx - 1] + if (!this.isLocked(targetRow)) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 }) + announce(`Position ${idx} of ${total}.`) + } } else if (e.key === "ArrowDown" && idx < total - 1) { - e.preventDefault() - this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 }) - announce(`Position ${idx + 2} of ${total}.`) + const targetRow = rows[idx + 1] + if (!this.isLocked(targetRow)) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 }) + announce(`Position ${idx + 2} of ${total}.`) + } } } diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index adf05b9..bc2b1e7 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -60,6 +60,10 @@ defmodule Mv.Membership.Setting do domain: Mv.Membership, data_layer: AshPostgres.DataLayer + # Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation) + @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + @valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + postgres do table "settings" repo Mv.Repo @@ -255,16 +259,10 @@ defmodule Mv.Membership.Setting do field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids) if is_list(field_ids) and field_ids != [] do - valid_member_fields = - Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) - - uuid_pattern = - ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - invalid_ids = Enum.reject(field_ids, fn id -> is_binary(id) and - (id in valid_member_fields or Regex.match?(uuid_pattern, id)) + (id in @valid_join_form_member_fields or Regex.match?(@uuid_pattern, id)) end) if Enum.empty?(invalid_ids) do @@ -442,6 +440,7 @@ defmodule Mv.Membership.Setting do attribute :join_form_field_ids, {:array, :string} do allow_nil? true + default [] public? true description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization." diff --git a/lib/membership/setting/changes/normalize_join_form_settings.ex b/lib/membership/setting/changes/normalize_join_form_settings.ex index d21434a..217ceb2 100644 --- a/lib/membership/setting/changes/normalize_join_form_settings.ex +++ b/lib/membership/setting/changes/normalize_join_form_settings.ex @@ -54,7 +54,7 @@ defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do base = if is_map(required_config), do: required_config, else: %{} base - |> Map.filter(fn {key, _} -> key in field_ids end) + |> Map.take(field_ids) |> Map.put("email", true) end end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a2ac7f9..11a60ef 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -990,7 +990,7 @@ defmodule MvWeb.CoreComponents do /> - {gettext("Actions")} + {gettext("Actions")} diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs index 26b5f33..a9cf599 100644 --- a/test/membership/setting_join_form_test.exs +++ b/test/membership/setting_join_form_test.exs @@ -11,6 +11,8 @@ defmodule Mv.Membership.SettingJoinFormTest do - `Mv.Membership.get_join_form_allowlist/0` is implemented and returns the allowlist for the public join form (subtask 4). """ + # Settings is a singleton; tests mutate shared DB state. We use async: false and on_exit to restore + # original values because Ecto Sandbox transaction rollback does not apply to this singleton pattern. use Mv.DataCase, async: false alias Mv.Constants From eadf90b5fc9e8a8bbc862a47af1ed08338bfc175 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 17:18:14 +0100 Subject: [PATCH 191/237] test: add tests for join request page --- docs/page-permission-route-coverage.md | 4 +- test/membership/join_request_test.exs | 26 ++++ .../join_confirm_controller_test.exs | 11 +- test/mv_web/live/join_live_test.exs | 130 ++++++++++++++++++ .../plugs/check_page_permission_test.exs | 6 + 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 test/mv_web/live/join_live_test.exs diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index f91ee0c..38625e6 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -36,7 +36,9 @@ This document lists all protected routes, which permission set may access them, ## Public Paths (no permission check) -- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale` +- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`** + +The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`. The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration). diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index f40c9ec..6c39d4e 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -121,6 +121,32 @@ defmodule Mv.Membership.JoinRequestTest do 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. + {:ok, settings} = Membership.get_settings() + Mv.Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + attrs = %{ + email: "allowlist#{System.unique_integer([:positive])}@example.com", + first_name: "Allowed", + confirmation_token: "tok-#{System.unique_integer([:positive])}", + form_data: %{"city" => "Berlin", "internal_or_secret" => "must not persist"}, + schema_version: 1 + } + + assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil) + assert request.email == attrs.email + assert request.first_name == attrs.first_name + refute Map.has_key?(request.form_data || %{}, "internal_or_secret") + assert (request.form_data || %{})["city"] == "Berlin" + end + end + defp error_message(errors, field) do errors |> Enum.filter(fn err -> Map.get(err, :field) == field end) diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs index d1e9117..a85cde5 100644 --- a/test/mv_web/controllers/join_confirm_controller_test.exs +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -60,13 +60,18 @@ defmodule MvWeb.JoinConfirmControllerTest do end @tag role: :unauthenticated - test "expired token returns 200 with expired message", %{conn: conn} do + test "expired token returns 200 with expired message and instructs to submit form again", %{ + conn: conn + } do Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub) conn = get(conn, "/confirm_join/expired-token") + body = response(conn, 200) - assert response(conn, 200) =~ "expired" - assert response(conn, 200) =~ "submit" + assert body =~ "expired" + assert body =~ "submit" + # Concept §2.5: clear message + "submit form again" + assert body =~ "form" or body =~ "again" end @tag role: :unauthenticated diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs new file mode 100644 index 0000000..cc4e5db --- /dev/null +++ b/test/mv_web/live/join_live_test.exs @@ -0,0 +1,130 @@ +defmodule MvWeb.JoinLiveTest do + @moduledoc """ + Tests for the public join page (Subtask 4: Public join page and anti-abuse). + + Covers: public path /join (unauthenticated 200), 404 when join disabled, + submit creates JoinRequest and shows success copy, honeypot prevents create, + rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member. + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import Ecto.Query + + alias Mv.Membership + alias Mv.Repo + + describe "GET /join" do + @tag role: :unauthenticated + test "unauthenticated GET /join returns 200 when join form is enabled", %{conn: conn} do + enable_join_form(true) + conn = get(conn, "/join") + assert conn.status == 200 + end + + @tag role: :unauthenticated + test "unauthenticated GET /join returns 404 when join form is disabled", %{conn: conn} do + enable_join_form(false) + conn = get(conn, "/join") + assert conn.status == 404 + end + end + + describe "submit join form" do + setup :enable_join_form_for_test + + @tag role: :unauthenticated + test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{ + conn: conn + } do + count_before = count_join_requests() + {:ok, view, _html} = live(conn, "/join") + + view + |> form("#join-form", %{ + "email" => "newuser#{System.unique_integer([:positive])}@example.com", + "first_name" => "Jane", + "last_name" => "Doe", + "honeypot" => "" + }) + |> render_submit() + + assert count_join_requests() == count_before + 1 + assert view |> element("[data-testid='join-success-message']") |> has_element?() + assert render(view) =~ "saved your details" + assert render(view) =~ "click the link" + end + + @tag role: :unauthenticated + test "submit with honeypot filled does not create JoinRequest but shows same success copy", %{ + conn: conn + } do + count_before = count_join_requests() + {:ok, view, _html} = live(conn, "/join") + + view + |> form("#join-form", %{ + "email" => "bot#{System.unique_integer([:positive])}@example.com", + "first_name" => "Bot", + "last_name" => "User", + "honeypot" => "filled-by-bot" + }) + |> render_submit() + + assert count_join_requests() == count_before + assert view |> element("[data-testid='join-success-message']") |> has_element?() + end + + @tag role: :unauthenticated + @tag :slow + test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ + conn: conn + } do + enable_join_form(true) + # Rely on test config: join rate limit low (e.g. 2 per window) + base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" + count_before = count_join_requests() + + {:ok, view, _html} = live(conn, "/join") + + # Exhaust limit with valid submits + for i <- 0..1 do + view + |> form("#join-form", %{ + "email" => "#{i}-#{base_email}", + "first_name" => "User", + "last_name" => "Test", + "honeypot" => "" + }) + |> render_submit() + end + + # Next submit should be rate limited + result = + view + |> form("#join-form", %{ + "email" => "third-#{base_email}", + "first_name" => "Third", + "last_name" => "User", + "honeypot" => "" + }) + |> render_submit() + + assert count_join_requests() == count_before + 2 + assert result =~ "rate limit" or result =~ "too many" or result =~ "429" + end + end + + defp enable_join_form(enabled) do + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled}) + end + + defp enable_join_form_for_test(_context) do + enable_join_form(true) + :ok + end + + defp count_join_requests do + Repo.one(from j in "join_requests", select: count(j.id)) || 0 + end +end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 1b3f827..80aa95e 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -204,6 +204,12 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do refute conn.halted end + + test "unauthenticated user can access /join (public join page, no redirect)" do + conn = conn_without_user("/join") |> CheckPagePermission.call([]) + + refute conn.halted + end end describe "error handling" do From f1d052620957717e6ea0716c39f4604b7ca0da89 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 18:25:17 +0100 Subject: [PATCH 192/237] feat: add join form --- assets/css/app.css | 13 ++ config/config.exs | 3 + config/test.exs | 3 + lib/mv/application.ex | 2 + lib/mv_web/components/layouts.ex | 20 +- lib/mv_web/endpoint.ex | 4 +- lib/mv_web/join_rate_limit.ex | 27 +++ lib/mv_web/live/join_live.ex | 239 ++++++++++++++++++++++ lib/mv_web/live/member_live/index.ex | 3 + lib/mv_web/plugs/check_page_permission.ex | 2 +- lib/mv_web/plugs/join_form_enabled.ex | 33 +++ lib/mv_web/router.ex | 7 + mix.exs | 3 +- mix.lock | 1 + priv/gettext/de/LC_MESSAGES/default.po | 47 +++++ priv/gettext/default.pot | 48 +++++ priv/gettext/en/LC_MESSAGES/default.po | 47 +++++ test/membership/join_request_test.exs | 1 + test/mv_web/live/join_live_test.exs | 59 +++++- 19 files changed, 547 insertions(+), 15 deletions(-) create mode 100644 lib/mv_web/join_rate_limit.ex create mode 100644 lib/mv_web/live/join_live.ex create mode 100644 lib/mv_web/plugs/join_form_enabled.ex diff --git a/assets/css/app.css b/assets/css/app.css index 28ea24b..e3c6e83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,6 +99,19 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */ +.join-form-helper { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} +.join-form-helper .join-form-helper-input { + position: absolute; + left: -9999px; +} + /* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers. Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline spacing; use inherited values so custom stylesheets can override. */ diff --git a/config/config.exs b/config/config.exs index 323f5cd..ab55f2a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -93,6 +93,9 @@ config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local # user confirmation, password reset). Override in config/runtime.exs from ENV. config :mv, :mail_from, {"Mila", "noreply@example.com"} +# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/test.exs b/config/test.exs index 864222f..84ccd70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -55,3 +55,6 @@ config :mv, :default_locale, "en" # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true + +# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 835652f..6b4a10b 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -9,6 +9,7 @@ defmodule Mv.Application do alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint + alias MvWeb.JoinRateLimit alias MvWeb.Telemetry @impl true @@ -18,6 +19,7 @@ defmodule Mv.Application do 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}, diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 79983c5..17fca11 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -82,7 +82,25 @@ defmodule MvWeb.Layouts do
<% else %> - + +
+ Mila Logo + + {@club_name} + + + + + +
{render_slot(@inner_block)} diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index d1b4247..dfa01ad 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [connect_info: [:peer_data, session: @session_options]], + longpoll: [connect_info: [:peer_data, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/mv_web/join_rate_limit.ex b/lib/mv_web/join_rate_limit.ex new file mode 100644 index 0000000..6bb1198 --- /dev/null +++ b/lib/mv_web/join_rate_limit.ex @@ -0,0 +1,27 @@ +defmodule MvWeb.JoinRateLimit do + @moduledoc """ + Rate limiting for the public join form (submit action). + + Uses Hammer with ETS backend. Key is derived from client IP so each IP + is limited independently. Config from :mv :join_rate_limit (scale_ms, limit). + """ + use Hammer, backend: :ets + + @doc """ + Checks if the given key (e.g. client IP) is within rate limit for join form submit. + + Returns: + - `:allow` - submission allowed + - `{:deny, _retry_after_ms}` - rate limit exceeded + """ + def check(key) when is_binary(key) do + config = Application.get_env(:mv, :join_rate_limit, []) + scale_ms = Keyword.get(config, :scale_ms, 60_000) + limit = Keyword.get(config, :limit, 10) + + case hit(key, scale_ms, limit) do + {:allow, _count} -> :allow + {:deny, retry_after} -> {:deny, retry_after} + end + end +end diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex new file mode 100644 index 0000000..fc7038a --- /dev/null +++ b/lib/mv_web/live/join_live.ex @@ -0,0 +1,239 @@ +defmodule MvWeb.JoinLive do + @moduledoc """ + Public join page (unauthenticated). Renders form from allowlist, handles submit + with honeypot and rate limiting; shows success copy after submit. + """ + use MvWeb, :live_view + + alias Mv.Membership + alias MvWeb.JoinRateLimit + alias MvWeb.Translations.MemberFields + + # Honeypot field name (legitimate-sounding to avoid bot detection) + @honeypot_field "website" + + @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) + + socket = + socket + |> assign(:join_fields, join_fields) + |> assign(:submitted, false) + |> assign(:rate_limit_error, nil) + |> assign(:client_ip, client_ip) + |> assign(:honeypot_field, @honeypot_field) + |> assign(:form, to_form(initial_form_params(join_fields))) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + {gettext("Become a member")} + + +

+ {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 %> + + <%= 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 + + @impl true + def handle_event("submit", params, socket) do + # Honeypot: if filled, treat as bot – show same success UI, do not create (silent failure) + honeypot_value = String.trim(params[@honeypot_field] || "") + + if honeypot_value != "" do + {:noreply, assign(socket, :submitted, true)} + else + key = "join:#{socket.assigns.client_ip}" + + case JoinRateLimit.check(key) do + :allow -> do_submit(socket, params) + {:deny, _retry_after} -> rate_limited_reply(socket, params) + end + end + end + + defp do_submit(socket, params) 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) + end + + {:error, message} -> + {:noreply, + socket + |> put_flash(:error, message) + |> assign(:form, to_form(params, as: "join"))} + end + end + + defp validation_error_reply(socket, params) do + {:noreply, + socket + |> put_flash(:error, gettext("Please check your entries. Email is required.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp rate_limited_reply(socket, params) do + {:noreply, + socket + |> assign(:rate_limit_error, gettext("Too many requests. Please try again later.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp build_join_fields_with_labels(allowlist) do + member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + Enum.map(allowlist, fn %{id: id, required: required} -> + label = + if id in member_field_strings do + MemberFields.label(String.to_existing_atom(id)) + else + gettext("Field") + end + + %{id: id, label: label, required: required} + end) + end + + defp initial_form_params(join_fields) do + join_fields + |> Enum.map(fn f -> {f.id, ""} end) + |> Map.new() + |> Map.put(@honeypot_field, "") + end + + defp input_type("email"), do: "email" + defp input_type(_), do: "text" + + defp build_submit_attrs(params, join_fields) do + allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id)) + typed = ["email", "first_name", "last_name"] + + email = String.trim(params["email"] || "") + + if email == "" do + {:error, gettext("Email is required.")} + else + attrs = %{ + email: email, + first_name: optional_param(params, "first_name"), + last_name: optional_param(params, "last_name"), + form_data: %{}, + schema_version: 1 + } + + form_data = + params + |> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) + |> Map.new() + |> Enum.map(fn {k, v} -> {k, String.trim(to_string(v))} end) + |> Map.new() + + attrs = %{attrs | form_data: form_data} + {:ok, attrs} + end + end + + defp optional_param(params, key) do + v = params[key] + if is_binary(v), do: String.trim(v), else: nil + end + + defp client_ip_from_socket(socket) do + case get_connect_info(socket, :peer_data) do + %{address: address} when is_tuple(address) -> + address |> Tuple.to_list() |> Enum.join(".") + + _ -> + "unknown" + end + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index e2e037d..c8f32a0 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -107,6 +107,9 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end + # Ensure nested module is loaded (can be missing after code reload in dev if load order changes) + Code.ensure_loaded!(FieldSelection) + # Load user field selection from session session_selection = FieldSelection.get_from_session(session) diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex index 3aa6561..ff6d47d 100644 --- a/lib/mv_web/plugs/check_page_permission.ex +++ b/lib/mv_web/plugs/check_page_permission.ex @@ -80,7 +80,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do Used by LiveView hook to skip redirect on sign-in etc. """ def public_path?(path) when is_binary(path) do - path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or + path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out", "/join"] or String.starts_with?(path, "/auth") or String.starts_with?(path, "/confirm") or String.starts_with?(path, "/password-reset") diff --git a/lib/mv_web/plugs/join_form_enabled.ex b/lib/mv_web/plugs/join_form_enabled.ex new file mode 100644 index 0000000..f6865c4 --- /dev/null +++ b/lib/mv_web/plugs/join_form_enabled.ex @@ -0,0 +1,33 @@ +defmodule MvWeb.Plugs.JoinFormEnabled do + @moduledoc """ + For GET /join: returns 404 when the join form is disabled in settings. + No-op for other paths. + """ + import Plug.Conn + + alias Mv.Membership + + def init(opts), do: opts + + def call(conn, _opts) do + if join_path?(conn), do: maybe_404(conn), else: conn + end + + defp join_path?(conn) do + conn.request_path == "/join" and conn.method == "GET" + end + + defp maybe_404(conn) do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> conn + _ -> send_404(conn) + end + end + + defp send_404(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(404, "Not Found") + |> halt() + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 3ab264f..74fcd22 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -15,6 +15,7 @@ defmodule MvWeb.Router do plug :load_from_session plug :set_locale plug MvWeb.Plugs.CheckPagePermission + plug MvWeb.Plugs.JoinFormEnabled end pipeline :api do @@ -126,6 +127,12 @@ defmodule MvWeb.Router do overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], gettext_backend: {MvWeb.Gettext, "auth"} + # Public join page (no auth required) + live_session :public_join, + on_mount: [{MvWeb.LiveUserAuth, :live_user_optional}] do + live "/join", JoinLive, :index + end + # Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission get "/confirm_join/:token", JoinConfirmController, :confirm diff --git a/mix.exs b/mix.exs index 0d1d4f1..56e7dde 100644 --- a/mix.exs +++ b/mix.exs @@ -82,7 +82,8 @@ defmodule Mv.MixProject do {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, {:nimble_csv, "~> 1.0"}, - {:imprintor, "~> 0.5.0"} + {:imprintor, "~> 0.5.0"}, + {:hammer, "~> 7.0"} ] end diff --git a/mix.lock b/mix.lock index 849dfd5..8ac995a 100644 --- a/mix.lock +++ b/mix.lock @@ -37,6 +37,7 @@ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "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"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 90bedc5..15ade4b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1792,6 +1792,7 @@ msgid "Email is invalid." msgstr "E-Mail ist ungültig." #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "E-Mail ist erforderlich." @@ -3348,6 +3349,7 @@ msgid "Could not save join form settings." msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden." #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "Feld" @@ -3401,3 +3403,48 @@ msgstr "Umordnen" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "Mitglied werden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "Bitte prüfe deine Angaben. E-Mail ist erforderlich." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "Antrag absenden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "Zu viele Anfragen. Bitte versuche es später erneut." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "Webseite" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 70bf233..913d5cc 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -514,6 +514,7 @@ msgstr "" msgid "Back to users list" msgstr "" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Select language" @@ -1793,6 +1794,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3350,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3404,48 @@ msgstr "" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website (leave empty)" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 02160f9..e91e3be 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1793,6 +1793,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3349,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3403,48 @@ msgstr "Reorder" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "The order of rows determines the field order in the join form." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website (leave empty)" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "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." +msgstr "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." diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 6c39d4e..ea509fa 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -125,6 +125,7 @@ defmodule Mv.Membership.JoinRequestTest 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. {:ok, settings} = Membership.get_settings() + Mv.Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index cc4e5db..bd133cd 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -5,6 +5,9 @@ defmodule MvWeb.JoinLiveTest do Covers: public path /join (unauthenticated 200), 404 when join disabled, submit creates JoinRequest and shows success copy, honeypot prevents create, rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member. + + 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 import Phoenix.LiveViewTest @@ -44,7 +47,7 @@ defmodule MvWeb.JoinLiveTest do "email" => "newuser#{System.unique_integer([:positive])}@example.com", "first_name" => "Jane", "last_name" => "Doe", - "honeypot" => "" + "website" => "" }) |> render_submit() @@ -66,7 +69,7 @@ defmodule MvWeb.JoinLiveTest do "email" => "bot#{System.unique_integer([:positive])}@example.com", "first_name" => "Bot", "last_name" => "User", - "honeypot" => "filled-by-bot" + "website" => "filled-by-bot" }) |> render_submit() @@ -79,38 +82,66 @@ defmodule MvWeb.JoinLiveTest do test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ conn: conn } do + # Reset rate limit state so this test is independent of others (same key in test) + try do + :ets.delete_all_objects(MvWeb.JoinRateLimit) + rescue + ArgumentError -> :ok + end + enable_join_form(true) + # Set allowlist so form has email, first_name, last_name + {:ok, settings} = Membership.get_settings() + + Membership.update_settings(settings, %{ + join_form_field_ids: ["email", "first_name", "last_name"], + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + }) + # Rely on test config: join rate limit low (e.g. 2 per window) base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" count_before = count_join_requests() + sandbox = conn.private[:ecto_sandbox] - {:ok, view, _html} = live(conn, "/join") - - # Exhaust limit with valid submits + # Exhaust limit with 2 valid submits (each needs a fresh session because form disappears after submit) for i <- 0..1 do + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + view |> form("#join-form", %{ "email" => "#{i}-#{base_email}", "first_name" => "User", "last_name" => "Test", - "honeypot" => "" + "website" => "" }) |> render_submit() end - # Next submit should be rate limited + # Next submit (new session) should be rate limited + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + result = view |> form("#join-form", %{ "email" => "third-#{base_email}", "first_name" => "Third", "last_name" => "User", - "honeypot" => "" + "website" => "" }) |> render_submit() assert count_join_requests() == count_before + 2 - assert result =~ "rate limit" or result =~ "too many" or result =~ "429" + assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429" end end @@ -120,7 +151,15 @@ defmodule MvWeb.JoinLiveTest do end defp enable_join_form_for_test(_context) do - enable_join_form(true) + {:ok, settings} = Membership.get_settings() + + {:ok, _} = + 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} + }) + :ok end From a9c61f703da6d1a4aa5801dd2d51d84eaa8f4a0b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:07 +0100 Subject: [PATCH 193/237] fix: resolve Mix.env at compile time in Vereinfacht client Mix.env() is not available in production releases. Use module attribute so it is only evaluated at compile time. --- lib/mv/vereinfacht/client.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index e7ca04c..3cbba71 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -92,10 +92,13 @@ defmodule Mv.Vereinfacht.Client do @sync_timeout_ms 5_000 + # Resolved at compile time so Mix is never called at runtime (Mix is not available in releases). + @env Mix.env() + # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). defp req_http_options do opts = [receive_timeout: @sync_timeout_ms] - if Mix.env() == :test, do: [retry: false] ++ opts, else: opts + if @env == :test, do: [retry: false] ++ opts, else: opts end defp post_and_parse_contact(url, body, api_key) do From 7686b63d7f2f57ea0f7c259de027da3cb21fcb1f Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:11 +0100 Subject: [PATCH 194/237] fix: use WCAG AA warning text class for Vereinfacht notice --- lib/mv_web/live/member_live/show/membership_fees_component.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 79ce317..370d4aa 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -128,7 +128,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% else %>
-

+

<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> {gettext("No Vereinfacht contact exists for this member.")}

From c264ce122d07f99e3af7811014fea13041f71008 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:11 +0100 Subject: [PATCH 195/237] test: remove skipped custom field slug lookup test --- test/membership/custom_field_slug_test.exs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/test/membership/custom_field_slug_test.exs b/test/membership/custom_field_slug_test.exs index aa8e649..9b3f451 100644 --- a/test/membership/custom_field_slug_test.exs +++ b/test/membership/custom_field_slug_test.exs @@ -192,21 +192,5 @@ defmodule Mv.Membership.CustomFieldSlugTest do end end - describe "slug-based lookup (future feature)" do - @tag :skip - test "can find custom field by slug", %{actor: actor} do - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Test Field", - value_type: :string - }) - |> Ash.create(actor: actor) - - # This test is for future implementation - # We might add a custom action like :by_slug - found = Ash.get!(CustomField, custom_field.slug, load: [:slug], actor: actor) - assert found.id == custom_field.id - end - end + # Slug-based lookup (e.g. CustomField by slug) is not implemented; primary read uses ID. end From b04d59e3c42eea9cf55b8ba047e94486c7a0d498 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:11 +0100 Subject: [PATCH 196/237] test: remove placeholder test for non-existent member IDs --- .../mv_web/live/group_live/show_authorization_test.exs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/mv_web/live/group_live/show_authorization_test.exs b/test/mv_web/live/group_live/show_authorization_test.exs index 31f90a9..c75e623 100644 --- a/test/mv_web/live/group_live/show_authorization_test.exs +++ b/test/mv_web/live/group_live/show_authorization_test.exs @@ -248,16 +248,6 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do match?({:error, {:live_redirect, %{to: "/groups"}}}, result) end - @tag :skip - test "non-existent member IDs are handled", %{conn: conn} do - # Future: test add_selected_members with invalid ID (would require pushing event with forged selected_member_ids) - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - assert has_element?(view, "button", "Add Member") - end - test "non-existent group IDs are handled", %{conn: conn} do # Accessing non-existent group should redirect non_existent_slug = "non-existent-group-#{System.unique_integer([:positive])}" From 137dca523aeed311517d46a00a1232866a5c199a Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:12 +0100 Subject: [PATCH 197/237] test: remove skipped linked-member full-router integration tests --- .../plugs/check_page_permission_test.exs | 85 +------------------ 1 file changed, 3 insertions(+), 82 deletions(-) diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 1b3f827..f7233a9 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -456,88 +456,9 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert conn.status == 200 end - # Full-router test: session may not preserve member_id; plug logic covered by unit test - # "own_data user with linked member can access /members/:id/edit (plug direct call)". - @tag role: :member - @tag :skip - test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{ - conn: conn, - current_user: user - } do - member = Mv.Fixtures.member_fixture() - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - {:ok, user_after_update} = - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) - |> Ash.update(actor: system_actor) - - user_with_member = - user_after_update - |> Ash.load!([:role], domain: Mv.Accounts) - |> Mv.Authorization.Actor.ensure_loaded() - |> Map.put(:member_id, member.id) - - conn = conn_with_password_user(conn, user_with_member) - - conn = get(conn, "/members/#{member.id}/edit") - assert conn.status == 200 - end - - @tag role: :member - @tag :skip - test "GET /members/:id/show/edit (linked member show edit) returns 200 when user has linked member", - %{ - conn: conn, - current_user: user - } do - member = Mv.Fixtures.member_fixture() - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - {:ok, user_after_update} = - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) - |> Ash.update(actor: system_actor) - - user_with_member = - user_after_update - |> Ash.load!([:role], domain: Mv.Accounts) - |> Mv.Authorization.Actor.ensure_loaded() - |> Map.put(:member_id, member.id) - - conn = conn_with_password_user(conn, user_with_member) - - conn = get(conn, "/members/#{member.id}/show/edit") - assert conn.status == 200 - end - - # Skipped: MemberLive.Show requires membership fee cycle data; plug allows access - # (page loads then LiveView may error). - @tag role: :member - @tag :skip - test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - member = Mv.Fixtures.member_fixture() - - user = - user - |> Ash.Changeset.for_update(:update_user, %{}) - |> Ash.Changeset.force_set_argument(:member, %{id: member.id}) - |> Ash.update(actor: system_actor) - |> case do - {:ok, u} -> Ash.load!(u, :role, domain: Mv.Accounts, actor: system_actor) - {:error, _} -> user - end - - conn = - conn - |> MvWeb.ConnCase.conn_with_password_user(user) - |> get("/members/#{member.id}") - - assert conn.status == 200 - end + # Linked-member access to /members/:id and edit routes: full-router tests are not feasible + # (session does not preserve member_id after auth). Plug behavior is covered by the unit + # tests "own_data user with linked member can access ... (plug direct call)" above. end # read_only (Vorstand/Buchhaltung): allowed /, /members, /members/:id, /groups, /groups/:slug From f43076255599dad65ace625b521168d1ff471188 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Mar 2026 18:15:12 +0100 Subject: [PATCH 198/237] test: re-enable profile avatar test for first letter of email --- test/mv_web/live/profile_navigation_test.exs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index 1edd3ad..fdea3d3 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -60,17 +60,22 @@ defmodule MvWeb.ProfileNavigationTest do assert html =~ "Profil" end - @tag :skip - # credo:disable-for-next-line Credo.Check.Design.TagTODO - # TODO: Implement user initials in navbar avatar - see issue #170 - test "shows user initials in avatar", %{conn: conn} do - # Setup: Create and login a user + test "shows first letter of email in avatar", %{conn: conn, actor: actor} do + # Current behavior: sidebar shows first letter of email (see issue #170 for full initials) user = create_test_user(%{email: "test.user@example.com"}) + admin_role = Mv.Fixtures.role_fixture("admin") + + {:ok, user} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update(actor: actor) + conn = conn_with_password_user(conn, user) {:ok, _view, html} = live(conn, "/") - # Initials from test.user@example.com - assert html =~ "TU" + assert html =~ "avatar" + assert html =~ ~r/text-sm font-semibold[^>]*>\s*T\s* Date: Tue, 10 Mar 2026 20:15:48 +0100 Subject: [PATCH 199/237] seeds: distribute fee types at create, add exit dates for 5 members --- priv/repo/seeds_dev.exs | 46 ++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs index 352299f..0186bfb 100644 --- a/priv/repo/seeds_dev.exs +++ b/priv/repo/seeds_dev.exs @@ -34,7 +34,7 @@ countries_list = |> List.replace_at(7, "Österreich") |> List.replace_at(14, "Schweiz") -# 20 members: varied names, cities, join dates; fee types by index (last 2 without fee type) +# 20 members: varied names, cities, join dates; fee types distributed over all members (round-robin) member_configs = [ %{ first_name: "Anna", @@ -218,7 +218,7 @@ member_configs = [ } ] -# Fee type index per member: 0..4 round-robin for first 18, nil for last 2 +# Fee type index per member: 0..4 round-robin for all 20 (each type used 4 times) # Cycle status: all_paid, all_unpaid, mixed (varied) cycle_statuses = [ :all_paid, @@ -240,18 +240,20 @@ cycle_statuses = [ :all_unpaid, :all_paid, :mixed, - nil + :all_paid ] +# Indices of members that get an exit date (5 distributed: 3, 7, 11, 15, 19) +exit_date_member_indices = [3, 7, 11, 15, 19] + Enum.with_index(member_configs) |> Enum.each(fn {config, index} -> email = "mitglied#{index + 1}@example.de" - fee_type_index = if index >= 18, do: nil, else: rem(index, length(all_fee_types)) - fee_type_id = if fee_type_index, do: Enum.at(all_fee_types, fee_type_index).id, else: nil + fee_type_index = rem(index, length(all_fee_types)) + fee_type_id = Enum.at(all_fee_types, fee_type_index).id cycle_status = Enum.at(cycle_statuses, index) - # Do not include membership_fee_type_id in upsert so re-runs do not overwrite - # existing assignments; set via update below only when member has none + # Set fee type at create so cycles are generated with correct interval (no interval-change conflict) base_attrs = %{ first_name: config.first_name, last_name: config.last_name, @@ -264,6 +266,11 @@ Enum.with_index(member_configs) country: Enum.at(countries_list, index) } + base_attrs = + if fee_type_id, + do: Map.put(base_attrs, :membership_fee_type_id, fee_type_id), + else: base_attrs + member = Membership.create_member!(base_attrs, upsert?: true, @@ -271,26 +278,14 @@ Enum.with_index(member_configs) actor: admin_user_with_role ) - final_member = - if is_nil(member.membership_fee_type_id) and fee_type_id do - {:ok, updated} = - Membership.update_member(member, %{membership_fee_type_id: fee_type_id}, - actor: admin_user_with_role - ) - - updated - else - member - end - - if not is_nil(final_member.membership_fee_type_id) and not is_nil(cycle_status) do + if not is_nil(member.membership_fee_type_id) and not is_nil(cycle_status) do member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) + Ash.load!(member, :membership_fee_cycles, actor: admin_user_with_role) cycles = if Enum.empty?(member_with_cycles.membership_fee_cycles) do {:ok, new_cycles, _} = - CycleGenerator.generate_cycles_for_member(final_member.id, + CycleGenerator.generate_cycles_for_member(member.id, skip_lock?: true, actor: admin_user_with_role ) @@ -330,6 +325,11 @@ Enum.with_index(member_configs) end end) end + + if index in exit_date_member_indices do + exit_date = Date.add(config.join_date, 365) + Membership.update_member(member, %{exit_date: exit_date}, actor: admin_user_with_role) + end end) # Groups (idempotent) @@ -482,7 +482,7 @@ for {email, values} <- custom_value_assignments do end IO.puts("✅ Dev seeds completed.") -IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)") +IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date") IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung") IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)") IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)") From 021b709e6a5ce3bb2c8e65eb43269cd3955f6999 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 22:54:41 +0100 Subject: [PATCH 200/237] refactor: address review comments for join view --- CODE_GUIDELINES.md | 2 +- docs/onboarding-join-concept.md | 4 +- lib/membership/join_request.ex | 3 ++ .../changes/filter_form_data_by_allowlist.ex | 38 ++++++++++++++++++ lib/mv_web/endpoint.ex | 4 +- lib/mv_web/join_rate_limit.ex | 1 + lib/mv_web/live/join_live.ex | 40 ++++++++++++++----- lib/mv_web/plugs/join_form_enabled.ex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 11 ++--- priv/gettext/default.pot | 10 ++--- priv/gettext/en/LC_MESSAGES/default.po | 11 ++--- test/membership/join_request_test.exs | 19 ++++++++- 12 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 lib/membership/join_request/changes/filter_form_data_by_allowlist.ex diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 47f589d..b789088 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -86,7 +86,7 @@ lib/ │ ├── membership.ex # Domain definition │ ├── member.ex # Member resource │ ├── join_request.ex # JoinRequest (public join form, double opt-in) -│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest) +│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest) │ ├── 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) diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 01c7ea4..680799d 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -42,7 +42,7 @@ ### 2.3 Data Flow -- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored. +- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys. - **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email. - **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent). - **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval). @@ -73,7 +73,7 @@ - **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200). - **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plug’s **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone. - **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm. -- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Verify library version and multi-node behaviour before or during implementation. +- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation. - **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention. - **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity. - **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 2519089..cf220a0 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -37,6 +37,7 @@ defmodule Mv.Membership.JoinRequest do accept [:email, :first_name, :last_name, :form_data, :schema_version] change Mv.Membership.JoinRequest.Changes.SetConfirmationToken + change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist end read :get_by_confirmation_token_hash do @@ -77,6 +78,8 @@ defmodule Mv.Membership.JoinRequest do end validations do + # Format/formatting of email is not validated here; invalid addresses may fail at send time + # or can be enforced via an Ash change if needed. validate present(:email), on: [:create] end diff --git a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex new file mode 100644 index 0000000..5de15c8 --- /dev/null +++ b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex @@ -0,0 +1,38 @@ +defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do + @moduledoc """ + Filters form_data to only keys that are in the join form allowlist (server-side). + + Ensures that even when submit_join_request/2 is called directly (e.g. from tests or API), + only allowlisted custom fields are persisted. Typed fields (email, first_name, last_name) + are not part of form_data; allowlist is join_form_field_ids minus those. + """ + use Ash.Resource.Change + + alias Mv.Membership + + @typed_fields ["email", "first_name", "last_name"] + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{} + + allowlist_ids = + case Membership.get_join_form_allowlist() do + list when is_list(list) -> + list + |> Enum.map(fn item -> item.id end) + |> MapSet.new() + |> MapSet.difference(MapSet.new(@typed_fields)) + + _ -> + MapSet.new() + end + + filtered = + form_data + |> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end) + |> Map.new() + + Ash.Changeset.force_change_attribute(changeset, :form_data, filtered) + end +end diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index dfa01ad..a591fb9 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [:peer_data, session: @session_options]], - longpoll: [connect_info: [:peer_data, session: @session_options]] + websocket: [connect_info: [:peer_data, :x_headers, session: @session_options]], + longpoll: [connect_info: [:peer_data, :x_headers, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/mv_web/join_rate_limit.ex b/lib/mv_web/join_rate_limit.ex index 6bb1198..878e1ee 100644 --- a/lib/mv_web/join_rate_limit.ex +++ b/lib/mv_web/join_rate_limit.ex @@ -15,6 +15,7 @@ defmodule MvWeb.JoinRateLimit do - `{:deny, _retry_after_ms}` - rate limit exceeded """ def check(key) when is_binary(key) do + # Read at runtime so config can be changed without restart (e.g. in tests). config = Application.get_env(:mv, :join_rate_limit, []) scale_ms = Keyword.get(config, :scale_ms, 60_000) limit = Keyword.get(config, :limit, 10) diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index fc7038a..99a7df9 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -213,9 +213,7 @@ defmodule MvWeb.JoinLive do form_data = params |> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) - |> Map.new() - |> Enum.map(fn {k, v} -> {k, String.trim(to_string(v))} end) - |> Map.new() + |> Map.new(fn {k, v} -> {k, String.trim(to_string(v))} end) attrs = %{attrs | form_data: form_data} {:ok, attrs} @@ -227,13 +225,37 @@ defmodule MvWeb.JoinLive do if is_binary(v), do: String.trim(v), else: nil end + # Prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy; fall back to peer_data. + # Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation. defp client_ip_from_socket(socket) do - case get_connect_info(socket, :peer_data) do - %{address: address} when is_tuple(address) -> - address |> Tuple.to_list() |> Enum.join(".") - - _ -> - "unknown" + with nil <- client_ip_from_headers(socket), + %{address: address} when is_tuple(address) <- get_connect_info(socket, :peer_data) do + address |> :inet.ntoa() |> to_string() + else + ip when is_binary(ip) -> ip + _ -> "unknown" end end + + defp client_ip_from_headers(socket) do + headers = get_connect_info(socket, :x_headers) || [] + real_ip = header_value(headers, "x-real-ip") + forwarded = header_value(headers, "x-forwarded-for") + + cond do + real_ip != nil -> real_ip + forwarded != nil -> String.split(forwarded, ~r/,\s*/) |> List.first() |> String.trim() + true -> nil + end + end + + defp header_value(headers, name) do + name_lower = String.downcase(name) + + headers + |> Enum.find_value(fn + {h, v} when is_binary(h) -> if String.downcase(h) == name_lower, do: String.trim(v) + _ -> nil + end) + end end diff --git a/lib/mv_web/plugs/join_form_enabled.ex b/lib/mv_web/plugs/join_form_enabled.ex index f6865c4..854a7cf 100644 --- a/lib/mv_web/plugs/join_form_enabled.ex +++ b/lib/mv_web/plugs/join_form_enabled.ex @@ -24,6 +24,7 @@ defmodule MvWeb.Plugs.JoinFormEnabled do end end + # Same body as default ErrorHTML 404 (no custom error templates in this app). defp send_404(conn) do conn |> put_resp_content_type("text/html") diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 15ade4b..96b8c07 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -513,6 +513,7 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Select language" @@ -3429,11 +3430,6 @@ msgstr "Zu viele Anfragen. Bitte versuche es später erneut." msgid "We have saved your details. To complete your request, please click the link we sent to your email." msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben." -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "Website" -msgstr "Webseite" - #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "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." @@ -3448,3 +3444,8 @@ msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an." #, elixir-autogen, elixir-format msgid "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." msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "Webseite" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 913d5cc..65197e1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3430,11 +3430,6 @@ msgstr "" msgid "We have saved your details. To complete your request, please click the link we sent to your email." msgstr "" -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "Website (leave empty)" -msgstr "" - #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "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." @@ -3449,3 +3444,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "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." msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e91e3be..4ebce69 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -514,6 +514,7 @@ msgstr "" msgid "Back to users list" msgstr "" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format, fuzzy msgid "Select language" @@ -3429,11 +3430,6 @@ msgstr "" msgid "We have saved your details. To complete your request, please click the link we sent to your email." msgstr "" -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "Website (leave empty)" -msgstr "" - #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "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." @@ -3448,3 +3444,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "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." msgstr "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." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "" diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index ea509fa..1992993 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -38,6 +38,21 @@ defmodule Mv.Membership.JoinRequestTest do end test "persists first_name, last_name and form_data when provided" do + # Allowlist must include custom fields so FilterFormDataByAllowlist persists them + {:ok, settings} = Membership.get_settings() + + Mv.Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name", "last_name", "city", "notes"], + join_form_field_required: %{ + "email" => true, + "first_name" => false, + "last_name" => false, + "city" => false, + "notes" => false + } + }) + attrs = @valid_submit_attrs |> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}") @@ -128,8 +143,8 @@ defmodule Mv.Membership.JoinRequestTest do Mv.Membership.update_settings(settings, %{ join_form_enabled: true, - join_form_field_ids: ["email", "first_name"], - join_form_field_required: %{"email" => true, "first_name" => false} + join_form_field_ids: ["email", "first_name", "city"], + join_form_field_required: %{"email" => true, "first_name" => false, "city" => false} }) attrs = %{ From 50433e607f294f3fdc22136b381a759c94c1679b Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 23:21:57 +0100 Subject: [PATCH 201/237] test: add tests for approval ui --- docs/onboarding-join-concept.md | 64 +++++++- docs/page-permission-route-coverage.md | 5 +- .../join_request_approval_domain_test.exs | 139 ++++++++++++++++++ .../join_request_approval_policy_test.exs | 115 +++++++++++++++ .../plugs/check_page_permission_test.exs | 120 ++++++++++++++- test/support/fixtures.ex | 34 +++++ 6 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 test/membership/join_request_approval_domain_test.exs create mode 100644 test/membership/join_request_approval_policy_test.exs diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 680799d..d3e8c42 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -1,6 +1,6 @@ # Onboarding & Join – High-Level Concept -**Status:** Draft for design decisions and implementation specs +**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) implemented.** **Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths. **Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage. @@ -102,10 +102,56 @@ ## 3. Step 2: Vorstand Approval - **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject. +- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification. - **Outcome of approval (admin-configurable):** - **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed. - **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented. -- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_user’s allowed pages. +- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_user’s allowed pages. + +### 3.1 Step 2 – Approval (detail) + +Implementation spec for Subtask 5. + +#### Route and pages + +- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. +- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. + +#### Backend (JoinRequest) + +- **New actions (authenticated only):** + - **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below). + - **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. +- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`. +- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data. + +#### Promotion: JoinRequest → Member + +- **When:** On successful `approve` only (status was `submitted`). +- **Mapping:** + - JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes. + - **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member. + - **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest). +- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. +- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest). + +#### Permission sets and routing + +- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list. +- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied. + +#### UI/UX (approval) + +- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table. +- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields. +- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed). + +#### Tests + +- JoinRequest: policy tests – approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only. +- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error. +- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot. +- Optional: LiveView smoke test – list loads, approve/reject from detail updates state. --- @@ -153,6 +199,7 @@ - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. - **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set. +- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail). - **Resend confirmation:** If not in Prio 1, create a separate ticket immediately. **Open for later:** @@ -180,26 +227,26 @@ ### Prio 1 – Public Join (4 subtasks) -#### 1. JoinRequest resource and public policies +#### 1. JoinRequest resource and public policies **(done)** - **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency). - **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. - **Boundary:** No UI, no emails – only resource, persistence, and actions callable with nil actor. - **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update). -#### 2. Submit and confirm flow +#### 2. Submit and confirm flow **(done)** - **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h. - **Boundary:** No join-form UI, no admin settings – only backend create/update and email/route. - **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases. -#### 3. Admin: Join form settings +#### 3. Admin: Join form settings **(done)** - **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed). - **Boundary:** No public form – only save/load of config and **server-side allowlist** for use in subtask 4. - **Done:** Settings save/load; allowlist available in backend for join form; tests. -#### 4. Public join page and anti-abuse +#### 4. Public join page and anti-abuse **(done)** - **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`. - **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2). @@ -215,7 +262,8 @@ #### 5. Approval UI (Vorstand) -- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add page to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). +- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1. +- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency). - **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation. --- @@ -223,7 +271,7 @@ ## 9. References - `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions. -- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests. +- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). - `lib/mv_web/plugs/check_page_permission.ex` – Public path list; **add `/join`** in `public_path?/1`. - `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` – Existing confirmation-email pattern (token, link, Mailer). - Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) – Rate limiting for Phoenix/Plug. diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index 38625e6..6571a39 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -31,8 +31,10 @@ This document lists all protected routes, which permission set may access them, | `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ | +| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ | +| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ | -**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. +**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks). ## Public Paths (no permission check) @@ -55,6 +57,7 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c - Unauthenticated: nil user denied, redirect `/sign-in`. - Public: unauthenticated allowed `/auth/sign-in`, `/register`. - Error: no role, invalid permission_set_name → denied. +- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added. ### Integration tests (full router, Mitglied = own_data) diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs new file mode 100644 index 0000000..9578fea --- /dev/null +++ b/test/membership/join_request_approval_domain_test.exs @@ -0,0 +1,139 @@ +defmodule Mv.Membership.JoinRequestApprovalDomainTest do + @moduledoc """ + Domain tests for JoinRequest approval: approve/reject and promotion to Member (Step 2). + + Asserts that approve creates one Member with mapped data, reject does not create Member, + status rules, and idempotency. No User creation in MVP. + """ + use Mv.DataCase, async: true + + import Ash.Expr + require Ash.Query + + alias Mv.Fixtures + alias Mv.Helpers.SystemActor + alias Mv.Membership + alias Mv.Membership.Member + + defp member_count do + actor = SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: actor) + length(members) + end + + describe "approve_join_request/2 – promotion to Member" do + test "approve creates exactly one member with email, first_name, last_name from JoinRequest" do + request = + Fixtures.submitted_join_request_fixture(%{ + first_name: "Approved", + last_name: "User" + }) + + count_before = member_count() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + + assert member_count() == count_before + 1 + + request_email = request.email + [member] = + Member + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership) + + assert member.email == request.email + assert member.first_name == request.first_name + assert member.last_name == request.last_name + end + + test "approve does not create a User (MVP)" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + + # No User should exist with this email from the approval flow + request_email = request.email + users_with_email = + Mv.Accounts.User + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(authorize?: false) + + assert users_with_email == [] + end + end + + describe "reject_join_request/2" do + test "reject does not create a member" do + request = Fixtures.submitted_join_request_fixture() + count_before = member_count() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + assert rejected.rejected_at != nil + + assert member_count() == count_before + end + end + + describe "approve_join_request/2 – status and idempotency" do + test "approve when status is already approved is idempotent or returns error" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + count_after_first = member_count() + + # Second approve: either {:ok, request} with no duplicate member, or {:error, _} + result = Membership.approve_join_request(request.id, actor: user) + + if match?({:ok, _}, result) do + assert member_count() == count_after_first + else + assert {:error, _} = result + end + end + + test "approve when status is pending_confirmation returns error" do + token = "pending-token-#{System.unique_integer([:positive])}" + attrs = %{ + email: "pending#{System.unique_integer([:positive])}@example.com", + confirmation_token: token + } + {:ok, request} = Membership.submit_join_request(attrs, actor: nil) + assert request.status == :pending_confirmation + + user = Fixtures.user_with_role_fixture("normal_user") + assert {:error, _} = Membership.approve_join_request(request.id, actor: user) + end + + test "approve when status is rejected returns error" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, _} = Membership.reject_join_request(request.id, actor: user) + + assert {:error, _} = Membership.approve_join_request(request.id, actor: user) + end + end + + describe "approve_join_request/2 – defaults" do + test "created member has join_date and membership_fee_type when not in form_data" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + + request_email = request.email + [member] = + Member + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership) + + assert member.join_date != nil + assert member.membership_fee_type_id != nil + end + end +end diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs new file mode 100644 index 0000000..e658435 --- /dev/null +++ b/test/membership/join_request_approval_policy_test.exs @@ -0,0 +1,115 @@ +defmodule Mv.Membership.JoinRequestApprovalPolicyTest do + @moduledoc """ + Policy tests for JoinRequest approval UI (Step 2). + + Asserts that approve/reject and list are allowed for normal_user and admin, + and forbidden for read_only, own_data, and actor: nil. + No UI; domain and resource policies only. + """ + use Mv.DataCase, async: true + + alias Mv.Fixtures + alias Mv.Membership + + describe "list_join_requests/1" do + test "normal_user can list join requests" do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, _list} = Membership.list_join_requests(actor: user) + end + + test "admin can list join requests" do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, _list} = Membership.list_join_requests(actor: user) + end + + test "read_only cannot list join requests" do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user) + end + + test "own_data cannot list join requests" do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user) + end + + test "actor nil cannot list join requests" do + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: nil) + end + end + + describe "approve_join_request/2" do + setup do + request = Fixtures.submitted_join_request_fixture() + %{request: request} + end + + test "normal_user can approve a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + assert approved.approved_at != nil + assert approved.reviewed_by_user_id == user.id + end + + test "admin can approve a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + end + + test "read_only cannot approve", %{request: request} do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: user) + end + + test "own_data cannot approve", %{request: request} do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: user) + end + + test "actor nil cannot approve", %{request: request} do + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: nil) + end + end + + describe "reject_join_request/2" do + setup do + request = Fixtures.submitted_join_request_fixture() + %{request: request} + end + + test "normal_user can reject a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + assert rejected.rejected_at != nil + assert rejected.reviewed_by_user_id == user.id + end + + test "admin can reject a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + end + + test "read_only cannot reject", %{request: request} do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: user) + end + + test "own_data cannot reject", %{request: request} do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: user) + end + + test "actor nil cannot reject", %{request: request} do + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: nil) + end + end +end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 80aa95e..31922b0 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -212,6 +212,72 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end end + describe "join_requests routes (approval UI, Step 2)" do + test "normal_user can access /join_requests" do + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "normal_user can access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "read_only cannot access /join_requests" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "read_only cannot access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "own_data cannot access /join_requests" do + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "own_data cannot access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "admin can access /join_requests" do + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "admin can access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + end + describe "error handling" do test "user with no role is denied" do user = Fixtures.user_with_role_fixture("admin") @@ -429,6 +495,22 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/admin/roles/#{id}/edit") assert redirected_to(conn) == "/users/#{user.id}" end + + @tag role: :member + test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/join_requests") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /join_requests/:id redirects to user profile", %{ + conn: conn, + current_user: user + } do + request = Fixtures.submitted_join_request_fixture() + conn = get(conn, "/join_requests/#{request.id}") + assert redirected_to(conn) == "/users/#{user.id}" + end end describe "integration: Mitglied (own_data) can access allowed paths via full router" do @@ -713,15 +795,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/admin/roles/#{id}") assert redirected_to(conn) == "/users/#{user.id}" end + + @tag role: :read_only + test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/join_requests") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /join_requests/:id redirects to user profile", %{ + conn: conn, + current_user: user + } do + request = Fixtures.submitted_join_request_fixture() + conn = get(conn, "/join_requests/#{request.id}") + assert redirected_to(conn) == "/users/#{user.id}" + end end - # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug + # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests describe "integration: normal_user (Kassenwart) allowed paths via full router" do setup %{conn: conn, current_user: current_user} do member = Mv.Fixtures.member_fixture() group = Mv.Fixtures.group_fixture() + join_request = Fixtures.submitted_join_request_fixture() - {:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug} + {:ok, + conn: conn, + current_user: current_user, + member_id: member.id, + group_slug: group.slug, + join_request_id: join_request.id} end @tag role: :normal_user @@ -804,6 +908,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/users/#{user.id}/show/edit") assert conn.status == 200 end + + @tag role: :normal_user + test "GET /join_requests returns 200", %{conn: conn} do + conn = get(conn, "/join_requests") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /join_requests/:id returns 200", %{conn: conn, join_request_id: id} do + conn = get(conn, "/join_requests/#{id}") + assert conn.status == 200 + end end describe "integration: normal_user denied paths via full router" do diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index d7cddda..56347c9 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -299,4 +299,38 @@ defmodule Mv.Fixtures do {:error, error} -> raise "Failed to create group: #{inspect(error)}" end end + + @doc """ + Creates a join request in status :submitted (for approval UI tests). + + Uses the public flow: submit_join_request then confirm_join_request with a known token. + Returns the JoinRequest struct so tests can use its id for approve/reject. + + ## Parameters + - `attrs` - Optional map: :email, :first_name, :last_name, :form_data, :schema_version. + Defaults: unique email; confirmation_token is generated and used internally. + + ## Returns + - JoinRequest struct with status :submitted + + ## Examples + + iex> request = submitted_join_request_fixture() + iex> request.status + :submitted + + iex> request = submitted_join_request_fixture(%{first_name: "Jane", last_name: "Doe"}) + """ + def submitted_join_request_fixture(attrs \\ %{}) do + token = "fixture-token-#{System.unique_integer([:positive])}" + base = %{ + email: "join#{System.unique_integer([:positive])}@example.com", + confirmation_token: token + } + attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token) + + {:ok, _} = Membership.submit_join_request(attrs, actor: nil) + {:ok, request} = Membership.confirm_join_request(token, actor: nil) + request + end end From 86d9242d83a0f5a766d8ae0c8c7f66301875713f Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 11 Mar 2026 02:04:03 +0100 Subject: [PATCH 202/237] feat: add approval ui for join requests --- lib/membership/join_request.ex | 61 +++- .../join_request/changes/approve_request.ex | 37 ++ .../join_request/changes/reject_request.ex | 36 ++ .../changes/set_submitted_for_seeding.ex | 15 + lib/membership/membership.ex | 201 ++++++++++- .../checks/has_join_request_access.ex | 32 ++ lib/mv/authorization/permission_sets.ex | 14 +- lib/mv_web/components/layouts.ex | 33 +- lib/mv_web/components/layouts/sidebar.ex | 33 +- lib/mv_web/helpers/date_formatter.ex | 19 ++ lib/mv_web/live/join_request_live/index.ex | 200 +++++++++++ lib/mv_web/live/join_request_live/show.ex | 320 ++++++++++++++++++ lib/mv_web/page_paths.ex | 4 + lib/mv_web/router.ex | 4 + priv/gettext/de/LC_MESSAGES/default.po | 178 ++++++++++ priv/gettext/default.pot | 178 ++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 178 ++++++++++ priv/repo/seeds_dev.exs | 42 +++ .../join_request_approval_domain_test.exs | 35 ++ .../join_request_approval_policy_test.exs | 4 + .../plugs/check_page_permission_test.exs | 10 +- test/support/fixtures.ex | 2 + 22 files changed, 1624 insertions(+), 12 deletions(-) create mode 100644 lib/membership/join_request/changes/approve_request.ex create mode 100644 lib/membership/join_request/changes/reject_request.ex create mode 100644 lib/membership/join_request/changes/set_submitted_for_seeding.ex create mode 100644 lib/mv/authorization/checks/has_join_request_access.ex create mode 100644 lib/mv_web/live/join_request_live/index.ex create mode 100644 lib/mv_web/live/join_request_live/show.ex diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index cf220a0..05a9e8d 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -40,6 +40,13 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist end + # Internal/seeding only: create with status submitted (no policy allows; use authorize?: false). + create :create_submitted do + description "Create a join request with status submitted (seeds, internal use only)" + accept [:email, :first_name, :last_name, :form_data, :schema_version] + change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding + end + read :get_by_confirmation_token_hash do description "Find a join request by confirmation token hash (for confirm flow only)" argument :confirmation_token_hash, :string, allow_nil?: false @@ -56,25 +63,64 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.ConfirmRequest end + + update :approve do + description "Approve a submitted join request and promote to Member" + require_atomic? false + + change Mv.Membership.JoinRequest.Changes.ApproveRequest + end + + update :reject do + description "Reject a submitted join request" + require_atomic? false + + change Mv.Membership.JoinRequest.Changes.RejectRequest + end end policies do - policy action(:submit) do + # Use :strict so unauthorized access returns Forbidden (not empty list). + # Default :filter would silently return [] for unauthorized reads instead of Forbidden. + default_access_type :strict + + # Public actions: bypass so nil actor is immediately authorized (skips all remaining policies). + # Using bypass (not policy) avoids AND-combination with the read policy below. + bypass action(:submit) do description "Allow unauthenticated submit (public join form)" authorize_if Mv.Authorization.Checks.ActorIsNil end - policy action(:get_by_confirmation_token_hash) do + bypass action(:get_by_confirmation_token_hash) do description "Allow unauthenticated lookup by token hash for confirm" authorize_if Mv.Authorization.Checks.ActorIsNil end - policy action(:confirm) do + bypass action(:confirm) do description "Allow unauthenticated confirm (confirmation link click)" authorize_if Mv.Authorization.Checks.ActorIsNil end - # Default read/destroy: no policy for actor nil → Forbidden + # READ: bypass for authorized roles (normal_user, admin). + # Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning + # expr(false), which would silently produce an empty list instead of Forbidden for + # unauthorized actors. See docs/policy-bypass-vs-haspermission.md. + # Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden). + bypass action_type(:read) do + description "Allow normal_user and admin to read join requests (SimpleCheck bypass)" + authorize_if Mv.Authorization.Checks.HasJoinRequestAccess + end + + # Approve/Reject: only actors with JoinRequest update permission + policy action(:approve) do + description "Allow authenticated users with JoinRequest update permission to approve" + authorize_if Mv.Authorization.Checks.HasPermission + end + + policy action(:reject) do + description "Allow authenticated users with JoinRequest update permission to reject" + authorize_if Mv.Authorization.Checks.HasPermission + end end validations do @@ -135,6 +181,13 @@ defmodule Mv.Membership.JoinRequest do update_timestamp :updated_at end + relationships do + belongs_to :reviewed_by_user, Mv.Accounts.User do + define_attribute? false + source_attribute :reviewed_by_user_id + end + end + # Public helpers (used by SetConfirmationToken change and domain confirm_join_request) @doc """ diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex new file mode 100644 index 0000000..aee6874 --- /dev/null +++ b/lib/membership/join_request/changes/approve_request.ex @@ -0,0 +1,37 @@ +defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do + @moduledoc """ + Sets the join request to approved and records the reviewer. + + Only transitions from :submitted status. If already approved, returns error + (idempotency guard via status validation). Promotion to Member is handled + by the domain function approve_join_request/2 after calling this action. + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, context) do + current_status = Ash.Changeset.get_data(changeset, :status) + + if current_status == :submitted do + reviewed_by_id = actor_id(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) + else + Ash.Changeset.add_error(changeset, + field: :status, + message: "can only approve a submitted join request (current status: #{current_status})" + ) + end + end + + defp actor_id(nil), do: nil + + defp actor_id(actor) when is_map(actor) do + Map.get(actor, :id) || Map.get(actor, "id") + end + + defp actor_id(_), do: nil +end diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex new file mode 100644 index 0000000..939df95 --- /dev/null +++ b/lib/membership/join_request/changes/reject_request.ex @@ -0,0 +1,36 @@ +defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do + @moduledoc """ + Sets the join request to rejected and records the reviewer. + + Only transitions from :submitted status. Returns an error for any other status. + No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set. + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, context) do + current_status = Ash.Changeset.get_data(changeset, :status) + + if current_status == :submitted do + reviewed_by_id = actor_id(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) + else + Ash.Changeset.add_error(changeset, + field: :status, + message: "can only reject a submitted join request (current status: #{current_status})" + ) + end + end + + defp actor_id(nil), do: nil + + defp actor_id(actor) when is_map(actor) do + Map.get(actor, :id) || Map.get(actor, "id") + end + + defp actor_id(_), do: nil +end diff --git a/lib/membership/join_request/changes/set_submitted_for_seeding.ex b/lib/membership/join_request/changes/set_submitted_for_seeding.ex new file mode 100644 index 0000000..c53b6d1 --- /dev/null +++ b/lib/membership/join_request/changes/set_submitted_for_seeding.ex @@ -0,0 +1,15 @@ +defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do + @moduledoc """ + Sets status to :submitted and submitted_at for seed/internal creation. + + Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors). + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + changeset + |> Ash.Changeset.force_change_attribute(:status, :submitted) + |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 3f34903..c04686b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -87,7 +87,8 @@ defmodule Mv.Membership do end resource Mv.Membership.JoinRequest do - # submit_join_request/2 implemented as custom function below (create + send email) + # Public submit/confirm and approval domain functions are implemented as custom + # functions below to handle cross-resource operations (Member promotion on approve). end end @@ -507,4 +508,202 @@ defmodule Mv.Membership do defp expired?(nil), do: true defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt + + # --------------------------------------------------------------------------- + # Step 2: Approval domain functions + # --------------------------------------------------------------------------- + + @doc """ + Lists join requests, optionally filtered by status. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + - `:status` - Optional atom to filter by status (default: `:submitted`). + Pass `:all` to return requests of all statuses. + + ## Returns + - `{:ok, list}` - List of JoinRequests + - `{:error, error}` - Authorization or query error + """ + @spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()} + def list_join_requests(opts \\ []) do + actor = Keyword.get(opts, :actor) + status = Keyword.get(opts, :status, :submitted) + + query = + if status == :all do + JoinRequest + |> Ash.Query.sort(inserted_at: :desc) + else + JoinRequest + |> Ash.Query.filter(expr(status == ^status)) + |> Ash.Query.sort(inserted_at: :desc) + end + + Ash.read(query, actor: actor, domain: __MODULE__) + end + + @doc """ + Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first. + + Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + + ## Returns + - `{:ok, list}` - List of JoinRequests (approved/rejected only) + - `{:error, error}` - Authorization or query error + """ + @spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()} + def list_join_requests_history(opts \\ []) do + actor = Keyword.get(opts, :actor) + + query = + JoinRequest + |> Ash.Query.filter(expr(status in [:approved, :rejected])) + |> Ash.Query.sort(updated_at: :desc) + |> Ash.Query.load(:reviewed_by_user) + + Ash.read(query, actor: actor, domain: __MODULE__) + end + + @doc """ + Returns the count of join requests with status `:submitted` (unprocessed). + + Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + + ## Returns + - Non-negative integer (0 on error or when unauthorized). + """ + @spec count_submitted_join_requests(keyword()) :: non_neg_integer() + def count_submitted_join_requests(opts \\ []) do + actor = Keyword.get(opts, :actor) + query = JoinRequest |> Ash.Query.filter(expr(status == :submitted)) + + case Ash.count(query, actor: actor, domain: __MODULE__) do + {:ok, count} when is_integer(count) and count >= 0 -> count + _ -> 0 + end + end + + @doc """ + Gets a single JoinRequest by id. + + ## Options + - `:actor` - Required. The actor for authorization. + + ## Returns + - `{:ok, request}` - The JoinRequest + - `{:ok, nil}` - Not found + - `{:error, error}` - Authorization or query error + """ + @spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()} + def get_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__) + end + + @doc """ + Approves a join request and promotes it to a Member. + + Finds the JoinRequest by id, calls the :approve action (which sets status to + :approved and records the reviewer), then creates a Member from the typed fields + and form_data. Idempotency: if the request is already approved, returns an error. + + ## Options + - `:actor` - Required. The reviewer (normal_user or admin). + + ## Returns + - `{:ok, approved_request}` - Approved JoinRequest + - `{:error, error}` - Status error, authorization error, or Member creation error + """ + @spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} + def approve_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + + with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__), + {:ok, approved} <- + request + |> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__) + |> Ash.update(actor: actor, domain: __MODULE__), + {:ok, _member} <- promote_to_member(approved, actor) do + {:ok, approved} + end + end + + @doc """ + Rejects a join request. + + Finds the JoinRequest by id and calls the :reject action (status → :rejected, + records reviewer). No Member is created. Returns error if not in :submitted status. + + ## Options + - `:actor` - Required. The reviewer (normal_user or admin). + + ## Returns + - `{:ok, rejected_request}` - Rejected JoinRequest + - `{:error, error}` - Status error or authorization error + """ + @spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} + def reject_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + + with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do + request + |> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__) + |> Ash.update(actor: actor, domain: __MODULE__) + end + end + + # Builds Member attrs + custom_field_values from a JoinRequest and creates the Member. + @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + @member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + defp promote_to_member(%JoinRequest{} = request, actor) do + {member_attrs, custom_field_values} = build_member_attrs(request) + + attrs = + if Enum.empty?(custom_field_values) do + member_attrs + else + Map.put(member_attrs, :custom_field_values, custom_field_values) + end + + Ash.create(Mv.Membership.Member, attrs, + action: :create_member, + actor: actor, + domain: __MODULE__ + ) + end + + defp build_member_attrs(%JoinRequest{} = request) do + # join_date defaults to today so membership fee cycles can be generated. + base_attrs = %{ + email: request.email, + first_name: request.first_name, + last_name: request.last_name, + join_date: Date.utc_today() + } + + form_data = request.form_data || %{} + + Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} -> + cond do + key in @member_field_strings -> + atom_key = String.to_existing_atom(key) + {Map.put(attrs, atom_key, value), cfvs} + + Regex.match?(@uuid_pattern, key) -> + cfv = %{custom_field_id: key, value: to_string(value)} + {attrs, [cfv | cfvs]} + + true -> + {attrs, cfvs} + end + end) + end end diff --git a/lib/mv/authorization/checks/has_join_request_access.ex b/lib/mv/authorization/checks/has_join_request_access.ex new file mode 100644 index 0000000..65256c9 --- /dev/null +++ b/lib/mv/authorization/checks/has_join_request_access.ex @@ -0,0 +1,32 @@ +defmodule Mv.Authorization.Checks.HasJoinRequestAccess do + @moduledoc """ + Simple policy check: true when the actor's role has JoinRequest read/update permission. + + Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based + check) so Ash does NOT call auto_filter, which would silently return an empty list for + unauthorized actors instead of Forbidden. + + Returns true for permission sets that grant JoinRequest read :all (normal_user, admin). + Returns false for all others (own_data, read_only, nil actor). + """ + use Ash.Policy.SimpleCheck + + alias Mv.Authorization.Actor + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)" + + @impl true + def match?(actor, _context, _opts) do + with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor), + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + Enum.any?(permissions.resources, fn p -> + p.resource == "JoinRequest" and p.action == :read and p.granted + end) + else + _ -> false + end + end +end diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index fffc818..3ffae93 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -218,7 +218,11 @@ defmodule Mv.Authorization.PermissionSets do perm("MembershipFeeCycle", :update, :all), perm("MembershipFeeCycle", :destroy, :all) ] ++ - role_read_all(), + role_read_all() ++ + [ + perm("JoinRequest", :read, :all), + perm("JoinRequest", :update, :all) + ], pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) @@ -247,7 +251,10 @@ defmodule Mv.Authorization.PermissionSets do # Edit group "/groups/:slug/edit", # Statistics - "/statistics" + "/statistics", + # Approval UI (Step 2) + "/join_requests", + "/join_requests/:id" ] } end @@ -270,7 +277,8 @@ defmodule Mv.Authorization.PermissionSets do perm_all("Group") ++ member_group_perms ++ perm_all("MembershipFeeType") ++ - perm_all("MembershipFeeCycle"), + perm_all("MembershipFeeCycle") ++ + perm_all("JoinRequest"), pages: [ # Explicit admin-only pages (for clarity and future restrictions) "/settings", diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 17fca11..25dfb1d 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -44,7 +44,16 @@ defmodule MvWeb.Layouts do def app(assigns) do club_name = get_club_name() - assigns = assign(assigns, :club_name, club_name) + join_form_enabled = get_join_form_enabled() + + unprocessed_join_requests_count = + get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) + + assigns = + assigns + |> assign(:club_name, club_name) + |> assign(:join_form_enabled, join_form_enabled) + |> assign(:unprocessed_join_requests_count, unprocessed_join_requests_count) ~H""" <%= if @current_user do %> @@ -78,7 +87,13 @@ defmodule MvWeb.Layouts do
- <.sidebar current_user={@current_user} club_name={@club_name} mobile={false} /> + <.sidebar + current_user={@current_user} + club_name={@club_name} + join_form_enabled={@join_form_enabled} + unprocessed_join_requests_count={@unprocessed_join_requests_count} + mobile={false} + />
<% else %> @@ -121,6 +136,20 @@ defmodule MvWeb.Layouts do end end + defp get_join_form_enabled do + case Mv.Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + defp get_unprocessed_join_requests_count(nil, _), do: 0 + defp get_unprocessed_join_requests_count(_user, false), do: 0 + + defp get_unprocessed_join_requests_count(user, true) do + Mv.Membership.count_submitted_join_requests(actor: user) + end + @doc """ Shows the flash group with standard titles and content. diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index cb94fb3..49d9cae 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -8,6 +8,15 @@ defmodule MvWeb.Layouts.Sidebar do attr :current_user, :map, default: nil, doc: "The current user" attr :club_name, :string, required: true, doc: "The name of the club" + + attr :join_form_enabled, :boolean, + default: false, + doc: "Whether the public join form is enabled in settings" + + attr :unprocessed_join_requests_count, :integer, + default: 0, + doc: "Count of submitted (unprocessed) join requests for sidebar indicator" + attr :mobile, :boolean, default: false, doc: "Whether this is mobile view" def sidebar(assigns) do @@ -96,6 +105,15 @@ defmodule MvWeb.Layouts.Sidebar do /> <% end %> + <%= if @join_form_enabled and can_access_page?(@current_user, PagePaths.join_requests()) do %> + <.menu_item + href={~p"/join_requests"} + icon="hero-inbox-arrow-down" + label={gettext("Join requests")} + indicator_dot={@unprocessed_join_requests_count > 0} + /> + <% end %> + <%= if admin_menu_visible?(@current_user) do %> <.menu_group icon="hero-cog-6-tooth" @@ -137,6 +155,10 @@ defmodule MvWeb.Layouts.Sidebar do attr :icon, :string, required: true, doc: "Heroicon name" attr :label, :string, required: true, doc: "Menu item label" + attr :indicator_dot, :boolean, + default: false, + doc: "Show a small dot on the icon (e.g. for unprocessed items)" + defp menu_item(assigns) do ~H"""
  • @@ -146,7 +168,16 @@ defmodule MvWeb.Layouts.Sidebar do data-tip={@label} role="menuitem" > - <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" /> + + <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" /> + <%= if @indicator_dot do %> + + <% end %> + {@label}
  • diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex index eaa9271..8674e21 100644 --- a/lib/mv_web/helpers/date_formatter.ex +++ b/lib/mv_web/helpers/date_formatter.ex @@ -24,4 +24,23 @@ defmodule MvWeb.Helpers.DateFormatter do def format_date(nil), do: "" def format_date(_), do: "Invalid date" + + @doc """ + Formats a DateTime struct to European format (dd.mm.yyyy HH:MM). + + ## 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(nil) + "" + """ + def format_datetime(%DateTime{} = dt) do + Calendar.strftime(dt, "%d.%m.%Y %H:%M") + end + + def format_datetime(nil), do: "" + + def format_datetime(_), do: "Invalid datetime" end diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex new file mode 100644 index 0000000..87797e9 --- /dev/null +++ b/lib/mv_web/live/join_request_live/index.ex @@ -0,0 +1,200 @@ +defmodule MvWeb.JoinRequestLive.Index do + @moduledoc """ + LiveView for listing and reviewing join requests (approval UI, Step 2). + + ## Features + - List join requests filtered by status (default: submitted) + - Navigate to detail view for approve/reject actions + - Accessible to normal_user and admin roles only + + ## Security + - Page access controlled by CheckPagePermission plug and can_access_page? guard + - Ash policy (HasPermission) enforces JoinRequest read :all for normal_user and admin + """ + use MvWeb, :live_view + + require Logger + + import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.Authorization + + alias Mv.Membership + alias MvWeb.Helpers.DateFormatter + + @impl true + def mount(_params, _session, socket) do + actor = current_actor(socket) + + cond do + not join_form_enabled?() -> + {:ok, redirect(socket, to: ~p"/members")} + + not can_access_page?(actor, "/join_requests") -> + {:ok, redirect(socket, to: ~p"/members")} + + true -> + {:ok, load_join_requests(socket, actor)} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {gettext("Join requests")} + + +
    +
    +

    {gettext("Open requests")}

    + <%= if Enum.empty?(@join_requests) do %> +
    +

    {gettext("No submitted join requests")}

    +
    + <% else %> + <.table + id="join-requests-table" + rows={@join_requests} + row_id={fn req -> "join-request-#{req.id}" end} + row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end} + row_tooltip={gettext("Click for details")} + > + <:col :let={req} label={gettext("Submitted at")}> + <%= if req.submitted_at do %> + {DateFormatter.format_datetime(req.submitted_at)} + <% else %> + <.empty_cell sr_text={gettext("Not submitted yet")} /> + <% end %> + + <:col :let={req} label={gettext("First name")}> + <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}> + {req.first_name} + + + <:col :let={req} label={gettext("Last name")}> + <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}> + {req.last_name} + + + <:col :let={req} label={gettext("Email")}> + {req.email} + + <:col :let={req} label={gettext("Status")}> + <.badge variant={status_badge_variant(req.status)}> + {format_status(req.status)} + + + + <% end %> +
    + +
    +

    {gettext("History")}

    + <%= if Enum.empty?(@join_requests_history) do %> +
    +

    + {gettext("No approved or rejected requests yet")} +

    +
    + <% else %> + <.table + id="join-requests-history-table" + rows={@join_requests_history} + row_id={fn req -> "join-request-history-#{req.id}" end} + row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end} + row_tooltip={gettext("Click for details")} + > + <:col :let={req} label={gettext("Email")}> + {req.email} + + <:col :let={req} label={gettext("First name")}> + <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}> + {req.first_name} + + + <:col :let={req} label={gettext("Last name")}> + <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}> + {req.last_name} + + + <:col :let={req} label={gettext("Status")}> + <.badge variant={status_badge_variant(req.status)}> + {format_status(req.status)} + + + <:col :let={req} label={gettext("Reviewed at")}> + {review_date(req)} + + <:col :let={req} label={gettext("Review by")}> + {reviewer_display(req)} + + + <% end %> +
    +
    +
    + """ + end + + defp join_form_enabled? do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + defp load_join_requests(socket, actor) do + socket = + case Membership.list_join_requests(actor: actor, status: :submitted) do + {:ok, requests} -> + assign(socket, :join_requests, requests) + + {:error, error} -> + Logger.warning("Failed to load join requests: #{inspect(error)}") + assign(socket, :join_requests, []) + end + + socket = + case Membership.list_join_requests_history(actor: actor) do + {:ok, history} -> + assign(socket, :join_requests_history, history) + + {:error, error} -> + Logger.warning("Failed to load join requests history: #{inspect(error)}") + assign(socket, :join_requests_history, []) + end + + assign(socket, :page_title, gettext("Join requests")) + end + + defp format_status(:pending_confirmation), do: gettext("Pending confirmation") + defp format_status(:submitted), do: gettext("Submitted") + defp format_status(:approved), do: gettext("Approved") + defp format_status(:rejected), do: gettext("Rejected") + defp format_status(other), do: to_string(other) + + defp status_badge_variant(:submitted), do: :info + defp status_badge_variant(:approved), do: :success + defp status_badge_variant(:rejected), do: :error + defp status_badge_variant(_), do: :neutral + + defp review_date(req) do + date = + case req.status do + :approved -> req.approved_at + :rejected -> req.rejected_at + _ -> nil + end + + if date, do: DateFormatter.format_datetime(date), else: "" + end + + defp reviewer_display(req) do + case req.reviewed_by_user do + nil -> "" + %{email: email} when not is_nil(email) -> to_string(email) |> String.trim() + _ -> "" + end + end +end diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex new file mode 100644 index 0000000..72579b3 --- /dev/null +++ b/lib/mv_web/live/join_request_live/show.ex @@ -0,0 +1,320 @@ +defmodule MvWeb.JoinRequestLive.Show do + @moduledoc """ + LiveView for displaying a single join request and performing approve/reject actions. + + ## Features + - Show all request data (typed fields + form_data rendered by field) + - Approve action: transitions to :approved, creates Member + - Reject action: transitions to :rejected (no Member created) + - Actions only available when status is :submitted + + ## Security + - Page access controlled by CheckPagePermission plug and can_access_page? guard + - Ash policy (HasPermission) enforces JoinRequest update :all for normal_user and admin + """ + use MvWeb, :live_view + + require Logger + + import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.Authorization + + alias Mv.Constants + alias Mv.Membership + alias MvWeb.Helpers.DateFormatter + + @impl true + def mount(_params, _session, socket) do + if join_form_enabled?() do + {:ok, + socket + |> assign(:join_request, nil) + |> assign(:join_form_field_ids, []) + |> assign(:page_title, gettext("Join request"))} + else + {:ok, redirect(socket, to: ~p"/members")} + end + end + + @impl true + def handle_params(%{"id" => id}, _url, socket) do + actor = current_actor(socket) + + if join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do + case Membership.get_join_request(id, actor: actor) do + {:ok, nil} -> + {:noreply, + socket + |> put_flash(:error, gettext("Join request not found.")) + |> push_navigate(to: ~p"/join_requests")} + + {:ok, request} -> + field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) + + {:noreply, + socket + |> assign(:join_request, request) + |> assign(:join_form_field_ids, field_ids) + |> assign(:page_title, gettext("Join request – %{email}", email: request.email))} + + {:error, _error} -> + {:noreply, + socket + |> put_flash(:error, gettext("Failed to load join request.")) + |> push_navigate(to: ~p"/join_requests")} + end + else + {:noreply, redirect(socket, to: ~p"/members")} + end + end + + @impl true + def handle_event("approve", _params, socket) do + actor = current_actor(socket) + request = socket.assigns.join_request + + case Membership.approve_join_request(request.id, actor: actor) do + {:ok, _approved} -> + {:noreply, + socket + |> put_flash(:info, gettext("Join request approved. Member created.")) + |> push_navigate(to: ~p"/join_requests")} + + {:error, error} -> + Logger.warning("Failed to approve join request #{request.id}: #{inspect(error)}") + + {:noreply, put_flash(socket, :error, gettext("Failed to approve join request."))} + end + end + + @impl true + def handle_event("reject", _params, socket) do + actor = current_actor(socket) + request = socket.assigns.join_request + + case Membership.reject_join_request(request.id, actor: actor) do + {:ok, _rejected} -> + {:noreply, + socket + |> put_flash(:info, gettext("Join request rejected.")) + |> push_navigate(to: ~p"/join_requests")} + + {:error, error} -> + Logger.warning("Failed to reject join request #{request.id}: #{inspect(error)}") + + {:noreply, put_flash(socket, :error, gettext("Failed to reject join request."))} + end + end + + defp join_form_enabled? do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + <:leading> + <.button + navigate={~p"/join_requests"} + variant="neutral" + aria-label={gettext("Back to join requests")} + > + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + + {gettext("Join request")} + + + <%= if @join_request do %> +
    +
    +

    {gettext("Request data")}

    +
    + <.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)} + /> +
    + {gettext("Status")}: + + <.badge variant={status_badge_variant(@join_request.status)}> + {format_status(@join_request.status)} + + +
    +
    +
    + + <%= 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.approved_at do %> + <.field_row + label={gettext("Approved at")} + value={DateFormatter.format_datetime(@join_request.approved_at)} + /> + <% end %> + <%= if @join_request.rejected_at do %> + <.field_row + label={gettext("Rejected at")} + value={DateFormatter.format_datetime(@join_request.rejected_at)} + /> + <% end %> + <.field_row + label={gettext("Review by")} + value={reviewer_display(@join_request)} + empty_text="-" + /> +
    +
    + <% end %> + + <%= if @join_request.status == :submitted do %> +
    + <.button + variant="danger" + phx-click="reject" + data-confirm={gettext("Reject this join request?")} + data-testid="join-request-reject-btn" + > + {gettext("Reject")} + + <.button + variant="primary" + phx-click="approve" + data-confirm={gettext("Approve this join request and create a member?")} + data-testid="join-request-approve-btn" + > + {gettext("Approve")} + +
    + <% end %> +
    + <% end %> +
    + """ + end + + attr :label, :string, required: true + attr :value, :any, default: nil + attr :empty_text, :string, default: nil + + defp field_row(assigns) do + ~H""" +
    + {@label}: + + <%= if @value && @value != "" do %> + {@value} + <% else %> + + {@empty_text || gettext("Not specified")} + + <% end %> + +
    + """ + end + + defp format_status(:pending_confirmation), do: gettext("Pending confirmation") + defp format_status(:submitted), do: gettext("Submitted") + defp format_status(:approved), do: gettext("Approved") + defp format_status(:rejected), do: gettext("Rejected") + defp format_status(other), do: to_string(other) + + defp status_badge_variant(:submitted), do: :info + defp status_badge_variant(:approved), do: :success + defp status_badge_variant(:rejected), do: :error + defp status_badge_variant(_), do: :neutral + + defp reviewer_display(%{reviewed_by_user: user}) do + case user do + nil -> + nil + + %{email: email} when not is_nil(email) -> + s = to_string(email) |> String.trim() + if s == "", do: nil, else: s + + _ -> + nil + end + end + + defp reviewer_display(_), do: nil + + # 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 + member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + # 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] + label = field_key_to_label(key, member_field_strings) + {label, 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.sort() + + legacy_entries = + Enum.map(legacy_keys, fn key -> + label = field_key_to_label(key, member_field_strings) + {label, form_data[key]} + end) + + in_order ++ legacy_entries + end + + defp field_key_to_label(key, member_field_strings) when is_binary(key) do + if key in member_field_strings, do: humanize_field(key), else: key + end + + defp field_key_to_label(key, _), do: to_string(key) + + defp humanize_field(key) when is_binary(key) do + key + |> String.replace("_", " ") + |> String.capitalize() + end +end diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex index 551cada..70e0ddb 100644 --- a/lib/mv_web/page_paths.ex +++ b/lib/mv_web/page_paths.ex @@ -9,6 +9,7 @@ defmodule MvWeb.PagePaths do # Sidebar top-level menu paths @members "/members" @statistics "/statistics" + @join_requests "/join_requests" # Administration submenu paths (all must match router) @users "/users" @@ -35,6 +36,9 @@ defmodule MvWeb.PagePaths do @doc "Path for Statistics page (sidebar and page permission check)." def statistics, do: @statistics + @doc "Path for Join Requests approval UI (sidebar and page permission check)." + def join_requests, do: @join_requests + @doc "Paths for Administration menu; show group if user can access any of these." def admin_menu_paths, do: @admin_page_paths diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 74fcd22..945e22c 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -83,6 +83,10 @@ defmodule MvWeb.Router do live "/groups/:slug", GroupLive.Show, :show live "/groups/:slug/edit", GroupLive.Form, :edit + # Join Request Approval (normal_user and admin) + live "/join_requests", JoinRequestLive.Index, :index + live "/join_requests/:id", JoinRequestLive.Show, :show + # Role Management (Admin only) live "/admin/roles", RoleLive.Index, :index live "/admin/roles/new", RoleLive.Form, :new diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 96b8c07..71c42be 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -59,6 +59,8 @@ msgid "Edit Member" msgstr "Mitglied 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 @@ -542,6 +544,8 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke, um zu sortieren" +#: 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" @@ -745,6 +749,7 @@ msgstr "Adresse" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -895,6 +900,8 @@ msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind." msgid "Quarterly" msgstr "Vierteljährlich" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -926,6 +933,8 @@ msgstr "Unbezahlt" msgid "Yearly" msgstr "Jährlich" +#: 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" @@ -3181,6 +3190,8 @@ msgstr "Keine Gruppenzuordnung" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.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/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur #, elixir-autogen, elixir-format msgid "Website" msgstr "Webseite" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Additional form data" +msgstr "Weitere Formulardaten" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "Genehmigen" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "Diesen Mitgliedsantrag genehmigen und Mitglied anlegen?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "Genehmigt" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "Genehmigt am" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "Zurück zu den Mitgliedsanträgen" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "Klicken für Details" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "Mitgliedsantrag konnte nicht genehmigt werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "Mitgliedsantrag konnte nicht geladen werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "Mitgliedsantrag konnte nicht abgelehnt werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "Mitgliedsantrag" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "Mitgliedsantrag genehmigt. Mitglied wurde angelegt." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "Mitgliedsantrag nicht gefunden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "Mitgliedsantrag abgelehnt." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "Mitgliedsantrag – %{email}" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "Mitgliedsanträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "Keine eingereichten Mitgliedsanträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "Noch nicht eingereicht" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "Bestätigung ausstehend" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "Ablehnen" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "Diesen Mitgliedsantrag ablehnen?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "Abgelehnt" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "Abgelehnt am" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +msgstr "Antragsdaten" + +#: 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 +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "Eingereicht" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "Eingereicht am" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "Noch keine genehmigten oder abgelehnten Anträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "Geprüft am" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "Historie" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "Offene Anträge" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "Geprüft von" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 65197e1..b04f216 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -60,6 +60,8 @@ msgid "Edit Member" 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 @@ -543,6 +545,8 @@ msgstr "" msgid "Click to sort" 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" @@ -746,6 +750,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -896,6 +901,8 @@ msgstr "" msgid "Quarterly" 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/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -927,6 +934,8 @@ msgstr "" msgid "Yearly" 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" @@ -3181,6 +3190,8 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.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/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Website" msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Additional form data" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +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 +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 4ebce69..0269a31 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -60,6 +60,8 @@ msgid "Edit Member" 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 @@ -543,6 +545,8 @@ msgstr "" msgid "Click to sort" 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" @@ -746,6 +750,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -896,6 +901,8 @@ msgstr "" msgid "Quarterly" 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/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -927,6 +934,8 @@ msgstr "" msgid "Yearly" 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" @@ -3181,6 +3190,8 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.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/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "Your details are only used to process your membership application and to #, elixir-autogen, elixir-format msgid "Website" 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/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "Approve" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "Approve this membership application and create a member?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "Approved" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "Approved at" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "Back to membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "Click for details" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "Failed to approve membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "Failed to load membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "Failed to reject membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "Membership application" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "Membership application approved. Member created." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "Membership application not found." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "Membership application rejected." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "Membership application – %{email}" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "Membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "No submitted membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "Not submitted yet" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "Pending confirmation" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "Reject" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "Reject this membership application?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "Rejected" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "Rejected at" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +msgstr "Request data" + +#: 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 +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "Submitted" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "Submitted at" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "No approved or rejected requests yet" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "Review date" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "Review by" diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs index 352299f..5f30a08 100644 --- a/priv/repo/seeds_dev.exs +++ b/priv/repo/seeds_dev.exs @@ -481,8 +481,50 @@ for {email, values} <- custom_value_assignments do end end +# Join form: enable so membership application list is visible in dev +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 + } + }) + end + _ -> + :ok +end + +# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data +join_request_configs = [ + %{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}}, + %{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}}, + %{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}}, + %{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}} +] + +for config <- join_request_configs do + attrs = %{ + email: config.email, + first_name: config.first_name, + last_name: config.last_name, + form_data: config.form_data || %{}, + schema_version: 1 + } + + Mv.Membership.JoinRequest + |> Ash.Changeset.for_create(:create_submitted, attrs) + |> Ash.create!(authorize?: false, domain: Mv.Membership) +end + IO.puts("✅ Dev seeds completed.") IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)") IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung") IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)") IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)") +IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing") diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs index 9578fea..1f9b3c2 100644 --- a/test/membership/join_request_approval_domain_test.exs +++ b/test/membership/join_request_approval_domain_test.exs @@ -38,6 +38,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do assert member_count() == count_before + 1 request_email = request.email + [member] = Member |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) @@ -56,6 +57,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do # No User should exist with this email from the approval flow request_email = request.email + users_with_email = Mv.Accounts.User |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) @@ -99,10 +101,12 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do test "approve when status is pending_confirmation returns error" do token = "pending-token-#{System.unique_integer([:positive])}" + attrs = %{ email: "pending#{System.unique_integer([:positive])}@example.com", confirmation_token: token } + {:ok, request} = Membership.submit_join_request(attrs, actor: nil) assert request.status == :pending_confirmation @@ -120,6 +124,36 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do end describe "approve_join_request/2 – defaults" do + setup do + # Create a fee type and set it as the default in settings so SetDefaultMembershipFeeType + # can assign it when a member is created from a join request (no fee type in form_data). + actor = SystemActor.get_system_actor() + + {:ok, fee_type} = + Ash.create( + Mv.MembershipFees.MembershipFeeType, + %{ + name: "Default Test Fee Type #{System.unique_integer([:positive])}", + amount: Decimal.new("50.00"), + interval: :yearly + }, + actor: actor, + domain: Mv.MembershipFees + ) + + {:ok, settings} = Membership.get_settings() + + settings + |> Ash.Changeset.for_update( + :update_membership_fee_settings, + %{default_membership_fee_type_id: fee_type.id}, + actor: actor + ) + |> Ash.update!(actor: actor) + + :ok + end + test "created member has join_date and membership_fee_type when not in form_data" do request = Fixtures.submitted_join_request_fixture() user = Fixtures.user_with_role_fixture("normal_user") @@ -127,6 +161,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) request_email = request.email + [member] = Member |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs index e658435..6c09526 100644 --- a/test/membership/join_request_approval_policy_test.exs +++ b/test/membership/join_request_approval_policy_test.exs @@ -59,12 +59,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do test "read_only cannot approve", %{request: request} do user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.approve_join_request(request.id, actor: user) end test "own_data cannot approve", %{request: request} do user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.approve_join_request(request.id, actor: user) end @@ -97,12 +99,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do test "read_only cannot reject", %{request: request} do user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.reject_join_request(request.id, actor: user) end test "own_data cannot reject", %{request: request} do user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.reject_join_request(request.id, actor: user) end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 31922b0..d8c46e1 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -813,13 +813,21 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end end - # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests + # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, + # /groups, /groups/:slug, /join_requests (only when join form is enabled in settings) describe "integration: normal_user (Kassenwart) allowed paths via full router" do setup %{conn: conn, current_user: current_user} do member = Mv.Fixtures.member_fixture() group = Mv.Fixtures.group_fixture() join_request = Fixtures.submitted_join_request_fixture() + # Enable join form so /join_requests and /join_requests/:id return 200 (not redirect) + {:ok, settings} = Mv.Membership.get_settings() + + if settings do + Mv.Membership.update_settings(settings, %{join_form_enabled: true}) + end + {:ok, conn: conn, current_user: current_user, diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 56347c9..73bf12a 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -323,10 +323,12 @@ defmodule Mv.Fixtures do """ def submitted_join_request_fixture(attrs \\ %{}) do token = "fixture-token-#{System.unique_integer([:positive])}" + base = %{ email: "join#{System.unique_integer([:positive])}@example.com", confirmation_token: token } + attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token) {:ok, _} = Membership.submit_join_request(attrs, actor: nil) From f53a3ce3cc3c14df5d55f6ad5e1d1f86f0e13558 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 11 Mar 2026 02:19:49 +0100 Subject: [PATCH 203/237] refactor: integrate approval ui review changes --- CODE_GUIDELINES.md | 2 +- docs/onboarding-join-concept.md | 1 + .../join_request/changes/approve_request.ex | 12 +--- .../join_request/changes/helpers.ex | 19 +++++++ .../join_request/changes/reject_request.ex | 12 +--- lib/membership/membership.ex | 57 +++++++++++++++---- lib/mv_web/components/layouts.ex | 11 +--- lib/mv_web/live/join_request_live/helpers.ex | 47 +++++++++++++++ lib/mv_web/live/join_request_live/index.ex | 39 +++---------- lib/mv_web/live/join_request_live/show.ex | 56 ++++-------------- priv/gettext/de/LC_MESSAGES/default.po | 12 ++-- priv/gettext/default.pot | 12 ++-- priv/gettext/en/LC_MESSAGES/default.po | 12 ++-- 13 files changed, 153 insertions(+), 139 deletions(-) create mode 100644 lib/membership/join_request/changes/helpers.ex create mode 100644 lib/mv_web/live/join_request_live/helpers.ex diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index b789088..6f8deb5 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -86,7 +86,7 @@ lib/ │ ├── membership.ex # Domain definition │ ├── member.ex # Member resource │ ├── join_request.ex # JoinRequest (public join form, double opt-in) -│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest) +│ ├── join_request/ # JoinRequest changes (Helpers, SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest, ApproveRequest, RejectRequest) │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource │ ├── setting.ex # Global settings (singleton resource; incl. join form config) diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index d3e8c42..8083a7b 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -133,6 +133,7 @@ Implementation spec for Subtask 5. - **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member. - **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest). - **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. +- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent. - **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest). #### Permission sets and routing diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index aee6874..24716f6 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -8,12 +8,14 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do """ use Ash.Resource.Change + alias Mv.Membership.JoinRequest.Changes.Helpers + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() def change(changeset, _opts, context) do current_status = Ash.Changeset.get_data(changeset, :status) if current_status == :submitted do - reviewed_by_id = actor_id(context.actor) + reviewed_by_id = Helpers.actor_id(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) @@ -26,12 +28,4 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do ) end end - - defp actor_id(nil), do: nil - - defp actor_id(actor) when is_map(actor) do - Map.get(actor, :id) || Map.get(actor, "id") - end - - defp actor_id(_), do: nil end diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex new file mode 100644 index 0000000..ee09b75 --- /dev/null +++ b/lib/membership/join_request/changes/helpers.ex @@ -0,0 +1,19 @@ +defmodule Mv.Membership.JoinRequest.Changes.Helpers do + @moduledoc """ + Shared helpers for JoinRequest change modules (e.g. ApproveRequest, RejectRequest). + """ + + @doc """ + Extracts the actor's user id from the Ash change context. + + Supports both atom and string keys for compatibility with different actor representations. + """ + @spec actor_id(term()) :: String.t() | nil + def actor_id(nil), do: nil + + def actor_id(actor) when is_map(actor) do + Map.get(actor, :id) || Map.get(actor, "id") + end + + def actor_id(_), do: nil +end diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex index 939df95..2c33a77 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -7,12 +7,14 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do """ use Ash.Resource.Change + alias Mv.Membership.JoinRequest.Changes.Helpers + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() def change(changeset, _opts, context) do current_status = Ash.Changeset.get_data(changeset, :status) if current_status == :submitted do - reviewed_by_id = actor_id(context.actor) + reviewed_by_id = Helpers.actor_id(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) @@ -25,12 +27,4 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do ) end end - - defp actor_id(nil), do: nil - - defp actor_id(actor) when is_map(actor) do - Map.get(actor, :id) || Map.get(actor, "id") - end - - defp actor_id(_), do: nil end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index c04686b..2f18f90 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -456,6 +456,20 @@ defmodule Mv.Membership do end end + @doc """ + Returns whether the public join form is enabled in global settings. + + Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether + to show join-related UI and to gate access to join request pages. + """ + @spec join_form_enabled?() :: boolean() + def join_form_enabled? do + case get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + @doc """ Returns the allowlist of fields configured for the public join form. @@ -585,8 +599,15 @@ defmodule Mv.Membership do query = JoinRequest |> Ash.Query.filter(expr(status == :submitted)) case Ash.count(query, actor: actor, domain: __MODULE__) do - {:ok, count} when is_integer(count) and count >= 0 -> count - _ -> 0 + {:ok, count} when is_integer(count) and count >= 0 -> + count + + {:error, error} -> + Logger.debug("count_submitted_join_requests failed: #{inspect(error)}") + 0 + + _ -> + 0 end end @@ -604,7 +625,13 @@ defmodule Mv.Membership do @spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()} def get_join_request(id, opts \\ []) do actor = Keyword.get(opts, :actor) - Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__) + + Ash.get(JoinRequest, id, + actor: actor, + load: [:reviewed_by_user], + not_found_error?: false, + domain: __MODULE__ + ) end @doc """ @@ -625,13 +652,22 @@ defmodule Mv.Membership do def approve_join_request(id, opts \\ []) do actor = Keyword.get(opts, :actor) - with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__), - {:ok, approved} <- - request - |> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__) - |> Ash.update(actor: actor, domain: __MODULE__), - {:ok, _member} <- promote_to_member(approved, actor) do - {:ok, approved} + result = + Ash.transact(JoinRequest, fn -> + with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__), + {:ok, approved} <- + request + |> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__) + |> Ash.update(actor: actor, domain: __MODULE__), + {:ok, _member} <- promote_to_member(approved, actor) do + {:ok, approved} + end + end) + + # Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()} + case result do + {:ok, inner} -> inner + {:error, _} = err -> err end end @@ -661,6 +697,7 @@ defmodule Mv.Membership do # Builds Member attrs + custom_field_values from a JoinRequest and creates the Member. @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + # Evaluated at compile time so we do not resolve member_fields() on every reduce step. @member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) defp promote_to_member(%JoinRequest{} = request, actor) do diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 25dfb1d..a6d75ba 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -44,8 +44,10 @@ defmodule MvWeb.Layouts do def app(assigns) do club_name = get_club_name() - join_form_enabled = get_join_form_enabled() + join_form_enabled = Mv.Membership.join_form_enabled?() + # 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. unprocessed_join_requests_count = get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) @@ -136,13 +138,6 @@ defmodule MvWeb.Layouts do end end - defp get_join_form_enabled do - case Mv.Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - defp get_unprocessed_join_requests_count(nil, _), do: 0 defp get_unprocessed_join_requests_count(_user, false), do: 0 diff --git a/lib/mv_web/live/join_request_live/helpers.ex b/lib/mv_web/live/join_request_live/helpers.ex new file mode 100644 index 0000000..5ec5105 --- /dev/null +++ b/lib/mv_web/live/join_request_live/helpers.ex @@ -0,0 +1,47 @@ +defmodule MvWeb.JoinRequestLive.Helpers do + @moduledoc """ + Shared helpers for JoinRequest LiveViews (Index, Show): status display, + badge variants, and reviewer display. + """ + use Gettext, backend: MvWeb.Gettext + + @doc "Human-readable label for a join request status atom." + def format_status(:pending_confirmation), do: gettext("Pending confirmation") + def format_status(:submitted), do: gettext("Submitted") + def format_status(:approved), do: gettext("Approved") + def format_status(:rejected), do: gettext("Rejected") + def format_status(other), do: to_string(other) + + @doc "Badge variant for the status (used with CoreComponents.badge)." + def status_badge_variant(:submitted), do: :info + def status_badge_variant(:approved), do: :success + def status_badge_variant(:rejected), do: :error + def status_badge_variant(_), do: :neutral + + @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). + """ + def reviewer_display(req) when is_map(req) do + user = Map.get(req, :reviewed_by_user) + + case user do + nil -> + nil + + %{email: email} when is_binary(email) -> + s = String.trim(email) + if s == "", do: nil, else: s + + %{"email" => email} when is_binary(email) -> + s = String.trim(email) + if s == "", do: nil, else: s + + _ -> + nil + end + end + + def reviewer_display(_), do: nil +end diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex index 87797e9..8d85837 100644 --- a/lib/mv_web/live/join_request_live/index.ex +++ b/lib/mv_web/live/join_request_live/index.ex @@ -20,13 +20,14 @@ defmodule MvWeb.JoinRequestLive.Index do alias Mv.Membership alias MvWeb.Helpers.DateFormatter + alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers @impl true def mount(_params, _session, socket) do actor = current_actor(socket) cond do - not join_form_enabled?() -> + not Membership.join_form_enabled?() -> {:ok, redirect(socket, to: ~p"/members")} not can_access_page?(actor, "/join_requests") -> @@ -81,8 +82,8 @@ defmodule MvWeb.JoinRequestLive.Index do {req.email} <:col :let={req} label={gettext("Status")}> - <.badge variant={status_badge_variant(req.status)}> - {format_status(req.status)} + <.badge variant={JoinRequestHelpers.status_badge_variant(req.status)}> + {JoinRequestHelpers.format_status(req.status)} @@ -119,15 +120,15 @@ defmodule MvWeb.JoinRequestLive.Index do <:col :let={req} label={gettext("Status")}> - <.badge variant={status_badge_variant(req.status)}> - {format_status(req.status)} + <.badge variant={JoinRequestHelpers.status_badge_variant(req.status)}> + {JoinRequestHelpers.format_status(req.status)} <:col :let={req} label={gettext("Reviewed at")}> {review_date(req)} <:col :let={req} label={gettext("Review by")}> - {reviewer_display(req)} + {JoinRequestHelpers.reviewer_display(req) || ""} <% end %> @@ -137,13 +138,6 @@ defmodule MvWeb.JoinRequestLive.Index do """ end - defp join_form_enabled? do - case Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - defp load_join_requests(socket, actor) do socket = case Membership.list_join_requests(actor: actor, status: :submitted) do @@ -168,17 +162,6 @@ defmodule MvWeb.JoinRequestLive.Index do assign(socket, :page_title, gettext("Join requests")) end - defp format_status(:pending_confirmation), do: gettext("Pending confirmation") - defp format_status(:submitted), do: gettext("Submitted") - defp format_status(:approved), do: gettext("Approved") - defp format_status(:rejected), do: gettext("Rejected") - defp format_status(other), do: to_string(other) - - defp status_badge_variant(:submitted), do: :info - defp status_badge_variant(:approved), do: :success - defp status_badge_variant(:rejected), do: :error - defp status_badge_variant(_), do: :neutral - defp review_date(req) do date = case req.status do @@ -189,12 +172,4 @@ defmodule MvWeb.JoinRequestLive.Index do if date, do: DateFormatter.format_datetime(date), else: "" end - - defp reviewer_display(req) do - case req.reviewed_by_user do - nil -> "" - %{email: email} when not is_nil(email) -> to_string(email) |> String.trim() - _ -> "" - end - 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 72579b3..138b433 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -22,10 +22,12 @@ defmodule MvWeb.JoinRequestLive.Show do alias Mv.Constants alias Mv.Membership alias MvWeb.Helpers.DateFormatter + alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers + alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations @impl true def mount(_params, _session, socket) do - if join_form_enabled?() do + if Membership.join_form_enabled?() do {:ok, socket |> assign(:join_request, nil) @@ -40,7 +42,7 @@ defmodule MvWeb.JoinRequestLive.Show do def handle_params(%{"id" => id}, _url, socket) do actor = current_actor(socket) - if join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do + if Membership.join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do case Membership.get_join_request(id, actor: actor) do {:ok, nil} -> {:noreply, @@ -106,13 +108,6 @@ defmodule MvWeb.JoinRequestLive.Show do end end - defp join_form_enabled? do - case Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - @impl true def render(assigns) do ~H""" @@ -154,8 +149,8 @@ defmodule MvWeb.JoinRequestLive.Show do
    {gettext("Status")}: - <.badge variant={status_badge_variant(@join_request.status)}> - {format_status(@join_request.status)} + <.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}> + {JoinRequestHelpers.format_status(@join_request.status)}
    @@ -191,7 +186,7 @@ defmodule MvWeb.JoinRequestLive.Show do <% end %> <.field_row label={gettext("Review by")} - value={reviewer_display(@join_request)} + value={JoinRequestHelpers.reviewer_display(@join_request)} empty_text="-" />
    @@ -245,33 +240,6 @@ defmodule MvWeb.JoinRequestLive.Show do """ end - defp format_status(:pending_confirmation), do: gettext("Pending confirmation") - defp format_status(:submitted), do: gettext("Submitted") - defp format_status(:approved), do: gettext("Approved") - defp format_status(:rejected), do: gettext("Rejected") - defp format_status(other), do: to_string(other) - - defp status_badge_variant(:submitted), do: :info - defp status_badge_variant(:approved), do: :success - defp status_badge_variant(:rejected), do: :error - defp status_badge_variant(_), do: :neutral - - defp reviewer_display(%{reviewed_by_user: user}) do - case user do - nil -> - nil - - %{email: email} when not is_nil(email) -> - s = to_string(email) |> String.trim() - if s == "", do: nil, else: s - - _ -> - nil - end - end - - defp reviewer_display(_), do: nil - # 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). @@ -307,14 +275,10 @@ defmodule MvWeb.JoinRequestLive.Show do end defp field_key_to_label(key, member_field_strings) when is_binary(key) do - if key in member_field_strings, do: humanize_field(key), else: key + if key in member_field_strings, + do: MemberFieldsTranslations.label(String.to_existing_atom(key)), + else: key end defp field_key_to_label(key, _), do: to_string(key) - - defp humanize_field(key) when is_binary(key) do - key - |> String.replace("_", " ") - |> String.capitalize() - end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 71c42be..055f36a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3476,8 +3476,7 @@ msgstr "Genehmigen" msgid "Approve this join request and create a member?" msgstr "Diesen Mitgliedsantrag genehmigen und Mitglied anlegen?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "Genehmigt" @@ -3553,8 +3552,7 @@ msgstr "Keine eingereichten Mitgliedsanträge" msgid "Not submitted yet" msgstr "Noch nicht eingereicht" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "Bestätigung ausstehend" @@ -3569,8 +3567,7 @@ msgstr "Ablehnen" msgid "Reject this join request?" msgstr "Diesen Mitgliedsantrag ablehnen?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "Abgelehnt" @@ -3590,8 +3587,7 @@ msgstr "Antragsdaten" msgid "Review information" msgstr "Bearbeitungsinformationen" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "Eingereicht" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b04f216..a1e0909 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3476,8 +3476,7 @@ msgstr "" msgid "Approve this join request and create a member?" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "" @@ -3553,8 +3552,7 @@ msgstr "" msgid "Not submitted yet" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "" @@ -3569,8 +3567,7 @@ msgstr "" msgid "Reject this join request?" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "" @@ -3590,8 +3587,7 @@ msgstr "" msgid "Review information" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 0269a31..eccae34 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3476,8 +3476,7 @@ msgstr "Approve" msgid "Approve this join request and create a member?" msgstr "Approve this membership application and create a member?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "Approved" @@ -3553,8 +3552,7 @@ msgstr "No submitted membership applications" msgid "Not submitted yet" msgstr "Not submitted yet" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "Pending confirmation" @@ -3569,8 +3567,7 @@ msgstr "Reject" msgid "Reject this join request?" msgstr "Reject this membership application?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "Rejected" @@ -3590,8 +3587,7 @@ msgstr "Request data" msgid "Review information" msgstr "Review information" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "Submitted" From c4135308e69be6536c9f17395cba677759ca5666 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 11 Mar 2026 09:18:37 +0100 Subject: [PATCH 204/237] 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 a4ad1f7b27cb808e8a49bcf9a58a59d82c9d6c55 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:12:49 +0100 Subject: [PATCH 205/237] docs: Update readme --- README.md | 56 +++++++++++++++---------------------------------------- 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index c0072aa..92b15d9 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ **Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs. -[![Build Status](https://drone.dev.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung) +[![Build Status](https://drone.cicd.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.cicd.local-it.cloud/local-it/mitgliederverwaltung) ![License](https://img.shields.io/badge/license-AGPL--v3-blue) ## 🚧 Project Status -⚠️ **Early development** — not production-ready. Expect breaking changes. +⚠️ **First Version** — Expect breaking changes. Contributions and feedback are welcome! ## ✨ Overview @@ -48,9 +48,10 @@ You can find our documentation for users here: https://wiki.local-it.org/s/mila- - ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.) - ✅ Sidebar navigation (standard-compliant, accessible) - ✅ Global settings management -- 🚧 Self-service & online application +- ✅ Self-service & online application - ✅ Accessibility improvements (WCAG 2.1 AA compliant keyboard navigation) -- 🚧 Email sending +- ✅ Email sending +- ✅ Integration of Accounting-Software ([Vereinfacht](https://github.com/vereinfacht/vereinfacht)) ## 🚀 Quick Start (Development) @@ -172,13 +173,7 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/ca ## ⚙️ Configuration -- **Env vars:** see `.env.example` - - `OIDC_CLIENT_SECRET` — secret for your OIDC client -- Database defaults (Docker Compose): - - Host: `localhost` - - Port: `5000` - - User/pass: `postgres` / `postgres` - - DB: `mila_dev` +- **Env vars:** see `.env.example` ## 🏗️ Architecture @@ -193,6 +188,8 @@ The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/ca - `lib/mv_web/` — Phoenix controllers, LiveViews, components - `lib/mv/` — Shared helpers and business logic - `assets/` — Tailwind, JavaScript, static files +- `test/` — All tests + 📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md) 📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md) @@ -228,42 +225,19 @@ For testing the production Docker build locally: # Copy template and edit cp .env.example .env nano .env - - # Required variables: - SECRET_KEY_BASE= - TOKEN_SIGNING_SECRET= - DOMAIN=localhost # or PHX_HOST=localhost - - # Optional OIDC configuration: - # OIDC_CLIENT_ID=mv - # OIDC_BASE_URL=http://localhost:8080/auth/v1 - # OIDC_CLIENT_SECRET= - # OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/oidc/callback - - # Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars): - # SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base - # TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret - # OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret - # DATABASE_URL_FILE=/run/secrets/database_url - # DATABASE_PASSWORD_FILE=/run/secrets/database_password ``` -3. **Start development environment** (for Rauthy): - ```bash - docker compose up -d - ``` - -4. **Start production environment:** +3. **Start production environment:** ```bash docker compose -f docker-compose.prod.yml up ``` -5. **Database migrations run automatically** on app start. For manual migration: +4. **Database migrations run automatically** on app start. For manual migration: ```bash docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate" ``` -6. **Access the production app:** +5. **Access the production app:** - Production App: http://localhost:4001 - Uses same Rauthy instance as dev (localhost:8080) @@ -286,9 +260,9 @@ For actual production deployment: ## 🤝 Contributing We welcome contributions! -- Open issues and PRs in this repo. -- Please follow existing code style and conventions. -- Expect breaking changes while the project is in early development. +- Open issues and PRs in this repo +- Please follow existing code style and conventions +- Expect breaking changes while the project is in early development ## 📄 License @@ -298,4 +272,4 @@ See the [LICENSE](LICENSE) file for details. ## 📬 Contact - Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues) -- Community links: coming soon. +- E-Mail: info@local-it.org From b9ff02b959ec383c5c1e0d6f6df1abc5d4883774 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:13:09 +0100 Subject: [PATCH 206/237] fix typo --- CODE_GUIDELINES.md | 2 +- DESIGN_DUIDELINES.md => DESIGN_GUIDELINES.md | 0 docs/feature-roadmap.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename DESIGN_DUIDELINES.md => DESIGN_GUIDELINES.md (100%) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 6f8deb5..4aa7566 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -61,7 +61,7 @@ We are building a membership management system (Mila) using the following techno 8. [Accessibility Guidelines](#8-accessibility-guidelines) **Related documents:** -- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. +- **UI / UX:** [`DESIGN_GUIDELINES.md`](../DESIGN_GUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. - **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields). --- diff --git a/DESIGN_DUIDELINES.md b/DESIGN_GUIDELINES.md similarity index 100% rename from DESIGN_DUIDELINES.md rename to DESIGN_GUIDELINES.md diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 89c2f39..9c8c835 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -195,7 +195,7 @@ - Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility. - Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element. - LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`. - - All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_DUIDELINES.md` §9. + - All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_GUIDELINES.md` §9. --- From 45c2f3e2b3c54b08b0550fbf1356bfdac585a920 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:13:21 +0100 Subject: [PATCH 207/237] i18n: fix translations --- lib/mv_web/live/global_settings_live.ex | 6 +-- lib/mv_web/live/import_live/components.ex | 9 ---- lib/mv_web/live/member_live/show.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 2 +- lib/mv_web/live/user_live/form.ex | 8 +-- priv/gettext/de/LC_MESSAGES/default.po | 63 +++++++++++++---------- priv/gettext/default.pot | 53 ++++++++++--------- priv/gettext/en/LC_MESSAGES/default.po | 63 +++++++++++++---------- 8 files changed, 107 insertions(+), 99 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 3c75fa8..7daa39a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -270,7 +270,7 @@ defmodule MvWeb.GlobalSettingsLive do
    <%!-- Vereinfacht Integration Section --%> - <.form_section title={gettext("Vereinfacht Integration")}> + <.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.")} @@ -378,7 +378,7 @@ defmodule MvWeb.GlobalSettingsLive do <%!-- OIDC Section --%> - <.form_section title={gettext("OIDC")}> + <.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.")} @@ -486,7 +486,7 @@ defmodule MvWeb.GlobalSettingsLive do />

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

    diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 5cbcba8..3bf10cb 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -28,15 +28,6 @@ defmodule MvWeb.ImportLive.Components do "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." )}

    -

    - <.link - href={~p"/settings#custom_fields"} - class="link" - data-testid="custom-fields-link" - > - {gettext("Manage Member Data")} - -

    """ diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index d47ee4c..e5fa207 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -230,7 +230,7 @@ defmodule MvWeb.MemberLive.Show do <%!-- Custom Fields Section --%> <%= if Enum.any?(@custom_fields) do %>
    - <.section_box title={gettext("Custom Fields")}> + <.section_box title={gettext("Individual datafields")}>
    <%= for custom_field <- @custom_fields do %> <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index bb61bb1..c5fd599 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -2,7 +2,7 @@ <.header> {gettext("Listing Roles")} <:subtitle> - {gettext("Manage user roles and their permission sets.")} + {gettext("Manage roles and their permission sets.")} <:actions> <%= if can?(@current_user, :create, Mv.Authorization.Role) do %> diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 5232ec7..4f82405 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do />
    <% end %> - +
    @@ -128,7 +128,7 @@ defmodule MvWeb.UserLive.Form do required autocomplete="new-password" /> - + <%= if !@user do %> <.input @@ -179,7 +179,7 @@ defmodule MvWeb.UserLive.Form do <% end %> <% end %>
    - + <%= if @can_manage_member_linking do %>
    diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 055f36a..5a2f5fc 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -592,7 +592,6 @@ msgid "This email is already linked to a different OIDC account. Cannot link mul msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." #: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" @@ -1551,11 +1550,6 @@ msgstr "Rolle konnte nicht gelöscht werden: %{error}" msgid "Listing Roles" msgstr "Rollen auflisten" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Manage user roles and their permission sets." -msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze." - #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -2242,11 +2236,6 @@ msgstr "Fehler beim Lesen der hochgeladenen Datei" msgid "You do not have permission to access this page." msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Manage Member Data" -msgstr "Mitgliederdaten verwalten" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2283,7 +2272,6 @@ msgstr "Beitragsart auswählen" msgid "Linked" msgstr "Verknüpft" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2300,11 +2288,6 @@ msgstr "Nicht verknüpft" msgid "SSO / OIDC user" msgstr "SSO / OIDC Benutzer*in" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format -msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import aborted" @@ -2535,11 +2518,6 @@ msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert." msgid "Syncing..." msgstr "Synchronisiere..." -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht Integration" -msgstr "Vereinfacht-Integration" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." @@ -2945,11 +2923,6 @@ msgstr "Aus OIDC_ONLY" msgid "Only OIDC sign-in (hide password login)" msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." -msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button." - #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Clear filters" @@ -3135,6 +3108,7 @@ msgid "Deleting this data field cannot be undone. All datafield values for this msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle Datenfeldwerte für dieses Feld werden dauerhaft gelöscht." #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" msgstr "Individuelle Datenfelder" @@ -3623,3 +3597,38 @@ 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 "Accounting-Software (Vereinfacht) Integration" +msgstr "Buchhaltungs-Software (Vereinfacht) Integration" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Manage roles and their permission sets." +msgstr "Verwalte Rollen und ihre Berechtigungssätze." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC (Single Sign On)" +msgstr "OIDC (Single Sign On)" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgstr "Diese*r Benutzer*in ist über SSO (Single Sign On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single Sign On Button." + +#~ #: lib/mv_web/live/import_live/components.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Manage Member Data" +#~ msgstr "Mitgliederdaten verwalten" + +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Vereinfacht Integration" +#~ msgstr "Vereinfacht-Integration" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a1e0909..b5b80d7 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -593,7 +593,6 @@ msgid "This email is already linked to a different OIDC account. Cannot link mul msgstr "" #: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" @@ -1552,11 +1551,6 @@ msgstr "" msgid "Listing Roles" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Manage user roles and their permission sets." -msgstr "" - #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -2243,11 +2237,6 @@ msgstr "" msgid "You do not have permission to access this page." msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Manage Member Data" -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "Export members to CSV" @@ -2284,7 +2273,6 @@ msgstr "" msgid "Linked" msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2301,11 +2289,6 @@ msgstr "" msgid "SSO / OIDC user" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format -msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import aborted" @@ -2536,11 +2519,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht Integration" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." @@ -2945,11 +2923,6 @@ msgstr "" msgid "Only OIDC sign-in (hide password login)" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." -msgstr "" - #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Clear filters" @@ -3135,6 +3108,7 @@ msgid "Deleting this data field cannot be undone. All datafield values for this msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" msgstr "" @@ -3623,3 +3597,28 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Review by" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Accounting-Software (Vereinfacht) Integration" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Manage roles and their permission sets." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC (Single Sign On)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format +msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index eccae34..6b1bca6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -593,7 +593,6 @@ msgid "This email is already linked to a different OIDC account. Cannot link mul msgstr "" #: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" @@ -1552,11 +1551,6 @@ msgstr "" msgid "Listing Roles" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Manage user roles and their permission sets." -msgstr "" - #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -2243,11 +2237,6 @@ msgstr "" msgid "You do not have permission to access this page." msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Manage Member Data" -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2284,7 +2273,6 @@ msgstr "" msgid "Linked" msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2301,11 +2289,6 @@ msgstr "" msgid "SSO / OIDC user" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format -msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import aborted" @@ -2536,11 +2519,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht Integration" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." @@ -2945,11 +2923,6 @@ msgstr "" msgid "Only OIDC sign-in (hide password login)" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." -msgstr "" - #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Clear filters" @@ -3135,6 +3108,7 @@ msgid "Deleting this data field cannot be undone. All datafield values for this msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" msgstr "" @@ -3623,3 +3597,38 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Review by" msgstr "Review by" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Accounting-Software (Vereinfacht) Integration" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Manage roles and their permission sets." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC (Single Sign On)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgstr "" + +#~ #: lib/mv_web/live/import_live/components.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Manage Member Data" +#~ msgstr "" + +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Vereinfacht Integration" +#~ msgstr "" From ca9e4accc8093a0214b6dea4638157a582373b27 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:25:16 +0100 Subject: [PATCH 208/237] fix formatting --- lib/mv_web/live/user_live/form.ex | 6 +++--- priv/gettext/de/LC_MESSAGES/default.po | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 4f82405..27912af 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do />
    <% end %> - +
    - + <%= if @can_manage_member_linking do %>
    diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 5a2f5fc..b92b6b7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3611,17 +3611,17 @@ msgstr "Verwalte Rollen und ihre Berechtigungssätze." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "OIDC (Single Sign On)" -msgstr "OIDC (Single Sign On)" +msgstr "OIDC (Single Sign-On)" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "Diese*r Benutzer*in ist über SSO (Single Sign On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." +msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." -msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single Sign On Button." +msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single Sign-On-Button." #~ #: lib/mv_web/live/import_live/components.ex #~ #, elixir-autogen, elixir-format, fuzzy From 762402adf9da7bba825e36499ed2aaa965a21059 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:30:26 +0100 Subject: [PATCH 209/237] fix translations --- lib/mv_web/live/global_settings_live.ex | 4 ++-- lib/mv_web/live/user_live/form.ex | 8 ++++---- priv/gettext/de/LC_MESSAGES/default.po | 8 ++++---- priv/gettext/default.pot | 6 +++--- priv/gettext/en/LC_MESSAGES/default.po | 8 ++++---- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 7daa39a..c123f14 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -378,7 +378,7 @@ defmodule MvWeb.GlobalSettingsLive do <%!-- OIDC Section --%> - <.form_section title={gettext("OIDC (Single Sign On)")}> + <.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.")} @@ -486,7 +486,7 @@ defmodule MvWeb.GlobalSettingsLive do />

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

    diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 27912af..32ce493 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do />
    <% end %> - +
    @@ -128,7 +128,7 @@ defmodule MvWeb.UserLive.Form do required autocomplete="new-password" /> - + <%= if !@user do %> <.input @@ -179,7 +179,7 @@ defmodule MvWeb.UserLive.Form do <% end %> <% end %>
    - + <%= if @can_manage_member_linking do %>
    diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index b92b6b7..419fbd3 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3609,18 +3609,18 @@ msgid "Manage roles and their permission sets." msgstr "Verwalte Rollen und ihre Berechtigungssätze." #: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "OIDC (Single Sign On)" +#, elixir-autogen, elixir-format, fuzzy +msgid "OIDC (Single Sign-On)" msgstr "OIDC (Single Sign-On)" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format, fuzzy -msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in deinem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT deiner Organisation." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single Sign-On-Button." #~ #: lib/mv_web/live/import_live/components.ex diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b5b80d7..c9c9e54 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3610,15 +3610,15 @@ msgstr "" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "OIDC (Single Sign On)" +msgid "OIDC (Single Sign-On)" msgstr "" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format -msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." msgstr "" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6b1bca6..e6f6e9e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3609,18 +3609,18 @@ msgid "Manage roles and their permission sets." msgstr "" #: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "OIDC (Single Sign On)" +#, elixir-autogen, elixir-format, fuzzy +msgid "OIDC (Single Sign-On)" msgstr "" #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format, fuzzy -msgid "This user is linked via SSO (Single Sign On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." +msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." msgstr "" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy -msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign On button." +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" #~ #: lib/mv_web/live/import_live/components.ex From 03d91d40298960b21ce88caffe0a9d0d10bf1f80 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:40:32 +0100 Subject: [PATCH 210/237] fix tests --- lib/mv_web/live/user_live/form.ex | 6 +++--- test/mv_web/components/member_filter_component_test.exs | 2 +- test/mv_web/live/custom_field_live/deletion_test.exs | 2 +- test/mv_web/member_live/show_test.exs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 32ce493..5232ec7 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do />
    <% end %> - +
    - + <%= if @can_manage_member_linking do %>
    diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs index 485475a..d32993c 100644 --- a/test/mv_web/components/member_filter_component_test.exs +++ b/test/mv_web/components/member_filter_component_test.exs @@ -78,7 +78,7 @@ defmodule MvWeb.Components.MemberFilterComponentTest do html = render(view) # Should have both "Payments" and "Custom Fields" group labels assert html =~ gettext("Payments") || html =~ "Payment" - assert html =~ gettext("Custom Fields") + assert html =~ gettext("Individual datafields") end test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 962ada1..7b0953a 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -77,7 +77,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Edit mode: section titles must not reappear when modal opens (regression) refute has_element?(view, "h2", "Member fields") - refute has_element?(view, "h2", "Custom fields") + refute has_element?(view, "h2", "Individual datafields") # Should show correct member count (1 member) assert render(view) =~ "1 member has a value assigned for this datafield" diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index 54829de..c451be9 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -52,7 +52,7 @@ defmodule MvWeb.MemberLive.ShowTest do {:ok, _view, html} = live(conn, ~p"/members/#{member}") # Custom Fields section should be visible - assert html =~ gettext("Custom Fields") + assert html =~ gettext("Individual datafields") # Custom field label should be visible assert html =~ custom_field.name @@ -97,7 +97,7 @@ defmodule MvWeb.MemberLive.ShowTest do {:ok, _view, html} = live(conn, ~p"/members/#{member}") # Custom Fields section should be visible - assert html =~ gettext("Custom Fields") + assert html =~ gettext("Individual datafields") # Both field labels should be visible assert html =~ field1.name From 15d4c7d97fc886c615962552ad8af400f8ebf4cb Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 11 Mar 2026 11:50:24 +0100 Subject: [PATCH 211/237] fix import test --- test/mv_web/live/import_live_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs index bb907ce..09ec02c 100644 --- a/test/mv_web/live/import_live_test.exs +++ b/test/mv_web/live/import_live_test.exs @@ -238,7 +238,6 @@ defmodule MvWeb.ImportLiveTest do assert has_element?(view, "[data-testid='import-page']") assert has_element?(view, "[data-testid='csv-upload-form']") assert has_element?(view, "[data-testid='start-import-button']") - assert has_element?(view, "[data-testid='custom-fields-link']") end test "template links and file input are present", %{conn: conn} do From 160c35c0bafe71a10c1ce4d8c89f83b123d9628e Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 11 Mar 2026 12:15:22 +0100 Subject: [PATCH 212/237] fix release process --- .drone.yml | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/.drone.yml b/.drone.yml index 5442fe7..9f18072 100644 --- a/.drone.yml +++ b/.drone.yml @@ -219,24 +219,8 @@ trigger: - main event: - push - - tag steps: - - name: build-and-publish-container - image: plugins/docker - settings: - registry: git.local-it.org - repo: git.local-it.org/local-it/mitgliederverwaltung - username: - from_secret: DRONE_REGISTRY_USERNAME - password: - from_secret: DRONE_REGISTRY_TOKEN - auto_tag: true - auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8} - when: - event: - - tag - - name: build-and-publish-container-branch image: plugins/docker settings: @@ -256,6 +240,33 @@ steps: depends_on: - check-fast +--- +kind: pipeline +type: docker +name: build-and-release + +trigger: + event: + - tag + +steps: + - name: build-and-publish-container + image: plugins/docker + settings: + registry: git.local-it.org + repo: git.local-it.org/local-it/mitgliederverwaltung + username: + from_secret: DRONE_REGISTRY_USERNAME + password: + from_secret: DRONE_REGISTRY_TOKEN + auto_tag: true + when: + event: + - tag + +depends_on: + - check-fast + --- kind: pipeline type: docker From a4f3aa5d6ff6ee903f1a2157d7f1941045b09b52 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 12 Mar 2026 13:39:48 +0100 Subject: [PATCH 213/237] 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 214/237] 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 215/237] 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 216/237] 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 217/237] 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 218/237] 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 219/237] 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 220/237] 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 221/237] 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 222/237] 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 223/237] 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 224/237] 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 225/237] 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 226/237] 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 227/237] 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 228/237] 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)}