From a25263b7219104dec2b01e434969ccf69a62df82 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 17 Feb 2026 19:29:49 +0100 Subject: [PATCH 01/45] 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 02/45] 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 03/45] 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 04/45] formatting --- lib/mv_web/controllers/auth_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index fb760e6..d9690df 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -105,6 +105,7 @@ defmodule MvWeb.AuthController do end defp format_meta_string([]), do: "" + defp format_meta_string(meta) when is_list(meta) do parts = Enum.map(meta, fn From e47e266570d29f9f481e993b046d42309236f21c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:42:54 +0100 Subject: [PATCH 05/45] feat: type not editable --- lib/membership/custom_field.ex | 20 +++- .../live/custom_field_live/form_component.ex | 61 +++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 5 + priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 5 + .../custom_field_validation_test.exs | 98 +++++++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ab4ad60..a1f564e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) + - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters + - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update] + defaults [:read] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -68,6 +69,21 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end + update :update do + accept [:name, :description, :required, :show_in_overview] + require_atomic? false + + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :value_type) do + {:error, + field: :value_type, + message: "cannot be changed after creation"} + else + :ok + end + end + end + destroy :destroy_with_values do primary? true end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index b809a1a..9f61ba3 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types + - Select value type from supported types (only on create; immutable after creation) - Set required flag - Real-time validation @@ -44,15 +44,36 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> + <%= if @custom_field do %> + <%!-- Show value_type as read-only text when editing --%> +
+ +
+ {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} +
+ +
+ <% else %> + <%!-- Show value_type as select when creating --%> + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ + :one_of + ] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> + <% end %> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -85,8 +106,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} end @impl true @@ -94,7 +123,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6dbb732..0d661cf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,6 +2604,11 @@ msgstr "PDF" msgid "Import" msgstr "Import" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index df282f3..0aef1b3 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,3 +2604,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 56f897d..371a028 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,6 +2605,11 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d0711ad..e642d82 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields + - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end + + describe "value_type immutability" do + test "rejects attempt to change value_type after creation", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Attempt to update value_type to :integer + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + value_type: :integer + }) + |> Ash.update(actor: actor) + + # Verify error message contains expected text + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :string + end + + test "allows updating other fields while value_type remains unchanged", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: "Original description" + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Update other fields (name, description) without touching value_type + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + description: "Updated description" + }) + |> Ash.update(actor: actor) + + # Verify value_type remained unchanged + assert updated_custom_field.value_type == original_value_type + assert updated_custom_field.value_type == :string + # Verify other fields were updated + assert updated_custom_field.name == "updated_name" + assert updated_custom_field.description == "Updated description" + end + + test "rejects value_type change even when other fields are updated", %{actor: actor} do + # Create custom field with value_type :boolean + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :boolean + + # Attempt to update both name and value_type + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + value_type: :date + }) + |> Ash.update(actor: actor) + + # Verify error message + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged, but name was not updated either + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :boolean + assert reloaded.name == "test_field" + end + end end From 9b1aad884ee86cb50c32fda118786c88d65fd947 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 17:01:43 +0100 Subject: [PATCH 06/45] style: use same disabled field as for memberfield --- lib/membership/custom_field.ex | 4 +- .../live/custom_field_live/form_component.ex | 40 +++++++++++++------ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index a1f564e..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -75,9 +75,7 @@ defmodule Mv.Membership.CustomField do validate fn changeset, _context -> if Ash.Changeset.changing_attribute?(changeset, :value_type) do - {:error, - field: :value_type, - message: "cannot be changed after creation"} + {:error, field: :value_type, message: "cannot be changed after creation"} else :ok end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 9f61ba3..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -45,19 +45,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do <.input field={@form[:name]} type="text" label={gettext("Name")} /> <%= if @custom_field do %> - <%!-- Show value_type as read-only text when editing --%> -
- -
- {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} -
- + <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> +
+
+ +
<% else %> <%!-- Show value_type as select when creating --%> From 0333f9e722c37d42552798436201682f5a301a45 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 08:55:55 +0100 Subject: [PATCH 07/45] fix: tests failing in ci --- .../live/components/sort_header_component.ex | 7 -- lib/mv_web/live/member_live/index.ex | 81 ++++++++++--------- .../components/sort_header_component_test.exs | 4 +- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} - phx-target={@myself} data-testid={@field} > {@label} @@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - send(self(), {:sort, field_str}) - {:noreply, socket} - end - # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 59ee8f9..b370e9a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -163,6 +163,7 @@ defmodule MvWeb.MemberLive.Index do - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members + - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("delete", %{"id" => id}, socket) do @@ -305,6 +306,46 @@ defmodule MvWeb.MemberLive.Index do end end + @impl true + def handle_event("sort", %{"field" => field_str}, socket) do + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + + {new_field, new_order} = determine_new_sort(field, socket) + old_field = socket.assigns.sort_field + + socket = + socket + |> assign(:sort_field, new_field) + |> assign(:sort_order, new_order) + |> update_sort_components(old_field, new_field, new_order) + |> load_members() + |> update_selection_assigns() + + # URL sync - push_patch happens synchronously in the event handler + query_params = + build_query_params( + socket.assigns.query, + export_sort_field(socket.assigns.sort_field), + export_sort_order(socket.assigns.sort_order), + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + end + # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = @@ -329,50 +370,10 @@ defmodule MvWeb.MemberLive.Index do Handles messages from child components. ## Supported messages: - - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ - @impl true - def handle_info({:sort, field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - - {new_field, new_order} = determine_new_sort(field, socket) - old_field = socket.assigns.sort_field - - socket = - socket - |> assign(:sort_field, new_field) - |> assign(:sort_order, new_order) - |> update_sort_components(old_field, new_field, new_order) - |> load_members() - |> update_selection_assigns() - - # URL sync - query_params = - build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} - end @impl true def handle_info({:search_changed, q}, socket) do diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 6d23ab4..bdde4ae 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking sends sort message to parent", %{conn: conn} do + test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component should send a message to the parent LiveView + # The component triggers a "sort" event on the parent LiveView # This is tested indirectly through the URL change in integration tests end From 0fd1b7e142e4948bd445126e235e22b1fff08164 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 09:40:02 +0100 Subject: [PATCH 08/45] fix testsand load performance --- lib/mv_web/live/member_live/index.ex | 11 ++++------- test/mv_web/member_live/index_test.exs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b370e9a..d391cd2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) - custom_fields_visible = - Mv.Membership.CustomField - |> Ash.Query.filter(expr(show_in_overview == true)) - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: actor) - - # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + custom_fields_visible = + all_custom_fields + |> Enum.filter(& &1.show_in_overview) + # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 4f36795..53a2815 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query From 3491b4b1ba357c8aaa7cde76398744c00959c5c8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:14 +0100 Subject: [PATCH 09/45] 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 10/45] 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 11/45] 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 12/45] 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 13/45] 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 14/45] fix linting --- lib/mv/membership/member_export/build.ex | 40 ++++++++++------ .../controllers/member_export_controller.ex | 48 ++++++++++--------- lib/mv_web/live/member_live/index.ex | 2 +- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ff8cf76..9e0cc7b 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -272,24 +272,34 @@ defmodule Mv.Membership.MemberExport.Build do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field), do: members - - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) - end - - members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + sort_by_custom_field_value(members, field, order, custom_fields) end end + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + + if is_nil(custom_field) do + members + else + sort_members_with_custom_field(members, custom_field, order) + end + end + + defp sort_members_with_custom_field(members, custom_field, order) do + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + defp sort_members_by_groups_export(members, order) do # Members with groups first, then by first group name alphabetically (min = first by sort order) # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 4ed8f2d..08bcba7 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -380,33 +380,37 @@ defmodule MvWeb.MemberExportController do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) + sort_by_custom_field_value(members, field, order, custom_fields) + end + end - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) - if is_nil(custom_field) do - members - else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) - sorted_with_values ++ without_values - end + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values end end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 9d93b08..218fa6f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -686,6 +686,7 @@ defmodule MvWeb.MemberLive.Index do socket = if socket.assigns[:sort_needs_update] do old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) |> assign(:sort_needs_update, false) @@ -1066,7 +1067,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp apply_sort_to_query(query, field, order) do cond do # Groups sort -> after load (in memory) From f4554b8a4bd2b8587f0a0b5211220c128e8a1069 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 14:01:14 +0100 Subject: [PATCH 15/45] docs: update Code Guidelines with issues from meta review analysis --- CODE_GUIDELINES.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index cc58ca9..70e1596 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)} ### 2.3 Error Handling +**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging. + **Use Tagged Tuples:** ```elixir @@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do end ``` +**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle. + +**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes. + **Component Design:** ```elixir @@ -1267,6 +1273,9 @@ gettext("Welcome to Mila") # With interpolation gettext("Hello, %{name}!", name: user.name) +# Plural: always pass count binding when message uses %{count} +ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) + # Domain-specific translations dgettext("auth", "Sign in with email") ``` @@ -1507,6 +1516,8 @@ defmodule MvWeb.MemberLive.IndexTest do end ``` +**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing). + #### 4.3.5 Component Tests Test function components: @@ -1876,6 +1887,8 @@ policies do end ``` +**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth. + **Actor Handling in LiveViews:** Always use the `current_actor/1` helper for consistent actor access: @@ -2707,7 +2720,9 @@ Building accessible applications ensures that all users, including those with di ### 8.2 ARIA Labels and Roles -**Use ARIA Attributes When Necessary:** +**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs. + +**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide. ```heex @@ -2931,11 +2946,11 @@ end **Announce Dynamic Content:** ```heex - +
<%= if @searched do %> - <%= ngettext("Found %{count} member", "Found %{count} members", @count) %> + <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %> <% end %>
From ec814a8c94fc245de6de21a93c8135596bbb8b9f Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:09:37 +0100 Subject: [PATCH 16/45] 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 17/45] 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 %>