From a25263b7219104dec2b01e434969ccf69a62df82 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 17 Feb 2026 19:29:49 +0100 Subject: [PATCH 001/113] 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/113] 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 b5fc03e94f524c7e6c0759d87324f13bb34cde51 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:10:46 +0100 Subject: [PATCH 003/113] 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 004/113] 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 3491b4b1ba357c8aaa7cde76398744c00959c5c8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:14 +0100 Subject: [PATCH 005/113] 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 006/113] 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 007/113] 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 008/113] 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 009/113] 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 010/113] 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 ec814a8c94fc245de6de21a93c8135596bbb8b9f Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:09:37 +0100 Subject: [PATCH 011/113] 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 012/113] 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 095/113] 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 096/113] 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 097/113] 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 098/113] 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 099/113] 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 100/113] 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 101/113] 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 102/113] 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 103/113] 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 104/113] 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 105/113] 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 106/113] 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 107/113] 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 108/113] 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 109/113] 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 110/113] 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 111/113] 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 112/113] 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 %>