From f29bbb02a2942b10c9a2bed81f2f899a64105e1e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 13:08:13 +0100 Subject: [PATCH] feat: add Vereinfacht connection test button to settings --- lib/mv/vereinfacht/client.ex | 59 +++++++++ lib/mv/vereinfacht/vereinfacht.ex | 21 ++++ lib/mv_web/live/global_settings_live.ex | 153 ++++++++++++++++++++++-- priv/gettext/de/LC_MESSAGES/default.po | 55 +++++++++ priv/gettext/default.pot | 55 +++++++++ priv/gettext/en/LC_MESSAGES/default.po | 55 +++++++++ 6 files changed, 389 insertions(+), 9 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 58e06a9..6ec8c8c 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -10,6 +10,54 @@ defmodule Mv.Vereinfacht.Client do @content_type "application/vnd.api+json" + @doc """ + Tests the connection to the Vereinfacht API with the given credentials. + + Makes a lightweight `GET /finance-contacts?page[size]=1` request to verify + that the API URL, API key, and club ID are valid and reachable. + + ## Returns + - `{:ok, :connected}` – credentials are valid (HTTP 200) + - `{:error, :not_configured}` – any parameter is nil or blank + - `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403) + - `{:error, {:request_failed, reason}}` – network/transport error + + ## Examples + + iex> test_connection("https://api.example.com/api/v1", "token", "2") + {:ok, :connected} + + iex> test_connection(nil, "token", "2") + {:error, :not_configured} + """ + @spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: + {:ok, :connected} | {:error, term()} + def test_connection(api_url, api_key, club_id) do + if blank?(api_url) or blank?(api_key) or blank?(club_id) do + {:error, :not_configured} + else + url = + api_url + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts?page[size]=1") + + case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do + {:ok, %{status: 200}} -> + {:ok, :connected} + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + end + + defp blank?(nil), do: true + defp blank?(s) when is_binary(s), do: String.trim(s) == "" + defp blank?(_), do: true + @doc """ Creates a finance contact in Vereinfacht for the given member. @@ -360,5 +408,16 @@ defmodule Mv.Vereinfacht.Client do defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t defp extract_error_message(body) when is_map(body), do: inspect(body) + + defp extract_error_message(body) when is_binary(body) do + trimmed = String.trim(body) + + if String.starts_with?(trimmed, "<") do + :html_response + else + trimmed + end + end + defp extract_error_message(other), do: inspect(other) end diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index ce8005d..6520b64 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -14,6 +14,27 @@ defmodule Mv.Vereinfacht do alias Mv.Helpers.SystemActor alias Mv.Helpers + @doc """ + Tests the connection to the Vereinfacht API using the current configuration. + + Delegates to `Mv.Vereinfacht.Client.test_connection/3` with the values from + `Mv.Config` (ENV variables take priority over database settings). + + ## Returns + - `{:ok, :connected}` – credentials are valid and API is reachable + - `{:error, :not_configured}` – URL, API key or club ID is missing + - `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403) + - `{:error, {:request_failed, reason}}` – network/transport error + """ + @spec test_connection() :: {:ok, :connected} | {:error, term()} + def test_connection do + Client.test_connection( + Mv.Config.vereinfacht_api_url(), + Mv.Config.vereinfacht_api_key(), + Mv.Config.vereinfacht_club_id() + ) + end + @doc """ Syncs a single member to Vereinfacht (create or update finance contact). diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index b841931..c710f79 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -51,6 +51,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?()) |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) |> assign(:last_vereinfacht_sync_result, nil) + |> assign(:vereinfacht_test_result, nil) |> assign_form() {:ok, socket} @@ -167,15 +168,29 @@ defmodule MvWeb.GlobalSettingsLive do > {gettext("Save Vereinfacht Settings")} - <.button - :if={Mv.Config.vereinfacht_configured?()} - type="button" - phx-click="sync_vereinfacht_contacts" - phx-disable-with={gettext("Syncing...")} - class="mt-4 btn-outline" - > - {gettext("Sync all members without Vereinfacht contact")} - +
+ <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + phx-click="test_vereinfacht_connection" + phx-disable-with={gettext("Testing...")} + class="btn-outline" + > + {gettext("Test Integration")} + + <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + phx-click="sync_vereinfacht_contacts" + phx-disable-with={gettext("Syncing...")} + class="btn-outline" + > + {gettext("Sync all members without Vereinfacht contact")} + +
+ <%= if @vereinfacht_test_result do %> + <.vereinfacht_test_result result={@vereinfacht_test_result} /> + <% end %> <%= if @last_vereinfacht_sync_result do %> <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> <% end %> @@ -207,6 +222,12 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end + @impl true + def handle_event("test_vereinfacht_connection", _params, socket) do + result = Mv.Vereinfacht.test_connection() + {:noreply, assign(socket, :vereinfacht_test_result, result)} + end + @impl true def handle_event("sync_vereinfacht_contacts", _params, socket) do case Mv.Vereinfacht.sync_members_without_contact() do @@ -246,15 +267,20 @@ defmodule MvWeb.GlobalSettingsLive do actor = MvWeb.LiveHelpers.current_actor(socket) # Never send blank API key so we do not overwrite the stored secret (security) setting_params_clean = drop_blank_vereinfacht_api_key(setting_params) + saves_vereinfacht = vereinfacht_params?(setting_params_clean) case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do {:ok, _updated_settings} -> {:ok, fresh_settings} = Membership.get_settings() + test_result = + if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil + socket = socket |> assign(:settings, fresh_settings) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) + |> assign(:vereinfacht_test_result, test_result) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() @@ -265,6 +291,12 @@ defmodule MvWeb.GlobalSettingsLive do end end + @vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url] + + defp vereinfacht_params?(params) when is_map(params) do + Enum.any?(@vereinfacht_param_keys, &Map.has_key?(params, &1)) + end + defp drop_blank_vereinfacht_api_key(params) when is_map(params) do case params do %{"vereinfacht_api_key" => v} when v in [nil, ""] -> @@ -412,6 +444,109 @@ defmodule MvWeb.GlobalSettingsLive do Gettext.dgettext(MvWeb.Gettext, "default", message) end + attr :result, :any, required: true + + defp vereinfacht_test_result(%{result: {:ok, :connected}} = assigns) do + ~H""" +
+ <.icon name="hero-check-circle" class="size-5 shrink-0" /> + {gettext("Connection successful. API URL, API Key and Club ID are valid.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do + ~H""" +
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> + {gettext("Not configured. Please set API URL, API Key and Club ID.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + {gettext("Connection failed (HTTP 401): API key is invalid or missing.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do + assigns = assign(assigns, :status, status) + assigns = assign(assigns, :message, message) + + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext("Connection failed (HTTP %{status}):", status: @status)} + {@message} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("Connection failed. Could not reach the API (network error or wrong URL).")} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + {gettext("Connection failed. Unknown error.")} +
+ """ + end + attr :result, :map, required: true defp vereinfacht_sync_result(assigns) do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 2f4c1b8..c0672c5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2682,6 +2682,61 @@ msgstr "Vereinfacht-Integration" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "Integration testen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "Wird getestet..." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "Verbindung erfolgreich. API-URL, API-Schlüssel und Vereins-ID sind korrekt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "Nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "Verbindung fehlgeschlagen (HTTP %{status}):" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "Verbindung fehlgeschlagen (HTTP 401): API-Schlüssel ist ungültig oder fehlt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "Verbindung fehlgeschlagen (HTTP 403): Zugriff verweigert. Bitte Vereins-ID und Berechtigungen des API-Schlüssels prüfen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "Verbindung fehlgeschlagen (HTTP 404): API-Endpunkt nicht gefunden. Bitte die API-URL prüfen (z. B. korrekter Versionspfad)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "Verbindung fehlgeschlagen. Die URL zeigt nicht auf eine Vereinfacht-API (HTML statt JSON erhalten)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "Verbindung fehlgeschlagen. API nicht erreichbar (Netzwerkfehler oder falsche URL)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "Verbindung fehlgeschlagen. Unbekannter Fehler." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 98c2b91..e26b874 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2683,6 +2683,61 @@ msgstr "" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a76c9f6..839a43b 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2683,6 +2683,61 @@ msgstr "" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht"