diff --git a/.env.example b/.env.example index 543579c..e24b118 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,10 @@ ASSOCIATION_NAME="Sportsclub XYZ" # OIDC_ADMIN_GROUP_NAME=admin # OIDC_GROUPS_CLAIM=groups +# Optional: Show only OIDC sign-in on login page (hide password form). +# When set to true and OIDC is configured, users see only the Single Sign-On button. +# OIDC_ONLY=true + # Optional: Vereinfacht accounting integration (finance-contacts sync) # If set, these override values from Settings UI; those fields become read-only. # VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 diff --git a/assets/css/app.css b/assets/css/app.css index 21b1b25..bbe7424 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -369,4 +369,24 @@ left: 0 !important; } +/* Sign-in: hide SSO button and "or" divider when OIDC is not configured. + Scoped to #sign-in-page to avoid hiding unrelated elements. */ +#sign-in-page[data-oidc-configured="false"] [id*="oidc"] { + display: none !important; +} +#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] { + display: none !important; +} +#sign-in-page[data-oidc-configured="false"] .divider { + display: none !important; +} + +/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */ +#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] { + display: none !important; +} +#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider { + display: none !important; +} + /* This file is for your main application CSS */ diff --git a/config/dev.exs b/config/dev.exs index e7b2af8..139b816 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -93,11 +93,13 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx # Signing Secret for Authentication config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" -config :mv, :oidc, - client_id: "mv", - base_url: "http://localhost:8080/auth/v1", - client_secret: System.get_env("OIDC_CLIENT_SECRET"), - redirect_uri: "http://localhost:4000/auth/user/oidc/callback" +# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out, +# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs. +# config :mv, :oidc, +# client_id: "mv", +# base_url: "http://localhost:8080/auth/v1", +# client_secret: System.get_env("OIDC_CLIENT_SECRET"), +# redirect_uri: "http://localhost:4000/auth/user/oidc/callback" # AshAuthentication development configuration config :mv, :session_identifier, :jti diff --git a/config/test.exs b/config/test.exs index fe2b855..864222f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,6 +49,9 @@ config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false +# Use English as default locale in tests so UI tests can assert on English strings. +config :mv, :default_locale, "en" + # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index ef7c4ce..abbd03f 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -33,6 +33,10 @@ - `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups"). - Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0). +### Sign-in page (OIDC-only mode) + +- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings. + ### Sync Logic - Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 154288b..894725f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -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 diff --git a/lib/mv/config.ex b/lib/mv/config.ex index d2ad66c..ec69b18 100644 --- a/lib/mv/config.ex +++ b/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 diff --git a/lib/mv/oidc_role_sync_config.ex b/lib/mv/oidc_role_sync_config.ex index 493a435..2a8574c 100644 --- a/lib/mv/oidc_role_sync_config.ex +++ b/lib/mv/oidc_role_sync_config.ex @@ -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 diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex index d3bc30d..177ed90 100644 --- a/lib/mv/secrets.ex +++ b/lib/mv/secrets.ex @@ -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 diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 58e06a9..6ec8c8c 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -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 diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index ce8005d..6520b64 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -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). diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index b121c4e..f28d81f 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -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 diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index 7d4cce6..cb94fb3 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -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 %> <% end %> diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex new file mode 100644 index 0000000..aa0d640 --- /dev/null +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -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""" +
+ <%!-- Language selector --%> + + + <.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} + /> +
+ """ + end +end diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex new file mode 100644 index 0000000..f7436ab --- /dev/null +++ b/lib/mv_web/live/datafields_live.ex @@ -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""" + + <.header> + {gettext("Datafields")} + <:subtitle> + {gettext("Configure member fields and custom data fields.")} + + + + <.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 title={gettext("Custom fields")}> + <.live_component + :if={@active_editing_section != :member_fields} + module={MvWeb.CustomFieldLive.IndexComponent} + id="custom-fields-component" + actor={@current_user} + /> + + + """ + 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 diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index b841931..752c8d6 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -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 - :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 + :if={Mv.Config.vereinfacht_configured?()} + type="button" + phx-click="test_vereinfacht_connection" + phx-disable-with={gettext("Testing...")} + class="btn-outline" + > + {gettext("Test Integration")} + + <.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")} + +
+ <%= 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 %> - <%!-- 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 %> +

+ {gettext("Some values are set via environment variables. Those fields are read-only.")} +

+ <% end %> + <.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save"> +
+ <.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" + ) + } + /> +
+ + <.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 + ) + ) + } + /> +
+ <.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" + ) + } + /> +
+ +

+ {gettext( + "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." + )} +

+
+
+ <.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")} + + """ @@ -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""" +
+ <.icon name="hero-check-circle" class="size-5 shrink-0" /> + {gettext("Connection successful. API URL, API Key and Club ID are valid.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do + ~H""" +
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> + {gettext("Not configured. Please set API URL, API Key and Club ID.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + {gettext("Connection failed (HTTP 401): API key is invalid or missing.")} +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext( + "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." + )} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do + assigns = assign(assigns, :status, status) + assigns = assign(assigns, :message, message) + + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> + + {gettext("Connection failed (HTTP %{status}):", status: @status)} + {@message} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + + {gettext("Connection failed. Could not reach the API (network error or wrong URL).")} + +
+ """ + end + + defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do + ~H""" +
+ <.icon name="hero-x-circle" class="size-5 shrink-0" /> + {gettext("Connection failed. Unknown error.")} +
+ """ + end + attr :result, :map, required: true defp vereinfacht_sync_result(assigns) do diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index 2b79c4e..bfa20f8 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -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.")} + <:actions> + <.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}> + <.icon name="hero-plus" /> {gettext("New Membership Fee Type")} + +
@@ -188,58 +247,169 @@ defmodule MvWeb.MembershipFeeSettingsLive do
- <%!-- Examples Card --%> + <%!-- Examples Card (collapsible) --%>
-

- <.icon name="hero-light-bulb" class="size-5" /> - {gettext("Examples")} -

+
+ + <.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" /> + <.icon name="hero-light-bulb" class="size-5" /> + {gettext("Examples")} + - <.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")} - /> +
+ <.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")} + /> -
+
- <.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")} + /> -
+
- <.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")} + /> -
+
- <.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")} + /> +
+
+ + <%!-- Fee Types Table --%> +
+

{gettext("Membership Fee Types")}

+ <.table + id="membership_fee_types" + rows={@membership_fee_types} + row_id={fn mft -> "mft-#{mft.id}" end} + > + <:col :let={mft} label={gettext("Name")}> + {mft.name} +

{mft.description}

+ + + <:col :let={mft} label={gettext("Amount")}> + {MembershipFeeHelpers.format_currency(mft.amount)} + + + <:col :let={mft} label={gettext("Interval")}> + + {MembershipFeeHelpers.format_interval(mft.interval)} + + + + <:col :let={mft} label={gettext("Members")}> + {get_member_count(mft, @member_counts)} + + + <: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" /> + + + + <:action :let={mft}> +
0} + class="tooltip tooltip-left" + data-tip={ + gettext("Cannot delete - %{count} member(s) assigned", + count: get_member_count(mft, @member_counts) + ) + } + > + +
+ + + + +
+ + <.icon name="hero-information-circle" class="size-5" /> + {gettext("About Membership Fee Types")} + +
+

+ {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." + )} +

+
    +
  • + {gettext("Name & Amount")} + - {gettext("Can be changed at any time. Amount changes affect future periods only.")} +
  • +
  • + {gettext("Interval")} + - {gettext( + "Fixed after creation. Members can only switch between types with the same interval." + )} +
  • +
  • + {gettext("Deletion")} + - {gettext("Only possible if no members are assigned to this type.")} +
  • +
+
+
+
""" 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( diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index 6fe80a8..d8569e2 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -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 diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index 84cd26d..f5f760f 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -47,7 +47,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do {gettext("Manage membership fee types for membership fees.")} <: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")} @@ -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")} > diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex index b78ba21..c913caa 100644 --- a/lib/mv_web/live_user_auth.ex +++ b/lib/mv_web/live_user_auth.ex @@ -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)} diff --git a/lib/mv_web/locale_controller.ex b/lib/mv_web/locale_controller.ex index 99a200f..afa1737 100644 --- a/lib/mv_web/locale_controller.ex +++ b/lib/mv_web/locale_controller.ex @@ -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) diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex index 2720c0f..551cada 100644 --- a/lib/mv_web/page_paths.ex +++ b/lib/mv_web/page_paths.ex @@ -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 diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index ec90f1b..8a4e6c0 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -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 diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 8608e17..550b238 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -137,11 +137,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo msgstr "" #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Language selection" msgstr "" #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Select language" msgstr "" + +#: lib/mv_web/auth_overrides.ex +#, elixir-autogen, elixir-format +msgid "or" +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 67e8551..4193b93 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -133,11 +133,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support." #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Language selection" msgstr "Sprachauswahl" #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswählen" + +#: lib/mv_web/auth_overrides.ex +#, elixir-autogen, elixir-format +msgid "or" +msgstr "oder" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 2f4c1b8..49fbe83 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -18,6 +18,7 @@ msgid "Actions" msgstr "Aktionen" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -322,6 +323,7 @@ msgstr "Benutzer*innen auflisten" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -335,6 +337,7 @@ msgstr "Mitglieder" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -382,7 +385,6 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -842,6 +844,7 @@ msgid "Create Member" msgstr "Mitglied erstellen" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -853,11 +856,13 @@ msgstr "Betrag" msgid "Back to Settings" msgstr "Zurück zu den Einstellungen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" @@ -868,6 +873,7 @@ msgstr "Löschen" msgid "Examples" msgstr "Beispiele" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -886,6 +892,7 @@ msgid "Half-yearly" msgstr "Halbjährlich" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -924,11 +931,13 @@ msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr" msgid "Monthly" msgstr "Monatlich" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "Name & Betrag" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1002,7 +1011,7 @@ msgstr "Alle auswählen" msgid "Select none" msgstr "Keine auswählen" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen." @@ -1044,11 +1053,6 @@ msgstr "Textfeld" msgid "Yes/No-Selection" msgstr "Ja/Nein-Auswahl" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Memberdata" -msgstr "Mitgliederdaten" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -1060,7 +1064,7 @@ msgstr "Optional" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" msgstr "Mitgliedsfeld wurde erfolgreich %{action}" @@ -1070,6 +1074,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}" msgid "A cycle for this period already exists" msgstr "Ein Zyklus für diesen Zeitraum existiert bereits" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1086,6 +1091,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag." #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1097,6 +1103,7 @@ msgstr "Ein Fehler ist aufgetreten" msgid "Are you sure you want to delete this cycle?" msgstr "Möchtest du diesen Zyklus wirklich löschen?" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Cannot delete - %{count} member(s) assigned" @@ -1117,11 +1124,6 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)." msgid "Click to edit amount" msgstr "Klicke, um den Betrag zu bearbeiten" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Configure global settings for membership fees." -msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1232,6 +1234,7 @@ msgstr "Feld bearbeiten: %{field}" msgid "Edit Membership Fee Type" msgstr "Mitgliedsbeitragsart bearbeiten" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit membership fee type" @@ -1330,6 +1333,7 @@ msgstr "Mitgliedsbeitragsstatus" msgid "Membership Fee Type" msgstr "Mitgliedsbeitragsart" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership Fee Types" @@ -1346,6 +1350,7 @@ msgstr "Mitgliedsbeiträge" msgid "Membership fee start" msgstr "Beitragsbeginn" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" @@ -1366,6 +1371,7 @@ msgstr "Mitgliedsbeitragsart erfolgreich gespeichert" msgid "Membership fee type updated. Cycles regenerated." msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "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." @@ -1376,6 +1382,7 @@ msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstruktur msgid "Monthly Interval - Joining Cycle Included" msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1550,6 +1557,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" msgid "You are about to delete all %{count} cycles for this member." msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete Membership Fee Type" @@ -1571,12 +1579,12 @@ msgstr "Spalten ein-/ausblenden" msgid "Back to settings" msgstr "Zurück zu den Einstellungen" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field %{action} successfully" msgstr "Datenfeld erfolgreich %{action}" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field deleted successfully" msgstr "Datenfeld erfolgreich gelöscht" @@ -1591,7 +1599,7 @@ msgstr "Datenfeld löschen" msgid "Edit Data Field" msgstr "Datenfeld bearbeiten" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete data field: %{error}" msgstr "Konnte Datenfeld nicht löschen: %{error}" @@ -1823,6 +1831,7 @@ msgstr "Zyklus löschen" msgid "The cycle period will be calculated based on this date and the interval." msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet." +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" @@ -1843,6 +1852,7 @@ msgstr "Benutzer*in erfolgreich gelöscht" msgid "User not found" msgstr "Benutzer*in nicht gefunden" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" @@ -1853,6 +1863,7 @@ msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" msgid "You do not have permission to access this user" msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" @@ -1924,16 +1935,6 @@ msgstr "E-Mail ist erforderlich." msgid "Roles" msgstr "Rollen" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Fee Settings" -msgstr "Beitragseinstellungen" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "Beitragstypen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2272,7 +2273,7 @@ msgstr "Dieser Benutzer kann nicht angezeigt werden." msgid "Not authorized." msgstr "Nicht berechtigt." -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." @@ -2423,6 +2424,7 @@ msgstr "Beitragsart auswählen" msgid "Linked" msgstr "Verknüpft" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2682,6 +2684,61 @@ msgstr "Vereinfacht-Integration" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "Integration testen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "Wird getestet..." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "Verbindung erfolgreich. API-URL, API-Schlüssel und Vereins-ID sind korrekt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "Nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "Verbindung fehlgeschlagen (HTTP %{status}):" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "Verbindung fehlgeschlagen (HTTP 401): API-Schlüssel ist ungültig oder fehlt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "Verbindung fehlgeschlagen (HTTP 403): Zugriff verweigert. Bitte Vereins-ID und Berechtigungen des API-Schlüssels prüfen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "Verbindung fehlgeschlagen (HTTP 404): API-Endpunkt nicht gefunden. Bitte die API-URL prüfen (z. B. korrekter Versionspfad)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "Verbindung fehlgeschlagen. Die URL zeigt nicht auf eine Vereinfacht-API (HTML statt JSON erhalten)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "Verbindung fehlgeschlagen. API nicht erreichbar (Netzwerkfehler oder falsche URL)." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "Verbindung fehlgeschlagen. Unbekannter Fehler." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht" @@ -2920,3 +2977,124 @@ msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Beitragsart" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "Admin-Gruppenname" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "Basis-URL" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "Grundeinstellungen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "Client-ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "Client-Geheimnis" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings and fee types for membership fees." +msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren." + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom fields" +msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "Datenfelder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "Aus OIDC_ADMIN_GROUP_NAME" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "Aus OIDC_BASE_URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "Aus OIDC_CLIENT_ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "Aus OIDC_CLIENT_SECRET" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "Aus OIDC_GROUPS_CLAIM" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "Aus OIDC_REDIRECT_URI" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Groups claim" +msgstr "Gruppenclaim" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Member fields" +msgstr "Mitgliedsfelder" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Membership fee settings" +msgstr "Beitragseinstellungen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "Weiterleitungs-URI" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save OIDC Settings" +msgstr "OIDC-Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "z. B. admin" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ONLY" +msgstr "Aus OIDC_ONLY" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 98c2b91..ea8e976 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -19,6 +19,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -323,6 +324,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -336,6 +338,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -383,7 +386,6 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -843,6 +845,7 @@ msgid "Create Member" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -854,11 +857,13 @@ msgstr "" msgid "Back to Settings" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Deletion" @@ -869,6 +874,7 @@ msgstr "" msgid "Examples" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -887,6 +893,7 @@ msgid "Half-yearly" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -925,11 +932,13 @@ msgstr "" msgid "Monthly" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1003,7 +1012,7 @@ msgstr "" msgid "Select none" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "" @@ -1045,11 +1054,6 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Memberdata" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -1061,7 +1065,7 @@ msgstr "" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" msgstr "" @@ -1071,6 +1075,7 @@ msgstr "" msgid "A cycle for this period already exists" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1087,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1098,6 +1104,7 @@ msgstr "" msgid "Are you sure you want to delete this cycle?" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Cannot delete - %{count} member(s) assigned" @@ -1118,11 +1125,6 @@ msgstr "" msgid "Click to edit amount" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership fees." -msgstr "" - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1233,6 +1235,7 @@ msgstr "" msgid "Edit Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Edit membership fee type" @@ -1331,6 +1334,7 @@ msgstr "" msgid "Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership Fee Types" @@ -1347,6 +1351,7 @@ msgstr "" msgid "Membership fee start" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" @@ -1367,6 +1372,7 @@ msgstr "" msgid "Membership fee type updated. Cycles regenerated." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "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." @@ -1377,6 +1383,7 @@ msgstr "" msgid "Monthly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1551,6 +1558,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Delete Membership Fee Type" @@ -1572,12 +1580,12 @@ msgstr "" msgid "Back to settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Data field %{action} successfully" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Data field deleted successfully" msgstr "" @@ -1592,7 +1600,7 @@ msgstr "" msgid "Edit Data Field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Failed to delete data field: %{error}" msgstr "" @@ -1824,6 +1832,7 @@ msgstr "" msgid "The cycle period will be calculated based on this date and the interval." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" @@ -1844,6 +1853,7 @@ msgstr "" msgid "User not found" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" @@ -1854,6 +1864,7 @@ msgstr "" msgid "You do not have permission to access this user" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" @@ -1925,16 +1936,6 @@ msgstr "" msgid "Roles" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Settings" -msgstr "" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2273,7 +2274,7 @@ msgstr "" msgid "Not authorized." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "" @@ -2424,6 +2425,7 @@ msgstr "" msgid "Linked" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2683,6 +2685,61 @@ msgstr "" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht" @@ -2920,3 +2977,124 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure global settings and fee types for membership fees." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Groups claim" +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Member fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Membership fee settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save OIDC Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ONLY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 0ec49bc..d3511b7 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -130,11 +130,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo msgstr "" #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Language selection" msgstr "" #: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Select language" msgstr "" + +#: lib/mv_web/auth_overrides.ex +#, elixir-autogen, elixir-format +msgid "or" +msgstr "or" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a76c9f6..915fc52 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -19,6 +19,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex @@ -323,6 +324,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format @@ -336,6 +338,7 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex @@ -383,7 +386,6 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings" @@ -843,6 +845,7 @@ msgid "Create Member" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -854,11 +857,13 @@ msgstr "" msgid "Back to Settings" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" @@ -869,6 +874,7 @@ msgstr "" msgid "Examples" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Fixed after creation. Members can only switch between types with the same interval." @@ -887,6 +893,7 @@ msgid "Half-yearly" msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -925,11 +932,13 @@ msgstr "" msgid "Monthly" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." @@ -1003,7 +1012,7 @@ msgstr "" msgid "Select none" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." msgstr "" @@ -1045,11 +1054,6 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Memberdata" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -1061,7 +1065,7 @@ msgstr "" msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Member field %{action} successfully" msgstr "" @@ -1071,6 +1075,7 @@ msgstr "" msgid "A cycle for this period already exists" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Membership Fee Types" @@ -1087,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount." msgstr "" #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format @@ -1098,6 +1104,7 @@ msgstr "" msgid "Are you sure you want to delete this cycle?" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Cannot delete - %{count} member(s) assigned" @@ -1118,11 +1125,6 @@ msgstr "" msgid "Click to edit amount" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Configure global settings for membership fees." -msgstr "" - #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Confirm Change" @@ -1233,6 +1235,7 @@ msgstr "" msgid "Edit Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit membership fee type" @@ -1331,6 +1334,7 @@ msgstr "" msgid "Membership Fee Type" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Types" @@ -1347,6 +1351,7 @@ msgstr "" msgid "Membership fee start" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" @@ -1367,6 +1372,7 @@ msgstr "" msgid "Membership fee type updated. Cycles regenerated." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "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." @@ -1377,6 +1383,7 @@ msgstr "" msgid "Monthly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy @@ -1551,6 +1558,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete Membership Fee Type" @@ -1572,12 +1580,12 @@ msgstr "" msgid "Back to settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field %{action} successfully" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Data field deleted successfully" msgstr "" @@ -1592,7 +1600,7 @@ msgstr "" msgid "Edit Data Field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete data field: %{error}" msgstr "" @@ -1824,6 +1832,7 @@ msgstr "" msgid "The cycle period will be calculated based on this date and the interval." msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" @@ -1844,6 +1853,7 @@ msgstr "" msgid "User not found" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" @@ -1854,6 +1864,7 @@ msgstr "" msgid "You do not have permission to access this user" msgstr "" +#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" @@ -1925,16 +1936,6 @@ msgstr "" msgid "Roles" msgstr "" -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Fee Settings" -msgstr "" - -#: lib/mv_web/components/layouts/sidebar.ex -#, elixir-autogen, elixir-format -msgid "Fee Types" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Administration" @@ -2273,7 +2274,7 @@ msgstr "" msgid "Not authorized." msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." msgstr "" @@ -2424,6 +2425,7 @@ msgstr "" msgid "Linked" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -2683,6 +2685,61 @@ msgstr "" msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Test Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Testing..." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection successful. API URL, API Key and Club ID are valid." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Not configured. Please set API URL, API Key and Club ID." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP %{status}):" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 401): API key is invalid or missing." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Could not reach the API (network error or wrong URL)." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Connection failed. Unknown error." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "View contact in Vereinfacht" @@ -2920,3 +2977,124 @@ msgstr "Required for Vereinfacht integration and cannot be disabled." #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Fee Type" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Admin group name" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Base URL" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format +msgid "Basic settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Client Secret" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings and fee types for membership fees." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Configure member fields and custom data fields." +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format +msgid "Datafields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_ADMIN_GROUP_NAME" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_BASE_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_CLIENT_SECRET" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_GROUPS_CLAIM" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From OIDC_REDIRECT_URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Groups claim" +msgstr "" + +#: lib/mv_web/live/datafields_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member fields" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Membership fee settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Redirect URI" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save OIDC Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "e.g. admin" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From OIDC_ONLY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in (hide password login)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." +msgstr "" diff --git a/priv/repo/migrations/20260224122831_add_oidc_to_settings.exs b/priv/repo/migrations/20260224122831_add_oidc_to_settings.exs new file mode 100644 index 0000000..154709a --- /dev/null +++ b/priv/repo/migrations/20260224122831_add_oidc_to_settings.exs @@ -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 diff --git a/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs b/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs new file mode 100644 index 0000000..3775ef6 --- /dev/null +++ b/priv/repo/migrations/20260224180000_add_oidc_only_to_settings.exs @@ -0,0 +1,20 @@ +defmodule Mv.Repo.Migrations.AddOidcOnlyToSettings do + @moduledoc """ + Adds oidc_only flag to settings. When true and OIDC is configured, + the sign-in page shows only OIDC (password login is hidden). + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :oidc_only, :boolean, default: false, null: false + end + end + + def down do + alter table(:settings) do + remove :oidc_only + end + end +end diff --git a/priv/resource_snapshots/repo/settings/20251127134451.json b/priv/resource_snapshots/repo/settings/20251127134451.json index fefc223..8767574 100644 --- a/priv/resource_snapshots/repo/settings/20251127134451.json +++ b/priv/resource_snapshots/repo/settings/20251127134451.json @@ -64,4 +64,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json index 4e635c4..da284c6 100644 --- a/priv/resource_snapshots/repo/settings/20251201115939.json +++ b/priv/resource_snapshots/repo/settings/20251201115939.json @@ -76,4 +76,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20251211195058.json b/priv/resource_snapshots/repo/settings/20251211195058.json index 4b437b8..ea73ec3 100644 --- a/priv/resource_snapshots/repo/settings/20251211195058.json +++ b/priv/resource_snapshots/repo/settings/20251211195058.json @@ -100,4 +100,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20260218185541.json b/priv/resource_snapshots/repo/settings/20260218185541.json index 4334f9a..9c7ef33 100644 --- a/priv/resource_snapshots/repo/settings/20260218185541.json +++ b/priv/resource_snapshots/repo/settings/20260218185541.json @@ -137,4 +137,4 @@ "repo": "Elixir.Mv.Repo", "schema": null, "table": "settings" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/settings/20260224122831.json b/priv/resource_snapshots/repo/settings/20260224122831.json new file mode 100644 index 0000000..73678b4 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260224122831.json @@ -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" +} diff --git a/test/mv/oidc_role_sync_config_test.exs b/test/mv/oidc_role_sync_config_test.exs index b4664aa..4b77378 100644 --- a/test/mv/oidc_role_sync_config_test.exs +++ b/test/mv/oidc_role_sync_config_test.exs @@ -1,21 +1,22 @@ defmodule Mv.OidcRoleSyncConfigTest do @moduledoc """ 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 describe "oidc_admin_group_name/0" 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) assert OidcRoleSyncConfig.oidc_admin_group_name() == nil end - test "returns configured admin group name when set" do - restore = put_config(admin_group_name: "mila-admin") + test "returns configured admin group name when set via ENV" do + restore = set_env("OIDC_ADMIN_GROUP_NAME", "mila-admin") on_exit(restore) assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin" @@ -24,26 +25,35 @@ defmodule Mv.OidcRoleSyncConfigTest do describe "oidc_groups_claim/0" 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) assert OidcRoleSyncConfig.oidc_groups_claim() == "groups" end - test "returns configured claim name when OIDC_GROUPS_CLAIM is set" do - restore = put_config(groups_claim: "ak_groups") + test "returns configured claim name when OIDC_GROUPS_CLAIM is set via ENV" do + restore = set_env("OIDC_GROUPS_CLAIM", "ak_groups") on_exit(restore) assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups" end end - defp put_config(opts) do - current = Application.get_env(:mv, :oidc_role_sync, []) - Application.put_env(:mv, :oidc_role_sync, Keyword.merge(current, opts)) + defp set_env(key, value) do + previous = System.get_env(key) + System.put_env(key, value) 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 diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs index 229f56a..a4729ec 100644 --- a/test/mv/oidc_role_sync_test.exs +++ b/test/mv/oidc_role_sync_test.exs @@ -12,14 +12,14 @@ defmodule Mv.OidcRoleSyncTest do setup do ensure_roles_exist() - restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups") + restore_config = put_oidc_env(admin_group_name: "mila-admin", groups_claim: "groups") on_exit(restore_config) :ok end describe "apply_admin_role_from_user_info/2" do test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do - restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups") + restore = put_oidc_env(admin_group_name: nil, groups_claim: "groups") on_exit(restore) email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com" @@ -58,7 +58,7 @@ defmodule Mv.OidcRoleSyncTest do end test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do - restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups") + restore = put_oidc_env(admin_group_name: "mila-admin", groups_claim: "ak_groups") on_exit(restore) email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com" @@ -131,13 +131,30 @@ defmodule Mv.OidcRoleSyncTest do end end - defp put_oidc_config(opts) do - current = Application.get_env(:mv, :oidc_role_sync, []) - merged = Keyword.merge(current, opts) - Application.put_env(:mv, :oidc_role_sync, merged) + defp put_oidc_env(opts) do + prev_admin = System.get_env("OIDC_ADMIN_GROUP_NAME") + prev_claim = System.get_env("OIDC_GROUPS_CLAIM") + + if opts[:admin_group_name] != nil do + System.put_env("OIDC_ADMIN_GROUP_NAME", to_string(opts[:admin_group_name])) + else + System.delete_env("OIDC_ADMIN_GROUP_NAME") + end + + if opts[:groups_claim] != nil do + System.put_env("OIDC_GROUPS_CLAIM", to_string(opts[:groups_claim])) + else + System.delete_env("OIDC_GROUPS_CLAIM") + end fn -> - Application.put_env(:mv, :oidc_role_sync, current) + if prev_admin, + do: System.put_env("OIDC_ADMIN_GROUP_NAME", prev_admin), + else: System.delete_env("OIDC_ADMIN_GROUP_NAME") + + if prev_claim, + do: System.put_env("OIDC_GROUPS_CLAIM", prev_claim), + else: System.delete_env("OIDC_GROUPS_CLAIM") end end diff --git a/test/mv_web/components/sidebar_authorization_test.exs b/test/mv_web/components/sidebar_authorization_test.exs index 110d9e5..5d4277b 100644 --- a/test/mv_web/components/sidebar_authorization_test.exs +++ b/test/mv_web/components/sidebar_authorization_test.exs @@ -30,7 +30,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) assert html =~ ~s(href="/members") - assert html =~ ~s(href="/membership_fee_types") + assert html =~ ~s(href="/membership_fee_settings") assert html =~ ~s(href="/statistics") assert html =~ ~s(data-testid="sidebar-administration") assert html =~ ~s(href="/users") @@ -55,7 +55,7 @@ defmodule MvWeb.SidebarAuthorizationTest do user = Fixtures.user_with_role_fixture("read_only") html = render_sidebar(sidebar_assigns(user)) - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(href="/admin/roles") refute html =~ ~s(href="/settings") @@ -76,7 +76,7 @@ defmodule MvWeb.SidebarAuthorizationTest do user = Fixtures.user_with_role_fixture("normal_user") html = render_sidebar(sidebar_assigns(user)) - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(href="/admin/roles") refute html =~ ~s(href="/settings") @@ -96,7 +96,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) refute html =~ ~s(href="/statistics") - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") refute html =~ ~s(data-testid="sidebar-administration") end @@ -117,7 +117,7 @@ defmodule MvWeb.SidebarAuthorizationTest do html = render_sidebar(sidebar_assigns(user)) refute html =~ ~s(href="/members") - refute html =~ ~s(href="/membership_fee_types") + refute html =~ ~s(href="/membership_fee_settings") refute html =~ ~s(href="/users") end end diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 36e46e2..28f98a2 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -54,7 +54,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Create custom field value create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Click delete button - find the delete link within the component view @@ -80,7 +80,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member1, custom_field, "test1") create_custom_field_value(member2, custom_field, "test2") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -93,7 +93,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "shows 0 members for custom field without values", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -108,7 +108,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "updates confirmation state when typing", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -126,7 +126,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "delete button is disabled when slug doesn't match", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -148,7 +148,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Open modal view @@ -185,7 +185,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do } do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -209,7 +209,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "closes modal without deleting", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") view |> element("#custom-fields-component a", "Delete") @@ -234,7 +234,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do describe "create custom field" do test "submitting new data field form creates custom field and shows success", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/datafields") # Open "New Data Field" form view diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 86680f3..6a739b5 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -64,21 +64,5 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert html =~ "must be present" end - - test "displays Memberdata section", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - assert html =~ "Memberdata" or html =~ "Member Data" - end - - test "displays flash message after member field visibility update", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate member field visibility update - send(view.pid, {:member_field_visibility_updated}) - - # Check for flash message - assert render(view) =~ "updated" or render(view) =~ "success" - end end end diff --git a/test/mv_web/live/member_field_live/index_component_test.exs b/test/mv_web/live/member_field_live/index_component_test.exs index 6ad1627..d3c1612 100644 --- a/test/mv_web/live/member_field_live/index_component_test.exs +++ b/test/mv_web/live/member_field_live/index_component_test.exs @@ -23,7 +23,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do describe "rendering" do test "renders all member fields from Constants", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Check that all member fields are displayed member_fields = Mv.Constants.member_fields() @@ -36,7 +36,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "displays show_in_overview status as badge", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Should have "Show in overview" column header assert html =~ "Show in overview" or html =~ "Show in Overview" @@ -46,7 +46,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "displays required status column", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Should have "Required" column; email is always required assert html =~ "Required" or html =~ "required" @@ -59,7 +59,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do {:ok, _updated} = Membership.update_settings(settings, %{member_field_visibility: %{}}) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # All fields should show as visible (Yes) by default # Check for "Yes" badge or similar indicator @@ -74,7 +74,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do {:ok, _updated} = Membership.update_member_field_visibility(settings, visibility_config) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Street and house_number should show as hidden (No) # Other fields should show as visible (Yes) @@ -102,7 +102,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "marks email as required (always from settings)", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Email is always required assert html =~ "email" or html =~ "Email" @@ -119,7 +119,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do required: true ) - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # First name row should show Required (and Optional for others) assert html =~ "First name" or html =~ "first_name" @@ -127,7 +127,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do end test "optional fields show Optional when not required in settings", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") + {:ok, _view, html} = live(conn, ~p"/admin/datafields") # Email is required; other fields default to optional assert html =~ "Optional" diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs index 71edbba..a836c4d 100644 --- a/test/mv_web/live/membership_fee_type_live/form_test.exs +++ b/test/mv_web/live/membership_fee_type_live/form_test.exs @@ -11,7 +11,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do require Ash.Query setup %{conn: conn} do - # User must have admin role (or normal_user) to access /membership_fee_types pages + # User must have admin role (or normal_user) to access /membership_fee_settings pages user = Mv.Fixtures.user_with_role_fixture("admin") authenticated_conn = conn_with_password_user(conn, user) %{conn: authenticated_conn, user: user} @@ -51,7 +51,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do describe "create form" do test "creates new membership fee type", %{conn: conn, user: user} do - {:ok, view, _html} = live(conn, "/membership_fee_types/new") + {:ok, view, _html} = live(conn, "/membership_fee_settings/new_fee_type") form_data = %{ "membership_fee_type[name]" => "New Type", @@ -65,7 +65,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do |> form("#membership-fee-type-form", form_data) |> render_submit() - assert to == "/membership_fee_types" + assert to == "/membership_fee_settings" # Verify type was created (use actor so read is authorized) type = @@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do end test "interval field is editable on create", %{conn: conn} do - {:ok, _view, html} = live(conn, "/membership_fee_types/new") + {:ok, _view, html} = live(conn, "/membership_fee_settings/new_fee_type") # Interval field should be editable (not disabled) refute html =~ "disabled" || html =~ "readonly" @@ -90,7 +90,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "loads existing type data", %{conn: conn} do fee_type = create_fee_type(%{name: "Existing Type", amount: Decimal.new("60.00")}) - {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, _view, html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") assert html =~ "Existing Type" assert html =~ "60" || html =~ "60,00" @@ -99,7 +99,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "interval field is grayed out on edit", %{conn: conn} do fee_type = create_fee_type(%{interval: :yearly}) - {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, _view, html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Interval field should be disabled assert html =~ "disabled" || html =~ "readonly" @@ -109,7 +109,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) create_member(%{membership_fee_type_id: fee_type.id}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount view @@ -129,7 +129,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do create_member(%{membership_fee_type_id: fee_type.id}) end) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount html = @@ -144,7 +144,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "amount change can be confirmed", %{conn: conn, user: user} do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount and confirm view @@ -173,7 +173,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do test "amount change can be cancelled", %{conn: conn, user: user} do fee_type = create_fee_type(%{amount: Decimal.new("50.00")}) - {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit") + {:ok, view, _html} = live(conn, "/membership_fee_settings/#{fee_type.id}/edit_fee_type") # Change amount and cancel view @@ -195,7 +195,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do end test "validation errors display correctly", %{conn: conn} do - {:ok, view, _html} = live(conn, "/membership_fee_types/new") + {:ok, view, _html} = live(conn, "/membership_fee_settings/new_fee_type") # Submit with invalid data html = @@ -214,7 +214,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do describe "permissions" do test "only admin can access", %{conn: conn} do # This test assumes non-admin users cannot access - {:ok, _view, html} = live(conn, "/membership_fee_types/new") + {:ok, _view, html} = live(conn, "/membership_fee_settings/new_fee_type") # Should show the form (admin user in setup) assert html =~ "Membership Fee Type" || html =~ "Beitragsart" diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs index 7d2d4be..c9bb7ca 100644 --- a/test/mv_web/live/membership_fee_type_live/index_test.exs +++ b/test/mv_web/live/membership_fee_type_live/index_test.exs @@ -60,7 +60,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do admin_user ) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") assert html =~ "Regular" assert html =~ "Reduced" @@ -77,33 +77,33 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do create_member(%{membership_fee_type_id: fee_type.id}, admin_user) end) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") assert html =~ "3" || html =~ "Members" || html =~ "Mitglieder" end test "create button navigates to form", %{conn: conn} do - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") {:error, {:live_redirect, %{to: to}}} = view - |> element("a[href='/membership_fee_types/new']") + |> element("a[href='/membership_fee_settings/new_fee_type']") |> render_click() - assert to == "/membership_fee_types/new" + assert to == "/membership_fee_settings/new_fee_type" end test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do fee_type = create_fee_type(%{interval: :yearly}, admin_user) - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") {:error, {:live_redirect, %{to: to}}} = view - |> element("a[href='/membership_fee_types/#{fee_type.id}/edit']") + |> element("a[href='/membership_fee_settings/#{fee_type.id}/edit_fee_type']") |> render_click() - assert to == "/membership_fee_types/#{fee_type.id}/edit" + assert to == "/membership_fee_settings/#{fee_type.id}/edit_fee_type" end end @@ -112,7 +112,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do fee_type = create_fee_type(%{interval: :yearly}, admin_user) create_member(%{membership_fee_type_id: fee_type.id}, admin_user) - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") # Delete button should be disabled assert html =~ "disabled" || html =~ "cursor-not-allowed" @@ -122,7 +122,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do fee_type = create_fee_type(%{interval: :yearly}, admin_user) # No members assigned - {:ok, view, _html} = live(conn, "/membership_fee_types") + {:ok, view, _html} = live(conn, "/membership_fee_settings") # Delete button should be enabled view @@ -142,10 +142,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do test "only admin can access", %{conn: conn} do # This test assumes non-admin users cannot access # Adjust based on actual permission implementation - {:ok, _view, html} = live(conn, "/membership_fee_types") + {:ok, _view, html} = live(conn, "/membership_fee_settings") # Should show the page (admin user in setup) - assert html =~ "Membership Fee Types" || html =~ "Beitragsarten" + assert html =~ "Membership Fee Settings" || html =~ "Beitragseinstellungen" || + html =~ "Membership Fee Types" end end end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index e342744..6dd8022 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -279,17 +279,11 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end @tag role: :member - test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - - @tag role: :member - test "GET /membership_fee_types/new redirects to user profile", %{ + test "GET /membership_fee_settings/new_fee_type redirects to user profile", %{ conn: conn, current_user: user } do - conn = get(conn, "/membership_fee_types/new") + conn = get(conn, "/membership_fee_settings/new_fee_type") assert redirected_to(conn) == "/users/#{user.id}" end @@ -385,7 +379,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end @tag role: :member - test "GET /membership_fee_types/:id/edit redirects to user profile", %{ + test "GET /membership_fee_settings/:id/edit_fee_type redirects to user profile", %{ conn: conn, current_user: user } do @@ -396,7 +390,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do |> List.first() if type do - conn = get(conn, "/membership_fee_types/#{type.id}/edit") + conn = get(conn, "/membership_fee_settings/#{type.id}/edit_fee_type") assert redirected_to(conn) == "/users/#{user.id}" end end @@ -680,15 +674,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert redirected_to(conn) == "/users/#{user.id}" end - @tag role: :read_only - test "GET /membership_fee_types redirects to user profile", %{ - conn: conn, - current_user: user - } do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - @tag role: :read_only test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do conn = get(conn, "/groups/new") @@ -864,15 +849,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert redirected_to(conn) == "/users/#{user.id}" end - @tag role: :normal_user - test "GET /membership_fee_types redirects to user profile", %{ - conn: conn, - current_user: user - } do - conn = get(conn, "/membership_fee_types") - assert redirected_to(conn) == "/users/#{user.id}" - end - @tag role: :normal_user test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do conn = get(conn, "/admin/roles")