OIDC-only sign-in, Vereinfacht connection test, locale defaults, and settings/docs cleanup #445
8 changed files with 487 additions and 136 deletions
|
|
@ -79,7 +79,13 @@ defmodule Mv.Membership.Setting do
|
||||||
:vereinfacht_api_url,
|
:vereinfacht_api_url,
|
||||||
:vereinfacht_api_key,
|
:vereinfacht_api_key,
|
||||||
:vereinfacht_club_id,
|
:vereinfacht_club_id,
|
||||||
:vereinfacht_app_url
|
:vereinfacht_app_url,
|
||||||
|
:oidc_client_id,
|
||||||
|
:oidc_base_url,
|
||||||
|
:oidc_redirect_uri,
|
||||||
|
:oidc_client_secret,
|
||||||
|
:oidc_admin_group_name,
|
||||||
|
:oidc_groups_claim
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -96,7 +102,13 @@ defmodule Mv.Membership.Setting do
|
||||||
:vereinfacht_api_url,
|
:vereinfacht_api_url,
|
||||||
:vereinfacht_api_key,
|
:vereinfacht_api_key,
|
||||||
:vereinfacht_club_id,
|
:vereinfacht_club_id,
|
||||||
:vereinfacht_app_url
|
:vereinfacht_app_url,
|
||||||
|
:oidc_client_id,
|
||||||
|
:oidc_base_url,
|
||||||
|
:oidc_redirect_uri,
|
||||||
|
:oidc_client_secret,
|
||||||
|
:oidc_admin_group_name,
|
||||||
|
:oidc_groups_claim
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -322,6 +334,44 @@ defmodule Mv.Membership.Setting do
|
||||||
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# OIDC authentication (can be overridden by ENV)
|
||||||
|
attribute :oidc_client_id, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :oidc_base_url, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :oidc_redirect_uri, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :oidc_client_secret, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? false
|
||||||
|
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :oidc_admin_group_name, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :oidc_groups_claim, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
|
||||||
|
end
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,10 @@ defmodule Mv.Config do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_vereinfacht_from_settings(key) do
|
defp get_vereinfacht_from_settings(key) do
|
||||||
|
get_from_settings(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_from_settings(key) do
|
||||||
case Mv.Membership.get_settings() do
|
case Mv.Membership.get_settings() do
|
||||||
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
|
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
|
||||||
{:error, _} -> nil
|
{:error, _} -> nil
|
||||||
|
|
@ -298,4 +302,77 @@ defmodule Mv.Config do
|
||||||
defp present?(nil), do: false
|
defp present?(nil), do: false
|
||||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||||
defp present?(_), do: false
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,19 @@ defmodule Mv.OidcRoleSyncConfig do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
||||||
|
|
||||||
Reads from Application config `:mv, :oidc_role_sync`:
|
Reads from Mv.Config (ENV first, then Settings):
|
||||||
- `:admin_group_name` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
- `oidc_admin_group_name/0` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
||||||
- `:groups_claim` – JWT/user_info claim name for groups (default: `"groups"`).
|
- `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."
|
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
|
||||||
def oidc_admin_group_name do
|
def oidc_admin_group_name do
|
||||||
get(:admin_group_name)
|
Mv.Config.oidc_admin_group_name()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
||||||
def oidc_groups_claim do
|
def oidc_groups_claim do
|
||||||
get(:groups_claim) || "groups"
|
Mv.Config.oidc_groups_claim() || "groups"
|
||||||
end
|
|
||||||
|
|
||||||
defp get(key) do
|
|
||||||
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ defmodule Mv.Secrets do
|
||||||
particularly for OIDC (Rauthy) authentication.
|
particularly for OIDC (Rauthy) authentication.
|
||||||
|
|
||||||
## Configuration Source
|
## Configuration Source
|
||||||
Secrets are read from the `:oidc` key in the application configuration,
|
Secrets are read via `Mv.Config` which prefers environment variables and
|
||||||
which is typically set in `config/runtime.exs` from environment variables:
|
falls back to Settings from the database:
|
||||||
- `OIDC_CLIENT_ID`
|
- OIDC_CLIENT_ID / settings.oidc_client_id
|
||||||
- `OIDC_CLIENT_SECRET`
|
- OIDC_CLIENT_SECRET / settings.oidc_client_secret
|
||||||
- `OIDC_BASE_URL`
|
- OIDC_BASE_URL / settings.oidc_base_url
|
||||||
- `OIDC_REDIRECT_URI`
|
- OIDC_REDIRECT_URI / settings.oidc_redirect_uri
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
This module is automatically called by AshAuthentication when resolving
|
This module is automatically called by AshAuthentication when resolving
|
||||||
|
|
@ -26,7 +26,7 @@ defmodule Mv.Secrets do
|
||||||
_opts,
|
_opts,
|
||||||
_meth
|
_meth
|
||||||
) do
|
) do
|
||||||
get_config(:client_id)
|
{:ok, Mv.Config.oidc_client_id()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def secret_for(
|
def secret_for(
|
||||||
|
|
@ -35,7 +35,7 @@ defmodule Mv.Secrets do
|
||||||
_opts,
|
_opts,
|
||||||
_meth
|
_meth
|
||||||
) do
|
) do
|
||||||
get_config(:redirect_uri)
|
{:ok, Mv.Config.oidc_redirect_uri()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def secret_for(
|
def secret_for(
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule Mv.Secrets do
|
||||||
_opts,
|
_opts,
|
||||||
_meth
|
_meth
|
||||||
) do
|
) do
|
||||||
get_config(:client_secret)
|
{:ok, Mv.Config.oidc_client_secret()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def secret_for(
|
def secret_for(
|
||||||
|
|
@ -53,13 +53,6 @@ defmodule Mv.Secrets do
|
||||||
_opts,
|
_opts,
|
||||||
_meth
|
_meth
|
||||||
) do
|
) do
|
||||||
get_config(:base_url)
|
{:ok, Mv.Config.oidc_base_url()}
|
||||||
end
|
|
||||||
|
|
||||||
defp get_config(key) do
|
|
||||||
:mv
|
|
||||||
|> Application.fetch_env!(:oidc)
|
|
||||||
|> Keyword.fetch!(key)
|
|
||||||
|> then(&{:ok, &1})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Settings"))
|
|> assign(:page_title, gettext("Settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)
|
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||||
|
|
@ -52,6 +51,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> 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(:vereinfacht_test_result, nil)
|
||||||
|
|> assign(:oidc_env_configured, Mv.Config.oidc_env_configured?())
|
||||||
|
|> assign(:oidc_client_id_env_set, Mv.Config.oidc_client_id_env_set?())
|
||||||
|
|> assign(:oidc_base_url_env_set, Mv.Config.oidc_base_url_env_set?())
|
||||||
|
|> assign(:oidc_redirect_uri_env_set, Mv.Config.oidc_redirect_uri_env_set?())
|
||||||
|
|> assign(:oidc_client_secret_env_set, Mv.Config.oidc_client_secret_env_set?())
|
||||||
|
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
||||||
|
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||||
|
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -196,21 +203,110 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<% end %>
|
<% end %>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
<%!-- Memberdata Section --%>
|
<%!-- OIDC Section --%>
|
||||||
<.form_section title={gettext("Memberdata")}>
|
<.form_section title={gettext("OIDC")}>
|
||||||
<.live_component
|
<%= if @oidc_env_configured do %>
|
||||||
:if={@active_editing_section != :custom_fields}
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||||
id="member-fields-component"
|
</p>
|
||||||
settings={@settings}
|
<% end %>
|
||||||
/>
|
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
||||||
<%!-- Custom Fields Section --%>
|
<div class="grid gap-4">
|
||||||
<.live_component
|
<.input
|
||||||
:if={@active_editing_section != :member_fields}
|
field={@form[:oidc_client_id]}
|
||||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
type="text"
|
||||||
id="custom-fields-component"
|
label={gettext("Client ID")}
|
||||||
actor={@current_user}
|
disabled={@oidc_client_id_env_set}
|
||||||
/>
|
placeholder={
|
||||||
|
if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:oidc_base_url]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Base URL")}
|
||||||
|
disabled={@oidc_base_url_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@oidc_base_url_env_set,
|
||||||
|
do: gettext("From OIDC_BASE_URL"),
|
||||||
|
else: "http://localhost:8080/auth/v1"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:oidc_redirect_uri]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Redirect URI")}
|
||||||
|
disabled={@oidc_redirect_uri_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@oidc_redirect_uri_env_set,
|
||||||
|
do: gettext("From OIDC_REDIRECT_URI"),
|
||||||
|
else: "http://localhost:4000/auth/user/oidc/callback"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for={@form[:oidc_client_secret].id}>
|
||||||
|
<span class="label-text">{gettext("Client Secret")}</span>
|
||||||
|
<%= if @oidc_client_secret_set do %>
|
||||||
|
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||||
|
<% end %>
|
||||||
|
</label>
|
||||||
|
<.input
|
||||||
|
field={@form[:oidc_client_secret]}
|
||||||
|
type="password"
|
||||||
|
label=""
|
||||||
|
disabled={@oidc_client_secret_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@oidc_client_secret_env_set,
|
||||||
|
do: gettext("From OIDC_CLIENT_SECRET"),
|
||||||
|
else:
|
||||||
|
if(@oidc_client_secret_set,
|
||||||
|
do: gettext("Leave blank to keep current"),
|
||||||
|
else: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.input
|
||||||
|
field={@form[:oidc_admin_group_name]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Admin group name")}
|
||||||
|
disabled={@oidc_admin_group_name_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@oidc_admin_group_name_env_set,
|
||||||
|
do: gettext("From OIDC_ADMIN_GROUP_NAME"),
|
||||||
|
else: gettext("e.g. admin")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:oidc_groups_claim]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Groups claim")}
|
||||||
|
disabled={@oidc_groups_claim_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@oidc_groups_claim_env_set,
|
||||||
|
do: gettext("From OIDC_GROUPS_CLAIM"),
|
||||||
|
else: "groups"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.button
|
||||||
|
:if={
|
||||||
|
not (@oidc_client_id_env_set and @oidc_base_url_env_set and
|
||||||
|
@oidc_redirect_uri_env_set and @oidc_client_secret_env_set and
|
||||||
|
@oidc_admin_group_name_env_set and @oidc_groups_claim_env_set)
|
||||||
|
}
|
||||||
|
phx-disable-with={gettext("Saving...")}
|
||||||
|
variant="primary"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Save OIDC Settings")}
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -265,8 +361,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) 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 / client secret so we do not overwrite stored secrets
|
||||||
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
|
setting_params_clean =
|
||||||
|
setting_params
|
||||||
|
|> drop_blank_vereinfacht_api_key()
|
||||||
|
|> drop_blank_oidc_client_secret()
|
||||||
|
|
||||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
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
|
||||||
|
|
@ -280,6 +380,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
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(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||||
|> assign(:vereinfacht_test_result, test_result)
|
|> assign(:vereinfacht_test_result, test_result)
|
||||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
@ -307,88 +408,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
defp drop_blank_oidc_client_secret(params) when is_map(params) do
|
||||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
case params do
|
||||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
%{"oidc_client_secret" => v} when v in [nil, ""] ->
|
||||||
id: "custom-fields-component",
|
Map.delete(params, "oidc_client_secret")
|
||||||
show_form: false
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply,
|
_ ->
|
||||||
socket
|
params
|
||||||
|> assign(:active_editing_section, nil)
|
end
|
||||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
|
||||||
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:custom_field_delete_error, error}, socket) do
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info(:custom_field_slug_mismatch, socket) do
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info({:custom_fields_load_error, _error}, socket) do
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Could not load data fields. Please check your permissions.")
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:editing_section_changed, section}, socket) do
|
|
||||||
{:noreply, assign(socket, :active_editing_section, section)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
|
||||||
# Reload settings to get updated member_field_visibility
|
|
||||||
{:ok, updated_settings} = Membership.get_settings()
|
|
||||||
|
|
||||||
# Send update to member fields component to close form
|
|
||||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
|
||||||
id: "member-fields-component",
|
|
||||||
show_form: false,
|
|
||||||
settings: updated_settings
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:settings, updated_settings)
|
|
||||||
|> assign(:active_editing_section, nil)
|
|
||||||
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:member_field_visibility_updated}, socket) do
|
|
||||||
# Legacy event - reload settings and update component
|
|
||||||
{:ok, updated_settings} = Membership.get_settings()
|
|
||||||
|
|
||||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
|
||||||
id: "member-fields-component",
|
|
||||||
settings: updated_settings
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply, assign(socket, :settings, updated_settings)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
# Never put API key into form/DOM to avoid secret leak in source or DevTools
|
# Never put API key / client secret into form/DOM to avoid secret leak
|
||||||
settings_for_form = %{settings | vereinfacht_api_key: nil}
|
settings_for_form = %{settings | vereinfacht_api_key: nil, oidc_client_secret: nil}
|
||||||
|
|
||||||
form =
|
form =
|
||||||
AshPhoenix.Form.for_update(
|
AshPhoenix.Form.for_update(
|
||||||
|
|
|
||||||
29
priv/repo/migrations/20260224122831_add_oidc_to_settings.exs
Normal file
29
priv/repo/migrations/20260224122831_add_oidc_to_settings.exs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddOidcToSettings do
|
||||||
|
@moduledoc """
|
||||||
|
Adds OIDC configuration columns to settings (ENV-overridable in UI).
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :oidc_client_id, :string
|
||||||
|
add :oidc_base_url, :string
|
||||||
|
add :oidc_redirect_uri, :string
|
||||||
|
add :oidc_client_secret, :string
|
||||||
|
add :oidc_admin_group_name, :string
|
||||||
|
add :oidc_groups_claim, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :oidc_client_id
|
||||||
|
remove :oidc_base_url
|
||||||
|
remove :oidc_redirect_uri
|
||||||
|
remove :oidc_client_secret
|
||||||
|
remove :oidc_admin_group_name
|
||||||
|
remove :oidc_groups_claim
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
164
priv/resource_snapshots/repo/settings/20260224122831.json
Normal file
164
priv/resource_snapshots/repo/settings/20260224122831.json
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "club_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_field_visibility",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_field_required",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "true",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "include_joining_cycle",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "default_membership_fee_type_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_url",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_key",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_club_id",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_app_url",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "inserted_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "C84FC81A2A446451D6B5EA72F9BBB3593CD7F0D71C4B7C9CE04934414FDB52EB",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "settings"
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
defmodule Mv.OidcRoleSyncConfigTest do
|
defmodule Mv.OidcRoleSyncConfigTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
|
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
|
||||||
|
Reads via Mv.Config (ENV first, then Settings).
|
||||||
"""
|
"""
|
||||||
use ExUnit.Case, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.OidcRoleSyncConfig
|
alias Mv.OidcRoleSyncConfig
|
||||||
|
|
||||||
describe "oidc_admin_group_name/0" do
|
describe "oidc_admin_group_name/0" do
|
||||||
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
|
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
|
||||||
restore = put_config(admin_group_name: nil)
|
restore = clear_env("OIDC_ADMIN_GROUP_NAME")
|
||||||
on_exit(restore)
|
on_exit(restore)
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
|
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns configured admin group name when set" do
|
test "returns configured admin group name when set via ENV" do
|
||||||
restore = put_config(admin_group_name: "mila-admin")
|
restore = set_env("OIDC_ADMIN_GROUP_NAME", "mila-admin")
|
||||||
on_exit(restore)
|
on_exit(restore)
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
|
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
|
||||||
|
|
@ -24,26 +25,35 @@ defmodule Mv.OidcRoleSyncConfigTest do
|
||||||
|
|
||||||
describe "oidc_groups_claim/0" do
|
describe "oidc_groups_claim/0" do
|
||||||
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
|
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
|
||||||
restore = put_config(groups_claim: nil)
|
restore = clear_env("OIDC_GROUPS_CLAIM")
|
||||||
on_exit(restore)
|
on_exit(restore)
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
|
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns configured claim name when OIDC_GROUPS_CLAIM is set" do
|
test "returns configured claim name when OIDC_GROUPS_CLAIM is set via ENV" do
|
||||||
restore = put_config(groups_claim: "ak_groups")
|
restore = set_env("OIDC_GROUPS_CLAIM", "ak_groups")
|
||||||
on_exit(restore)
|
on_exit(restore)
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
|
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp put_config(opts) do
|
defp set_env(key, value) do
|
||||||
current = Application.get_env(:mv, :oidc_role_sync, [])
|
previous = System.get_env(key)
|
||||||
Application.put_env(:mv, :oidc_role_sync, Keyword.merge(current, opts))
|
System.put_env(key, value)
|
||||||
|
|
||||||
fn ->
|
fn ->
|
||||||
Application.put_env(:mv, :oidc_role_sync, current)
|
if previous, do: System.put_env(key, previous), else: System.delete_env(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_env(key) do
|
||||||
|
previous = System.get_env(key)
|
||||||
|
System.delete_env(key)
|
||||||
|
|
||||||
|
fn ->
|
||||||
|
if previous, do: System.put_env(key, previous)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue