Merge branch 'main' into feat/299_plz
This commit is contained in:
commit
bfc078d5aa
45 changed files with 2187 additions and 425 deletions
|
|
@ -79,7 +79,14 @@ defmodule Mv.Membership.Setting do
|
|||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
: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,
|
||||
:oidc_only
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -96,7 +103,14 @@ defmodule Mv.Membership.Setting do
|
|||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
: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,
|
||||
:oidc_only
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -322,6 +336,52 @@ defmodule Mv.Membership.Setting do
|
|||
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||
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
|
||||
|
||||
attribute :oidc_only, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
|
|
|||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -38,12 +38,10 @@ defmodule MvWeb.AuthOverrides do
|
|||
set :image_url, nil
|
||||
end
|
||||
|
||||
# Translate the or in the horizontal rule to German
|
||||
# Translate the "or" in the horizontal rule (between password form and SSO).
|
||||
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
|
||||
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
||||
set :text,
|
||||
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
|
||||
Gettext.gettext(MvWeb.Gettext, "or")
|
||||
end)
|
||||
set :text, dgettext("auth", "or")
|
||||
end
|
||||
|
||||
# Hide AshAuthentication's Flash component since we use flash_group in root layout
|
||||
|
|
|
|||
|
|
@ -80,11 +80,11 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_item
|
||||
href={~p"/membership_fee_types"}
|
||||
icon="hero-currency-euro"
|
||||
label={gettext("Fee Types")}
|
||||
href={~p"/groups"}
|
||||
icon="hero-user-group"
|
||||
label={gettext("Groups")}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
|
|
@ -102,24 +102,26 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
label={gettext("Administration")}
|
||||
testid="sidebar-administration"
|
||||
>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Basic settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_datafields()) do %>
|
||||
<.menu_subitem href={~p"/admin/datafields"} label={gettext("Datafields")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
|
||||
<.menu_subitem
|
||||
href={~p"/membership_fee_settings"}
|
||||
label={gettext("Fee Settings")}
|
||||
label={gettext("Membership fee settings")}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_import()) do %>
|
||||
<.menu_subitem href={~p"/admin/import"} label={gettext("Import")} />
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<% end %>
|
||||
</.menu_group>
|
||||
<% end %>
|
||||
|
|
|
|||
101
lib/mv_web/live/auth/sign_in_live.ex
Normal file
101
lib/mv_web/live/auth/sign_in_live.ex
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
defmodule MvWeb.SignInLive do
|
||||
@moduledoc """
|
||||
Custom sign-in page with language selector and conditional Single Sign-On button.
|
||||
|
||||
- Renders a language selector (same pattern as LinkOidcAccountLive).
|
||||
- Wraps the default AshAuthentication SignIn component in a container with
|
||||
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
|
||||
"""
|
||||
use Phoenix.LiveView
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias AshAuthentication.Phoenix.Components
|
||||
alias Mv.Config
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
overrides =
|
||||
session
|
||||
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||
|
||||
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
|
||||
locale =
|
||||
session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(overrides: overrides)
|
||||
|> assign_new(:otp_app, fn -> nil end)
|
||||
|> assign(:path, session["path"] || "/")
|
||||
|> assign(:reset_path, session["reset_path"])
|
||||
|> assign(:register_path, session["register_path"])
|
||||
|> assign(:current_tenant, session["tenant"])
|
||||
|> assign(:resources, session["resources"])
|
||||
|> assign(:context, session["context"] || %{})
|
||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||
|> assign(:gettext_fn, session["gettext_fn"])
|
||||
|> assign(:live_action, :sign_in)
|
||||
|> assign(:oidc_configured, Config.oidc_configured?())
|
||||
|> assign(:oidc_only, Config.oidc_only?())
|
||||
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|
||||
|> assign(:sign_in_id, "sign-in")
|
||||
|> assign(:locale, locale)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_, _uri, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="sign-in-page"
|
||||
class={@root_class}
|
||||
data-oidc-configured={to_string(@oidc_configured)}
|
||||
data-oidc-only={to_string(@oidc_only)}
|
||||
data-locale={@locale}
|
||||
>
|
||||
<%!-- Language selector --%>
|
||||
<nav
|
||||
aria-label={dgettext("auth", "Language selection")}
|
||||
class="absolute top-4 right-4 flex justify-end z-10"
|
||||
>
|
||||
<form method="post" action="/set_locale" class="text-sm">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm select-bordered bg-base-100"
|
||||
aria-label={dgettext("auth", "Select language")}
|
||||
>
|
||||
<option value="de" selected={@locale == "de"}>Deutsch</option>
|
||||
<option value="en" selected={@locale == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<.live_component
|
||||
module={Components.SignIn}
|
||||
otp_app={@otp_app}
|
||||
live_action={@live_action}
|
||||
path={@path}
|
||||
auth_routes_prefix={@auth_routes_prefix}
|
||||
resources={@resources}
|
||||
reset_path={@reset_path}
|
||||
register_path={@register_path}
|
||||
id={@sign_in_id}
|
||||
overrides={@overrides}
|
||||
current_tenant={@current_tenant}
|
||||
context={@context}
|
||||
gettext_fn={@gettext_fn}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
132
lib/mv_web/live/datafields_live.ex
Normal file
132
lib/mv_web/live/datafields_live.ex
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
defmodule MvWeb.DatafieldsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing member field visibility/required and custom fields (datafields).
|
||||
|
||||
Renders MemberFieldLive.IndexComponent and CustomFieldLive.IndexComponent.
|
||||
Moved from GlobalSettingsLive (Memberdata section) to a dedicated page.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Datafields"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||
<.header>
|
||||
{gettext("Datafields")}
|
||||
<:subtitle>
|
||||
{gettext("Configure member fields and custom data fields.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form_section title={gettext("Member fields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
<.form_section title={gettext("Custom fields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> 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
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
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
|
||||
{: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
|
||||
|
|
@ -34,15 +34,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
def mount(_params, session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
# Get locale from session for translations
|
||||
locale = session["locale"] || "de"
|
||||
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||
|
|
@ -51,6 +50,17 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|
||||
|> assign(:last_vereinfacht_sync_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_only_env_set, Mv.Config.oidc_only_env_set?())
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
||||
|> assign_form()
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -167,35 +177,162 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
>
|
||||
{gettext("Save Vereinfacht Settings")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
type="button"
|
||||
phx-click="sync_vereinfacht_contacts"
|
||||
phx-disable-with={gettext("Syncing...")}
|
||||
class="mt-4 btn-outline"
|
||||
>
|
||||
{gettext("Sync all members without Vereinfacht contact")}
|
||||
</.button>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<.button
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
type="button"
|
||||
phx-click="test_vereinfacht_connection"
|
||||
phx-disable-with={gettext("Testing...")}
|
||||
class="btn-outline"
|
||||
>
|
||||
{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 %>
|
||||
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
|
||||
<% end %>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- Memberdata Section --%>
|
||||
<.form_section title={gettext("Memberdata")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
<%!-- OIDC Section --%>
|
||||
<.form_section title={gettext("OIDC")}>
|
||||
<%= if @oidc_env_configured do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
</p>
|
||||
<% end %>
|
||||
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@form[:oidc_client_id]}
|
||||
type="text"
|
||||
label={gettext("Client ID")}
|
||||
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 class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<.input
|
||||
field={@form[:oidc_only]}
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
disabled={@oidc_only_env_set or not @oidc_configured}
|
||||
/>
|
||||
<span class="label-text">
|
||||
{gettext("Only OIDC sign-in (hide password login)")}
|
||||
<%= if @oidc_only_env_set do %>
|
||||
<span class="label-text-alt text-base-content/70 ml-1">
|
||||
({gettext("From OIDC_ONLY")})
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</label>
|
||||
<p class="label-text-alt text-base-content/70 mt-1">
|
||||
{gettext(
|
||||
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</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 and
|
||||
@oidc_only_env_set)
|
||||
}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
>
|
||||
{gettext("Save OIDC Settings")}
|
||||
</.button>
|
||||
</.form>
|
||||
</.form_section>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -207,6 +344,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
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
|
||||
def handle_event("sync_vereinfacht_contacts", _params, socket) do
|
||||
case Mv.Vereinfacht.sync_members_without_contact() do
|
||||
|
|
@ -244,17 +387,28 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
# Never send blank API key so we do not overwrite the stored secret (security)
|
||||
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
|
||||
# Never send blank API key / client secret so we do not overwrite stored secrets
|
||||
setting_params_clean =
|
||||
setting_params
|
||||
|> drop_blank_vereinfacht_api_key()
|
||||
|> drop_blank_oidc_client_secret()
|
||||
|
||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||
{:ok, _updated_settings} ->
|
||||
{:ok, fresh_settings} = Membership.get_settings()
|
||||
|
||||
test_result =
|
||||
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:settings, fresh_settings)
|
||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:vereinfacht_test_result, test_result)
|
||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
||||
|
|
@ -265,6 +419,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
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
|
||||
case params do
|
||||
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
|
||||
|
|
@ -275,88 +435,28 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
defp drop_blank_oidc_client_secret(params) when is_map(params) do
|
||||
case params do
|
||||
%{"oidc_client_secret" => v} when v in [nil, ""] ->
|
||||
Map.delete(params, "oidc_client_secret")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> 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)}
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
# Never put API key into form/DOM to avoid secret leak in source or DevTools
|
||||
settings_for_form = %{settings | vereinfacht_api_key: nil}
|
||||
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
|
||||
settings_display =
|
||||
settings
|
||||
|> merge_vereinfacht_env_values()
|
||||
|> merge_oidc_env_values()
|
||||
|
||||
settings_for_form = %{
|
||||
settings_display
|
||||
| vereinfacht_api_key: nil,
|
||||
oidc_client_secret: nil
|
||||
}
|
||||
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
@ -370,6 +470,66 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp put_if_env_set(map, _key, false, _value), do: map
|
||||
defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value)
|
||||
|
||||
defp merge_vereinfacht_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_api_url,
|
||||
Mv.Config.vereinfacht_api_url_env_set?(),
|
||||
Mv.Config.vereinfacht_api_url()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_club_id,
|
||||
Mv.Config.vereinfacht_club_id_env_set?(),
|
||||
Mv.Config.vereinfacht_club_id()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_app_url,
|
||||
Mv.Config.vereinfacht_app_url_env_set?(),
|
||||
Mv.Config.vereinfacht_app_url()
|
||||
)
|
||||
end
|
||||
|
||||
defp merge_oidc_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(
|
||||
:oidc_client_id,
|
||||
Mv.Config.oidc_client_id_env_set?(),
|
||||
Mv.Config.oidc_client_id()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_base_url,
|
||||
Mv.Config.oidc_base_url_env_set?(),
|
||||
Mv.Config.oidc_base_url()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_redirect_uri,
|
||||
Mv.Config.oidc_redirect_uri_env_set?(),
|
||||
Mv.Config.oidc_redirect_uri()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_admin_group_name,
|
||||
Mv.Config.oidc_admin_group_name_env_set?(),
|
||||
Mv.Config.oidc_admin_group_name()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_groups_claim,
|
||||
Mv.Config.oidc_groups_claim_env_set?(),
|
||||
Mv.Config.oidc_groups_claim()
|
||||
)
|
||||
|> put_if_oidc_only_env_set()
|
||||
end
|
||||
|
||||
defp put_if_oidc_only_env_set(s) do
|
||||
if Mv.Config.oidc_only_env_set?() do
|
||||
Map.put(s, :oidc_only, Mv.Config.oidc_only?())
|
||||
else
|
||||
s
|
||||
end
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||
|
|
@ -412,6 +572,109 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||
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
|
||||
|
||||
defp vereinfacht_sync_result(assigns) do
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
defmodule MvWeb.MembershipFeeSettingsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing membership fee settings (Admin).
|
||||
LiveView for membership fee settings and fee types (Admin).
|
||||
|
||||
Allows administrators to configure:
|
||||
- Default membership fee type for new members
|
||||
- Whether to include the joining cycle in membership fee generation
|
||||
Combines:
|
||||
- Global settings (default fee type, include joining cycle)
|
||||
- Membership fee types table (CRUD links to new/edit routes; delete inline)
|
||||
Examples and info are collapsible to save space.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -23,11 +29,14 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
|
||||
|
||||
member_counts = load_member_counts(membership_fee_types, actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:membership_fee_types, membership_fee_types)
|
||||
|> assign(:member_counts, member_counts)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -81,6 +90,51 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||
{:ok, fee_type} ->
|
||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -88,8 +142,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership fees.")}
|
||||
{gettext("Configure global settings and fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
|
|
@ -188,58 +247,169 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<%!-- Examples Card (collapsible) --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
<details class="group">
|
||||
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
|
||||
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</summary>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
<div class="pt-4 space-y-4">
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Fee Types Table --%>
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
|
||||
<.table
|
||||
id="membership_fee_types"
|
||||
rows={@membership_fee_types}
|
||||
row_id={fn mft -> "mft-#{mft.id}" end}
|
||||
>
|
||||
<:col :let={mft} label={gettext("Name")}>
|
||||
<span class="font-medium">{mft.name}</span>
|
||||
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Amount")}>
|
||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-label={gettext("Edit membership fee type")}
|
||||
>
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={mft}>
|
||||
<div
|
||||
:if={get_member_count(mft, @member_counts) > 0}
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
||||
aria-label={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:if={get_member_count(mft, @member_counts) == 0}
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label={gettext("Delete Membership Fee Type")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<details class="mt-6 card bg-base-200">
|
||||
<summary class="card-body cursor-pointer list-none card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Membership Fee Types")}
|
||||
</summary>
|
||||
<div class="card-body pt-0 prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{gettext("Name & Amount")}</strong>
|
||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Interval")}</strong>
|
||||
- {gettext(
|
||||
"Fixed after creation. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Deletion")}</strong>
|
||||
- {gettext("Only possible if no members are assigned to this type.")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -286,6 +456,32 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
|
||||
defp load_member_counts(fee_types, actor) do
|
||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||
|
||||
members =
|
||||
Member
|
||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||
|> Ash.Query.select([:membership_fee_type_id])
|
||||
|> Ash.read!(domain: Membership, actor: actor)
|
||||
|
||||
members
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp get_member_count(fee_type, member_counts) do
|
||||
Map.get(member_counts, fee_type.id, 0)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
|
|||
|
|
@ -384,7 +384,8 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
defp format_interval_value(value), do: to_string(value)
|
||||
|
||||
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_settings"
|
||||
defp return_path(_, _), do: ~p"/membership_fee_settings"
|
||||
|
||||
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
|
||||
# Checks if amount changed and updates socket assigns accordingly
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
{gettext("Manage membership fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
|
|
@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
<:action :let={mft}>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_types/#{mft.id}/edit"}
|
||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-label={gettext("Edit membership fee type")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ defmodule MvWeb.LiveUserAuth do
|
|||
end
|
||||
|
||||
def on_mount(:live_no_user, _params, session, socket) do
|
||||
# Set the locale for not logged in user to set the language in the Log-In Screen
|
||||
# otherwise the locale is not taken for the Log-In Screen
|
||||
locale = session["locale"] || "en"
|
||||
# Set the locale for not logged in user (default from config, "de" in dev/prod).
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
{:cont, assign(socket, :locale, locale)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
defmodule MvWeb.LocaleController do
|
||||
use MvWeb, :controller
|
||||
|
||||
def set_locale(conn, %{"locale" => locale}) do
|
||||
@supported_locales ["de", "en"]
|
||||
|
||||
def set_locale(conn, %{"locale" => locale}) when locale in @supported_locales do
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
# Store locale in a cookie that persists beyond the session
|
||||
|> put_resp_cookie("locale", locale,
|
||||
max_age: 365 * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
|
|
@ -14,6 +15,8 @@ defmodule MvWeb.LocaleController do
|
|||
|> redirect(to: get_referer(conn) || "/")
|
||||
end
|
||||
|
||||
def set_locale(conn, _params), do: redirect(conn, to: get_referer(conn) || "/")
|
||||
|
||||
defp get_referer(conn) do
|
||||
conn.req_headers
|
||||
|> Enum.find(fn {k, _v} -> k == "referer" end)
|
||||
|
|
|
|||
|
|
@ -8,30 +8,30 @@ defmodule MvWeb.PagePaths do
|
|||
|
||||
# Sidebar top-level menu paths
|
||||
@members "/members"
|
||||
@membership_fee_types "/membership_fee_types"
|
||||
@statistics "/statistics"
|
||||
|
||||
# Administration submenu paths (all must match router)
|
||||
@users "/users"
|
||||
@groups "/groups"
|
||||
@admin_roles "/admin/roles"
|
||||
@admin_datafields "/admin/datafields"
|
||||
@membership_fee_settings "/membership_fee_settings"
|
||||
@admin_import "/admin/import"
|
||||
@settings "/settings"
|
||||
|
||||
@admin_page_paths [
|
||||
@users,
|
||||
@groups,
|
||||
@admin_roles,
|
||||
@admin_datafields,
|
||||
@membership_fee_settings,
|
||||
@admin_import,
|
||||
@settings
|
||||
]
|
||||
|
||||
@doc "Path for Members index (sidebar and page permission check)."
|
||||
def members, do: @members
|
||||
|
||||
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
|
||||
def membership_fee_types, do: @membership_fee_types
|
||||
|
||||
@doc "Path for Statistics page (sidebar and page permission check)."
|
||||
def statistics, do: @statistics
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ defmodule MvWeb.PagePaths do
|
|||
def users, do: @users
|
||||
def groups, do: @groups
|
||||
def admin_roles, do: @admin_roles
|
||||
def admin_datafields, do: @admin_datafields
|
||||
def membership_fee_settings, do: @membership_fee_settings
|
||||
def admin_import, do: @admin_import
|
||||
def settings, do: @settings
|
||||
end
|
||||
|
|
|
|||
|
|
@ -68,16 +68,13 @@ defmodule MvWeb.Router do
|
|||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
# Membership Fee Settings
|
||||
# Membership Fee Settings (includes fee types list; new/edit under sub-routes)
|
||||
live "/membership_fee_settings", MembershipFeeSettingsLive
|
||||
|
||||
# Membership Fee Types Management
|
||||
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
|
||||
live "/membership_fee_settings/new_fee_type", MembershipFeeTypeLive.Form, :new
|
||||
live "/membership_fee_settings/:id/edit_fee_type", MembershipFeeTypeLive.Form, :edit
|
||||
|
||||
# Statistics
|
||||
live "/statistics", StatisticsLive, :index
|
||||
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
||||
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
|
||||
|
||||
# Groups Management
|
||||
live "/groups", GroupLive.Index, :index
|
||||
|
|
@ -91,6 +88,9 @@ defmodule MvWeb.Router do
|
|||
live "/admin/roles/:id", RoleLive.Show, :show
|
||||
live "/admin/roles/:id/edit", RoleLive.Form, :edit
|
||||
|
||||
# Datafields (member fields + custom fields)
|
||||
live "/admin/datafields", DatafieldsLive
|
||||
|
||||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
|
|
@ -112,7 +112,8 @@ defmodule MvWeb.Router do
|
|||
auth_routes_prefix: "/auth",
|
||||
on_mount: [{MvWeb.LiveUserAuth, :live_no_user}],
|
||||
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
|
||||
gettext_backend: {MvWeb.Gettext, "auth"}
|
||||
gettext_backend: {MvWeb.Gettext, "auth"},
|
||||
live_view: MvWeb.SignInLive
|
||||
|
||||
# Remove this if you do not want to use the reset password feature
|
||||
reset_route auth_routes_prefix: "/auth",
|
||||
|
|
@ -212,8 +213,8 @@ defmodule MvWeb.Router do
|
|||
end)
|
||||
end
|
||||
|
||||
# Our supported languages for now are german and english, english as fallback language
|
||||
# Our supported languages: German and English; default German.
|
||||
defp supported_locale?(locale), do: locale in ["en", "de"]
|
||||
defp fallback_locale(nil), do: "en"
|
||||
defp fallback_locale(nil), do: Application.get_env(:mv, :default_locale, "de")
|
||||
defp fallback_locale(locale), do: locale
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue