OIDC-only sign-in, Vereinfacht connection test, locale defaults, and settings/docs cleanup #445

Merged
moritz merged 17 commits from feature/settings into main 2026-02-24 15:51:51 +01:00
6 changed files with 389 additions and 9 deletions
Showing only changes of commit f29bbb02a2 - Show all commits

View file

@ -10,6 +10,54 @@ defmodule Mv.Vereinfacht.Client do
@content_type "application/vnd.api+json" @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 """ @doc """
Creates a finance contact in Vereinfacht for the given member. 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" => [%{"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(%{"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_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) defp extract_error_message(other), do: inspect(other)
end end

View file

@ -14,6 +14,27 @@ defmodule Mv.Vereinfacht do
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Helpers 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 """ @doc """
Syncs a single member to Vereinfacht (create or update finance contact). Syncs a single member to Vereinfacht (create or update finance contact).

View file

@ -51,6 +51,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?()) |> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|> assign(:last_vereinfacht_sync_result, nil) |> assign(:last_vereinfacht_sync_result, nil)
|> assign(:vereinfacht_test_result, nil)
|> assign_form() |> assign_form()
{:ok, socket} {:ok, socket}
@ -167,15 +168,29 @@ defmodule MvWeb.GlobalSettingsLive do
> >
{gettext("Save Vereinfacht Settings")} {gettext("Save Vereinfacht Settings")}
</.button> </.button>
<.button <div class="mt-2 flex flex-wrap gap-2">
:if={Mv.Config.vereinfacht_configured?()} <.button
type="button" :if={Mv.Config.vereinfacht_configured?()}
phx-click="sync_vereinfacht_contacts" type="button"
phx-disable-with={gettext("Syncing...")} phx-click="test_vereinfacht_connection"
class="mt-4 btn-outline" phx-disable-with={gettext("Testing...")}
> class="btn-outline"
{gettext("Sync all members without Vereinfacht contact")} >
</.button> {gettext("Test Integration")}
</.button>
<.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")}
</.button>
</div>
<%= if @vereinfacht_test_result do %>
<.vereinfacht_test_result result={@vereinfacht_test_result} />
<% end %>
<%= if @last_vereinfacht_sync_result do %> <%= if @last_vereinfacht_sync_result do %>
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
<% end %> <% end %>
@ -207,6 +222,12 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end 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 @impl true
def handle_event("sync_vereinfacht_contacts", _params, socket) do def handle_event("sync_vereinfacht_contacts", _params, socket) do
case Mv.Vereinfacht.sync_members_without_contact() do case Mv.Vereinfacht.sync_members_without_contact() do
@ -246,15 +267,20 @@ defmodule MvWeb.GlobalSettingsLive do
actor = MvWeb.LiveHelpers.current_actor(socket) actor = MvWeb.LiveHelpers.current_actor(socket)
# Never send blank API key so we do not overwrite the stored secret (security) # Never send blank API key so we do not overwrite the stored secret (security)
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params) 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 case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
{:ok, fresh_settings} = Membership.get_settings() {:ok, fresh_settings} = Membership.get_settings()
test_result =
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:vereinfacht_test_result, test_result)
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()
@ -265,6 +291,12 @@ defmodule MvWeb.GlobalSettingsLive do
end end
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 defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
case params do case params do
%{"vereinfacht_api_key" => v} when v in [nil, ""] -> %{"vereinfacht_api_key" => v} when v in [nil, ""] ->
@ -412,6 +444,109 @@ defmodule MvWeb.GlobalSettingsLive do
Gettext.dgettext(MvWeb.Gettext, "default", message) Gettext.dgettext(MvWeb.Gettext, "default", message)
end end
attr :result, :any, required: true
defp vereinfacht_test_result(%{result: {:ok, :connected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
<.icon name="hero-check-circle" class="size-5 shrink-0" />
<span>{gettext("Connection successful. API URL, API Key and Club ID are valid.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<span>{gettext("Not configured. Please set API URL, API Key and Club ID.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>{gettext("Connection failed (HTTP 401): API key is invalid or missing.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do
assigns = assign(assigns, :status, status)
assigns = assign(assigns, :message, message)
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext("Connection failed (HTTP %{status}):", status: @status)}
<span class="ml-1">{@message}</span>
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Connection failed. Could not reach the API (network error or wrong URL).")}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Connection failed. Unknown error.")}</span>
</div>
"""
end
attr :result, :map, required: true attr :result, :map, required: true
defp vereinfacht_sync_result(assigns) do defp vereinfacht_sync_result(assigns) do

View file

@ -2682,6 +2682,61 @@ msgstr "Vereinfacht-Integration"
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." 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." 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"

View file

@ -2683,6 +2683,61 @@ msgstr ""
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr "" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"

View file

@ -2683,6 +2683,61 @@ msgstr ""
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr "" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"