Merge branch 'main' into feat/299_plz
This commit is contained in:
commit
bfc078d5aa
45 changed files with 2187 additions and 425 deletions
132
lib/mv/config.ex
132
lib/mv/config.ex
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue