mitgliederverwaltung/lib/mv/config.ex
Moritz 8edbbac95f
feat: OIDC configuration in global Settings (ENV or DB)
- Add oidc_* attributes to Setting, migration and Config helpers
- Secrets and OidcRoleSyncConfig read from Config (ENV overrides DB)
- GlobalSettingsLive: OIDC section with disabled fields when ENV set
- OIDC role sync tests use DataCase for DB access
2026-02-24 13:58:24 +01:00

378 lines
11 KiB
Elixir

defmodule Mv.Config do
@moduledoc """
Configuration helper functions for the application.
Provides centralized access to configuration values to avoid
magic strings/atoms scattered throughout the codebase.
"""
@doc """
Returns whether SQL sandbox mode is enabled.
SQL sandbox mode is typically enabled in test environments
to allow concurrent database access in tests.
## Returns
- `true` if SQL sandbox is enabled
- `false` otherwise
"""
@spec sql_sandbox?() :: boolean()
def sql_sandbox? do
Application.get_env(:mv, :sql_sandbox, false)
end
@doc """
Returns the maximum file size for CSV imports in bytes.
Reads the `max_file_size_mb` value from the CSV import configuration
and converts it to bytes.
## Returns
- Maximum file size in bytes (default: 10_485_760 bytes = 10 MB)
## Examples
iex> Mv.Config.csv_import_max_file_size_bytes()
10_485_760
"""
@spec csv_import_max_file_size_bytes() :: non_neg_integer()
def csv_import_max_file_size_bytes do
max_file_size_mb = get_csv_import_config(:max_file_size_mb, 10)
max_file_size_mb * 1024 * 1024
end
@doc """
Returns the maximum number of rows allowed in CSV imports.
Reads the `max_rows` value from the CSV import configuration.
## Returns
- Maximum number of rows (default: 1000)
## Examples
iex> Mv.Config.csv_import_max_rows()
1000
"""
@spec csv_import_max_rows() :: pos_integer()
def csv_import_max_rows do
get_csv_import_config(:max_rows, 1000)
end
@doc """
Returns the maximum file size for CSV imports in megabytes.
Reads the `max_file_size_mb` value from the CSV import configuration.
## Returns
- Maximum file size in megabytes (default: 10)
## Examples
iex> Mv.Config.csv_import_max_file_size_mb()
10
"""
@spec csv_import_max_file_size_mb() :: pos_integer()
def csv_import_max_file_size_mb do
get_csv_import_config(:max_file_size_mb, 10)
end
# Helper function to get CSV import config values
defp get_csv_import_config(key, default) do
Application.get_env(:mv, :csv_import, [])
|> Keyword.get(key, default)
|> parse_and_validate_integer(default)
end
# Parses and validates integer configuration values.
#
# Accepts:
# - Integer values (passed through)
# - String integers (e.g., "1000") - parsed to integer
# - Invalid values (e.g., "abc", nil) - falls back to default
#
# Always clamps the result to a minimum of 1 to ensure positive values.
#
# Note: We don't log warnings for unparseable values because:
# - These functions may be called frequently (e.g., on every request)
# - Logging would create excessive log spam
# - The fallback to default provides a safe behavior
# - Configuration errors should be caught during deployment/testing
defp parse_and_validate_integer(value, _default) when is_integer(value) do
max(1, value)
end
defp parse_and_validate_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, _remainder} -> max(1, int)
:error -> default
end
end
defp parse_and_validate_integer(_value, default) do
default
end
@doc """
Returns the maximum number of rows allowed in PDF exports.
Reads the `row_limit` value from the PDF export configuration.
## Returns
- Maximum number of rows (default: 5000)
## Examples
iex> Mv.Config.pdf_export_row_limit()
5000
"""
@spec pdf_export_row_limit() :: pos_integer()
def pdf_export_row_limit do
get_pdf_export_config(:row_limit, 5000)
end
# Helper function to get PDF export config values
defp get_pdf_export_config(key, default) do
Application.get_env(:mv, :pdf_export, [])
|> Keyword.get(key, default)
|> parse_and_validate_integer(default)
end
# ---------------------------------------------------------------------------
# Vereinfacht accounting software integration
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the Vereinfacht API base URL.
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
"""
@spec vereinfacht_api_url() :: String.t() | nil
def vereinfacht_api_url do
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
end
@doc """
Returns the Vereinfacht API key (Bearer token).
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
"""
@spec vereinfacht_api_key() :: String.t() | nil
def vereinfacht_api_key do
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
end
@doc """
Returns the Vereinfacht club ID for multi-tenancy.
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
"""
@spec vereinfacht_club_id() :: String.t() | nil
def vereinfacht_club_id do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end
@doc """
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
"""
@spec vereinfacht_app_url() :: String.t() | nil
def vereinfacht_app_url do
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
derive_app_url_from_api_url(vereinfacht_api_url())
end
defp derive_app_url_from_api_url(nil), do: nil
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
api_url = String.trim(api_url)
uri = URI.parse(api_url)
host = uri.host || ""
if String.starts_with?(host, "api.") do
app_host = "app." <> String.slice(host, 4..-1//1)
scheme = uri.scheme || "https"
"#{scheme}://#{app_host}"
else
nil
end
end
defp derive_app_url_from_api_url(_), do: nil
@doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
"""
@spec vereinfacht_configured?() :: boolean()
def vereinfacht_configured? do
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
present?(vereinfacht_club_id())
end
@doc """
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
"""
@spec vereinfacht_env_configured?() :: boolean()
def vereinfacht_env_configured? do
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
vereinfacht_club_id_env_set?()
end
@doc """
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
"""
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
@doc """
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
"""
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
@doc """
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
"""
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
@doc """
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
"""
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
defp env_set?(key) do
case System.get_env(key) do
nil -> false
v when is_binary(v) -> String.trim(v) != ""
_ -> false
end
end
defp env_or_setting(env_key, setting_key) do
case System.get_env(env_key) do
nil -> get_vereinfacht_from_settings(setting_key)
value -> trim_nil(value)
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 trim_nil(nil), do: nil
defp trim_nil(s) when is_binary(s) do
t = String.trim(s)
if t == "", do: nil, else: t
end
@doc """
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
Uses the configured app base URL (or derived from API URL) and appends
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
"""
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
base = vereinfacht_app_url()
if present?(base) do
base
|> String.trim_trailing("/")
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
else
nil
end
end
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?()
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")
end