Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
carla 2026-02-24 16:02:56 +01:00
commit bfc078d5aa
45 changed files with 2187 additions and 425 deletions

View file

@ -262,13 +262,44 @@ defmodule Mv.Config do
end
end
defp env_or_setting_bool(env_key, setting_key) do
case System.get_env(env_key) do
nil ->
get_from_settings_bool(setting_key)
value when is_binary(value) ->
v = String.trim(value) |> String.downcase()
v in ["true", "1", "yes"]
_ ->
false
end
end
defp get_vereinfacht_from_settings(key) do
get_from_settings(key)
end
defp get_from_settings(key) do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
{:error, _} -> nil
end
end
defp get_from_settings_bool(key) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
case Map.get(settings, key) do
true -> true
_ -> false
end
{:error, _} ->
false
end
end
defp trim_nil(nil), do: nil
defp trim_nil(s) when is_binary(s) do
@ -298,4 +329,105 @@ defmodule Mv.Config do
defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
# ---------------------------------------------------------------------------
# OIDC authentication
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the OIDC client ID. ENV first, then Settings.
"""
@spec oidc_client_id() :: String.t() | nil
def oidc_client_id do
env_or_setting("OIDC_CLIENT_ID", :oidc_client_id)
end
@doc """
Returns the OIDC provider base URL. ENV first, then Settings.
"""
@spec oidc_base_url() :: String.t() | nil
def oidc_base_url do
env_or_setting("OIDC_BASE_URL", :oidc_base_url)
end
@doc """
Returns the OIDC redirect URI. ENV first, then Settings.
"""
@spec oidc_redirect_uri() :: String.t() | nil
def oidc_redirect_uri do
env_or_setting("OIDC_REDIRECT_URI", :oidc_redirect_uri)
end
@doc """
Returns the OIDC client secret. ENV first, then Settings.
"""
@spec oidc_client_secret() :: String.t() | nil
def oidc_client_secret do
env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
end
@doc """
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
"""
@spec oidc_admin_group_name() :: String.t() | nil
def oidc_admin_group_name do
env_or_setting("OIDC_ADMIN_GROUP_NAME", :oidc_admin_group_name)
end
@doc """
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
"""
@spec oidc_groups_claim() :: String.t() | nil
def oidc_groups_claim do
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
nil -> "groups"
v -> v
end
end
@doc """
Returns true if any OIDC ENV variable is set (used to show hint in Settings UI).
"""
@spec oidc_env_configured?() :: boolean()
def oidc_env_configured? do
oidc_client_id_env_set?() or oidc_base_url_env_set?() or
oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or
oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() or
oidc_only_env_set?()
end
@doc """
Returns true when OIDC is configured and can be used for sign-in (client ID, base URL,
redirect URI, and client secret must be set). Used to show or hide the Single Sign-On button on the
sign-in page. Without client secret, the OIDC flow fails with MissingSecret; without redirect_uri,
the OIDC Plug crashes with URI.new(nil).
"""
@spec oidc_configured?() :: boolean()
def oidc_configured? do
id = oidc_client_id()
base = oidc_base_url()
secret = oidc_client_secret()
redirect = oidc_redirect_uri()
present = &(is_binary(&1) and String.trim(&1) != "")
present.(id) and present.(base) and present.(secret) and present.(redirect)
end
@doc """
Returns true when only OIDC sign-in should be shown (password login hidden).
ENV OIDC_ONLY first (true/1/yes vs false/0/no), then Settings.oidc_only.
Only has effect when OIDC is configured; when false or OIDC not configured, both password and OIDC are shown as usual.
"""
@spec oidc_only?() :: boolean()
def oidc_only? do
env_or_setting_bool("OIDC_ONLY", :oidc_only)
end
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET")
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
end

View file

@ -2,23 +2,19 @@ defmodule Mv.OidcRoleSyncConfig do
@moduledoc """
Runtime configuration for OIDC group role sync (e.g. admin group Admin role).
Reads from Application config `:mv, :oidc_role_sync`:
- `:admin_group_name` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `:groups_claim` JWT/user_info claim name for groups (default: `"groups"`).
Reads from Mv.Config (ENV first, then Settings):
- `oidc_admin_group_name/0` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `oidc_groups_claim/0` JWT/user_info claim name for groups (default: `"groups"`).
Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings OIDC).
"""
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
def oidc_admin_group_name do
get(:admin_group_name)
Mv.Config.oidc_admin_group_name()
end
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
get(:groups_claim) || "groups"
end
defp get(key) do
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
Mv.Config.oidc_groups_claim() || "groups"
end
end

View file

@ -7,59 +7,66 @@ defmodule Mv.Secrets do
particularly for OIDC (Rauthy) authentication.
## Configuration Source
Secrets are read from the `:oidc` key in the application configuration,
which is typically set in `config/runtime.exs` from environment variables:
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
- `OIDC_BASE_URL`
- `OIDC_REDIRECT_URI`
Secrets are read via `Mv.Config` which prefers environment variables and
falls back to Settings from the database:
- OIDC_CLIENT_ID / settings.oidc_client_id
- OIDC_CLIENT_SECRET / settings.oidc_client_secret
- OIDC_BASE_URL / settings.oidc_base_url
- OIDC_REDIRECT_URI / settings.oidc_redirect_uri
## Usage
This module is automatically called by AshAuthentication when resolving
secrets for the User resource's OIDC strategy.
When a value is nil, returns `{:error, MissingSecret}` so that AshAuthentication
does not crash (e.g. URI.new(nil)) and can redirect to sign-in with an error.
"""
use AshAuthentication.Secret
alias AshAuthentication.Errors.MissingSecret
def secret_for(
[:authentication, :strategies, :oidc, :client_id],
Mv.Accounts.User,
resource,
_opts,
_meth
) do
get_config(:client_id)
secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id)
end
def secret_for(
[:authentication, :strategies, :oidc, :redirect_uri],
Mv.Accounts.User,
resource,
_opts,
_meth
) do
get_config(:redirect_uri)
secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri)
end
def secret_for(
[:authentication, :strategies, :oidc, :client_secret],
Mv.Accounts.User,
resource,
_opts,
_meth
) do
get_config(:client_secret)
secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret)
end
def secret_for(
[:authentication, :strategies, :oidc, :base_url],
Mv.Accounts.User,
resource,
_opts,
_meth
) do
get_config(:base_url)
secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url)
end
defp get_config(key) do
:mv
|> Application.fetch_env!(:oidc)
|> Keyword.fetch!(key)
|> then(&{:ok, &1})
defp secret_or_error(nil, resource, key) do
path = [:authentication, :strategies, :oidc, key]
{:error, MissingSecret.exception(path: path, resource: resource)}
end
defp secret_or_error(value, resource, key) when is_binary(value) do
if String.trim(value) == "" do
secret_or_error(nil, resource, key)
else
{:ok, value}
end
end
end

View file

@ -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

View file

@ -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).