From 2443bc62ac6d726340fdd10a4fbc063d75884b82 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 7 May 2026 12:13:45 +0200 Subject: [PATCH] feat: Improve handling of association name config --- lib/membership/membership.ex | 12 +++++ lib/mv/config.ex | 21 +++++++++ lib/mv_web/components/layouts.ex | 7 ++- lib/mv_web/live/global_settings_live.ex | 43 +++++++++++++++++- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++ priv/gettext/default.pot | 10 +++++ priv/gettext/en/LC_MESSAGES/default.po | 10 +++++ test/mv_web/components/layouts_test.exs | 26 +++++++++++ .../mv_web/live/global_settings_live_test.exs | 44 +++++++++++++++++++ 9 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 test/mv_web/components/layouts_test.exs diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7fa35dc..ffe7703 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -178,6 +178,18 @@ defmodule Mv.Membership do end end + @doc """ + Invalidates the global settings cache. + + This should be used by callers that update settings through paths outside of + `update_settings/2` (for example, custom form submit flows) to keep reads via + `get_settings/0` consistent across views. + """ + @spec invalidate_settings_cache() :: :ok + def invalidate_settings_cache do + SettingsCache.invalidate() + end + @doc """ Lists only required custom fields. diff --git a/lib/mv/config.ex b/lib/mv/config.ex index c807193..33c92cf 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -143,6 +143,27 @@ defmodule Mv.Config do |> parse_and_validate_integer(default) end + # --------------------------------------------------------------------------- + # Association name + # ENV variable takes priority; fallback to Settings from database. + # --------------------------------------------------------------------------- + + @doc """ + Returns the association name. + + Reads from `ASSOCIATION_NAME` env first, then from Settings. + """ + @spec association_name() :: String.t() | nil + def association_name do + env_or_setting("ASSOCIATION_NAME", :club_name) + end + + @doc """ + Returns true if ASSOCIATION_NAME is set (field is read-only in Settings). + """ + @spec association_name_env_set?() :: boolean() + def association_name_env_set?, do: env_set?("ASSOCIATION_NAME") + # --------------------------------------------------------------------------- # Vereinfacht accounting software integration # ENV variables take priority; fallback to Settings from database. diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 9aff23c..c6e3e01 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -135,8 +135,11 @@ defmodule MvWeb.Layouts do slot :inner_block, required: true def app(assigns) do - # Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query. - %{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings() + # Single settings read for layout defaults. + # Use an explicitly provided club_name as source of truth to avoid stale + # values from cache reads immediately after a settings update in LiveViews. + %{club_name: fallback_club_name, join_form_enabled: join_form_enabled} = get_layout_settings() + club_name = assigns[:club_name] || fallback_club_name # NOTE: Unprocessed count runs on every page load when join form is enabled; consider # loading only on navigation or caching briefly if performance becomes an issue. diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 983f075..228970a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -65,6 +65,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:settings, settings) |> assign(:locale, locale) |> assign(:environment, environment) + |> assign(:association_name_env_set, Mv.Config.association_name_env_set?()) |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) @@ -125,6 +126,13 @@ defmodule MvWeb.GlobalSettingsLive do
<%!-- Club Settings Section --%> <.form_section title={gettext("Club Settings")}> + <%= if @association_name_env_set do %> +

+ {gettext( + "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." + )} +

+ <% end %> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input @@ -132,10 +140,18 @@ defmodule MvWeb.GlobalSettingsLive do type="text" label={gettext("Association Name")} required + disabled={@association_name_env_set} + placeholder={ + if(@association_name_env_set, do: gettext("From ASSOCIATION_NAME"), else: nil) + } />
- <.button phx-disable-with={gettext("Saving...")} variant="primary"> + <.button + :if={not @association_name_env_set} + phx-disable-with={gettext("Saving...")} + variant="primary" + > {gettext("Save Name")} @@ -919,6 +935,7 @@ defmodule MvWeb.GlobalSettingsLive do # Never send blank API key / client secret / smtp password so we do not overwrite stored secrets setting_params_clean = setting_params + |> drop_env_managed_association_name() |> drop_blank_vereinfacht_api_key() |> drop_blank_oidc_client_secret() |> drop_blank_smtp_password() @@ -927,6 +944,10 @@ defmodule MvWeb.GlobalSettingsLive do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do {:ok, updated_settings} -> + # Keep cross-view reads consistent after settings updates (layouts/sidebar + # read via Membership.get_settings/0). + Membership.invalidate_settings_cache() + # Use the returned record for the form so saved values show immediately; # get_settings() can return cached data without the new attribute until reload. test_result = @@ -1195,10 +1216,19 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_env_managed_association_name(params) when is_map(params) do + if Mv.Config.association_name_env_set?() do + Map.delete(params, "club_name") + else + params + end + end + defp assign_form(%{assigns: %{settings: settings}} = socket) do - # Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form + # Show ENV values in disabled fields (Association Name, Vereinfacht, OIDC, SMTP); never expose secrets in form settings_display = settings + |> merge_association_env_values() |> merge_vereinfacht_env_values() |> merge_oidc_env_values() |> merge_smtp_env_values() @@ -1225,6 +1255,15 @@ defmodule MvWeb.GlobalSettingsLive do defp put_if_env_set(map, _key, false, _value), do: map defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value) + defp merge_association_env_values(s) do + put_if_env_set( + s, + :club_name, + Mv.Config.association_name_env_set?(), + Mv.Config.association_name() + ) + end + defp merge_vereinfacht_env_values(s) do s |> put_if_env_set( diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 1482490..31830ce 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3927,3 +3927,13 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "Der Vereinsname wird über die Umgebungsvariable ASSOCIATION_NAME gesetzt. Dieses Feld ist schreibgeschützt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "Aus ASSOCIATION_NAME" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 21e7b16..586d1f4 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3927,3 +3927,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7d94b7c..6b8db53 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3927,3 +3927,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "From ASSOCIATION_NAME" diff --git a/test/mv_web/components/layouts_test.exs b/test/mv_web/components/layouts_test.exs new file mode 100644 index 0000000..411750b --- /dev/null +++ b/test/mv_web/components/layouts_test.exs @@ -0,0 +1,26 @@ +defmodule MvWeb.LayoutsTest do + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Membership + alias MvWeb.Layouts + + describe "app/1" do + test "prefers provided club_name over settings fallback" do + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{club_name: "Settings Club Name"}) + + html = + render_component(&Layouts.app/1, %{ + flash: %{}, + current_user: nil, + club_name: "Provided Club Name", + inner_block: [%{inner_block: fn _, _ -> "content" end}] + }) + + assert html =~ "Provided Club Name" + refute html =~ "Settings Club Name" + end + end +end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 37c4e38..8d2963c 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -13,6 +13,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do "MAIL_FROM_NAME", "MAIL_FROM_EMAIL" ] + @association_env_key "ASSOCIATION_NAME" describe "Global Settings LiveView" do setup %{conn: conn} do @@ -51,6 +52,17 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert render(view) =~ "Updated Club Name" end + test "updated club name is shown after remount", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + assert view + |> form("#settings-form", %{setting: %{club_name: "Remount Club Name"}}) + |> render_submit() + + {:ok, _view_after_remount, html_after_remount} = live(conn, ~p"/settings") + assert html_after_remount =~ "Remount Club Name" + end + test "shows error when club_name is empty", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/settings") @@ -90,6 +102,34 @@ defmodule MvWeb.GlobalSettingsLiveTest do "Open" ) end + + @tag :ui + test "disables association name input when ASSOCIATION_NAME is set", %{conn: conn} do + clear_association_name_env() + System.put_env(@association_env_key, "Association Name from ENV") + on_exit(fn -> clear_association_name_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "#setting_club_name[disabled]") + assert has_element?(view, "#setting_club_name[placeholder='From ASSOCIATION_NAME']") + refute has_element?(view, "#settings-form button", "Save Name") + assert render(view) =~ "Association name is set via environment variable ASSOCIATION_NAME" + end + + @tag :ui + test "keeps association name input editable when ASSOCIATION_NAME is not set", %{conn: conn} do + clear_association_name_env() + on_exit(fn -> clear_association_name_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + refute has_element?(view, "#setting_club_name[disabled]") + assert has_element?(view, "#settings-form button", "Save Name") + + refute render(view) =~ + "Association name is set via environment variable ASSOCIATION_NAME" + end end describe "SMTP / E-Mail section" do @@ -270,4 +310,8 @@ defmodule MvWeb.GlobalSettingsLiveTest do defp clear_smtp_env do Enum.each(@smtp_env_keys, &System.delete_env/1) end + + defp clear_association_name_env do + System.delete_env(@association_env_key) + end end