diff --git a/docs/settings-authentication-mockup.txt b/docs/settings-authentication-mockup.txt new file mode 100644 index 0000000..00f64c4 --- /dev/null +++ b/docs/settings-authentication-mockup.txt @@ -0,0 +1,44 @@ +# Settings page – Authentication section (ASCII mockup) + +Structure after renaming "OIDC" to "Authentication" and adding the registration toggle. +Subsections use their own headings (h3) inside the main "Authentication" form_section. + ++------------------------------------------------------------------+ +| Settings | +| Manage global settings for the association. | ++------------------------------------------------------------------+ + ++-- Club Settings -------------------------------------------------+ +| Association Name: [________________] [Save Name] | ++------------------------------------------------------------------+ + ++-- Join Form -----------------------------------------------------+ +| ... (unchanged) | ++------------------------------------------------------------------+ + ++-- SMTP / E-Mail -------------------------------------------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Accounting-Software (Vereinfacht) Integration -----------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)") +| | +| Direct registration | <-- subsection heading (h3) +| [x] Allow direct registration (/register) | +| If disabled, users cannot sign up via /register; sign-in | +| and the join form remain available. | +| | +| OIDC (Single Sign-On) | <-- subsection heading (h3) +| (Some values are set via environment variables...) | +| Client ID: [________________] | +| Base URL: [________________] | +| Redirect URI: [________________] | +| Client Secret: [________________] (set) | +| Admin group name: [________________] | +| Groups claim: [________________] | +| [ ] Only OIDC sign-in (hide password login) | +| [Save OIDC Settings] | ++------------------------------------------------------------------+ diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 6b9cd1e..29a2d4b 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -405,6 +405,10 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Block direct registration when disabled in global settings + validate {Mv.Accounts.User.Validations.RegistrationEnabled, []}, + where: [action_is(:register_with_password)] + # Email uniqueness check for all actions that change the email attribute # Validates that user email is not already used by another (unlinked) member validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex new file mode 100644 index 0000000..71cc7b1 --- /dev/null +++ b/lib/accounts/user/validations/registration_enabled.ex @@ -0,0 +1,27 @@ +defmodule Mv.Accounts.User.Validations.RegistrationEnabled do + @moduledoc """ + Validation that blocks direct registration (register_with_password) when + registration is disabled in global settings. Used so that even direct API/form + submissions cannot register when the setting is off. + """ + use Ash.Resource.Validation + + alias Mv.Membership + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(_changeset, _opts, _context) do + case Membership.get_settings() do + {:ok, %{registration_enabled: true}} -> + :ok + + _ -> + {:error, + field: :base, + message: + "Registration is disabled. Please use the join form or contact an administrator."} + end + end +end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index ce63589..83c5c8b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do (e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `default_membership_fee_type_id` - Default membership fee type for new members (optional) + - `registration_enabled` - Whether direct registration via /register is allowed (default: true) - `join_form_enabled` - Whether the public /join page is active (default: false) - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is either a member field name string (e.g. "email") or a custom field UUID. Email is always @@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -514,6 +517,15 @@ defmodule Mv.Membership.Setting do description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env." end + # Authentication: direct registration toggle + attribute :registration_enabled, :boolean do + allow_nil? false + default true + public? true + + description "When true, users can register via /register; when false, only sign-in and join form remain available." + end + # Join form (Beitrittsformular) settings attribute :join_form_enabled, :boolean do allow_nil? false diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 44b3408..3aab0ed 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -35,6 +35,19 @@ defmodule MvWeb.AuthOverrides do end end +defmodule MvWeb.AuthOverridesRegistrationDisabled do + @moduledoc """ + When direct registration is disabled in global settings, this override is + prepended in SignInLive so the Password component hides the "Need an account?" + toggle (register_toggle_text: nil disables the register link per library docs). + """ + use AshAuthentication.Phoenix.Overrides + + override AshAuthentication.Phoenix.Components.Password do + set :register_toggle_text, nil + end +end + defmodule MvWeb.AuthOverridesDE do @moduledoc """ German locale-specific overrides for AshAuthentication Phoenix components. diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex index 96bf62b..45cf44a 100644 --- a/lib/mv_web/live/auth/sign_in_live.ex +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -19,7 +19,7 @@ defmodule MvWeb.SignInLive do alias AshAuthentication.Phoenix.Components alias Mv.Config - alias MvWeb.{AuthOverridesDE, Layouts} + alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts} @impl true def mount(_params, session, socket) do @@ -36,7 +36,18 @@ defmodule MvWeb.SignInLive do # without _gettext support (e.g. HorizontalRule) still render in German. base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default]) locale_overrides = if locale == "de", do: [AuthOverridesDE], else: [] - overrides = locale_overrides ++ base_overrides + + registration_disabled = + if session["registration_enabled"] == false, + do: [AuthOverridesRegistrationDisabled], + else: [] + + # When registration is disabled: hide register link (register_path: nil) and hide + # "Need an account?" toggle (override register_toggle_text: nil so it takes precedence). + overrides = registration_disabled ++ locale_overrides ++ base_overrides + + register_path = + if session["registration_enabled"] == false, do: nil, else: session["register_path"] socket = socket @@ -44,7 +55,7 @@ defmodule MvWeb.SignInLive do |> assign_new(:otp_app, fn -> nil end) |> assign(:path, session["path"] || "/") |> assign(:reset_path, session["reset_path"]) - |> assign(:register_path, session["register_path"]) + |> assign(:register_path, register_path) |> assign(:current_tenant, session["tenant"]) |> assign(:resources, session["resources"]) |> assign(:context, session["context"] || %{}) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fadbc32..158b7fa 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -11,12 +11,14 @@ defmodule MvWeb.GlobalSettingsLive do ## Settings - `club_name` - The name of the association/club (required) + - `registration_enabled` - Whether direct registration via /register is allowed - `join_form_enabled` - Whether the public /join page is active - `join_form_field_ids` - Ordered list of field IDs shown on the join form - `join_form_field_required` - Map of field ID => required boolean ## Events - `validate` / `save` - Club settings form + - `toggle_registration_enabled` - Enable/disable direct registration (/register) - `toggle_join_form_enabled` - Enable/disable the join form - `add_join_form_field` / `remove_join_form_field` - Manage join form fields - `toggle_join_form_field_required` - Toggle required flag per field @@ -80,6 +82,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) + |> assign(:registration_enabled, settings.registration_enabled != false) |> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?()) |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?()) |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?()) @@ -607,8 +610,29 @@ defmodule MvWeb.GlobalSettingsLive do <% end %> - <%!-- OIDC Section --%> - <.form_section title={gettext("OIDC (Single Sign-On)")}> + <%!-- Authentication: Direct registration + OIDC --%> + <.form_section title={gettext("Authentication")}> +
+ {gettext( + "If disabled, users cannot sign up via /register; sign-in and the join form remain available." + )} +
+{gettext("Some values are set via environment variables. Those fields are read-only.")} @@ -853,6 +877,7 @@ defmodule MvWeb.GlobalSettingsLive do socket = socket |> assign(:settings, fresh_settings) + |> assign(:registration_enabled, fresh_settings.registration_enabled != false) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) @@ -889,6 +914,24 @@ defmodule MvWeb.GlobalSettingsLive do {:noreply, persist_join_form_settings(socket)} end + @impl true + def handle_event("toggle_registration_enabled", _params, socket) do + settings = socket.assigns.settings + new_value = not socket.assigns.registration_enabled + + case Membership.update_settings(settings, %{registration_enabled: new_value}) do + {:ok, updated_settings} -> + {:noreply, + socket + |> assign(:settings, updated_settings) + |> assign(:registration_enabled, updated_settings.registration_enabled != false) + |> assign_form()} + + {:error, _} -> + {:noreply, put_flash(socket, :error, gettext("Failed to update setting."))} + end + end + @impl true def handle_event("toggle_add_field_dropdown", _params, socket) do {:noreply, diff --git a/lib/mv_web/plugs/registration_enabled.ex b/lib/mv_web/plugs/registration_enabled.ex new file mode 100644 index 0000000..a8405bb --- /dev/null +++ b/lib/mv_web/plugs/registration_enabled.ex @@ -0,0 +1,55 @@ +defmodule MvWeb.Plugs.RegistrationEnabled do + @moduledoc """ + When direct registration is disabled in settings: + - GET /register is redirected to /sign-in with a flash message. + Puts registration_enabled from settings into session for /sign-in and /register + so the sign-in LiveView can show or hide the register link. + """ + import Plug.Conn + import Phoenix.Controller + + alias Mv.Membership + + def init(opts), do: opts + + def call(conn, _opts) do + conn + |> maybe_redirect_register() + |> maybe_put_registration_enabled_in_session() + end + + defp maybe_redirect_register(conn) do + if conn.request_path == "/register" and conn.method == "GET" do + case Membership.get_settings() do + {:ok, %{registration_enabled: true}} -> + conn + + _ -> + conn + |> put_flash(:info, get_flash_message(conn)) + |> redirect(to: "/sign-in") + |> halt() + end + else + conn + end + end + + defp get_flash_message(_conn) do + Gettext.dgettext(MvWeb.Gettext, "default", "Registration is disabled.") + end + + defp maybe_put_registration_enabled_in_session(conn) do + if conn.request_path in ["/sign-in", "/register"] do + enabled = + case Membership.get_settings() do + {:ok, %{registration_enabled: enabled?}} -> enabled? + _ -> true + end + + put_session(conn, "registration_enabled", enabled) + else + conn + end + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 945e22c..c7df3fd 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -16,6 +16,7 @@ defmodule MvWeb.Router do plug :set_locale plug MvWeb.Plugs.CheckPagePermission plug MvWeb.Plugs.JoinFormEnabled + plug MvWeb.Plugs.RegistrationEnabled end pipeline :api do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c23799a..9396bab 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3867,3 +3867,33 @@ msgstr "Vielen Dank" #, elixir-autogen, elixir-format msgid "You will receive an email once your application has been reviewed." msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "Direkte Registrierung erlauben (/register)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "Anmeldung" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "Direkte Registrierung" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "Einstellung konnte nicht gespeichert werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar." + +#: lib/mv_web/plugs/registration_enabled.ex +#, elixir-autogen, elixir-format +msgid "Registration is disabled." +msgstr "Die Registrierung ist deaktiviert." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ff61365..1d01d9e 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3867,3 +3867,33 @@ msgstr "" #, elixir-autogen, elixir-format msgid "You will receive an email once your application has been reviewed." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "" + +#: lib/mv_web/plugs/registration_enabled.ex +#, elixir-autogen, elixir-format +msgid "Registration is disabled." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 82aed54..1ed8cee 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3867,3 +3867,33 @@ msgstr "Thank you" #, elixir-autogen, elixir-format msgid "You will receive an email once your application has been reviewed." msgstr "You will receive an email once your application has been reviewed." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "Allow direct registration (/register)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "Authentication" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "Direct registration" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "Failed to update setting." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available." + +#: lib/mv_web/plugs/registration_enabled.ex +#, elixir-autogen, elixir-format +msgid "Registration is disabled." +msgstr "Registration is disabled." diff --git a/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs b/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs new file mode 100644 index 0000000..facd3e2 --- /dev/null +++ b/priv/repo/migrations/20260313140000_add_registration_enabled_to_settings.exs @@ -0,0 +1,20 @@ +defmodule Mv.Repo.Migrations.AddRegistrationEnabledToSettings do + @moduledoc """ + Adds registration_enabled flag to settings. When false, direct registration + via /register is disabled; sign-in and join form remain available. + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :registration_enabled, :boolean, default: true, null: false + end + end + + def down do + alter table(:settings) do + remove :registration_enabled + end + end +end diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 328a9f4..e449284 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -4,6 +4,8 @@ defmodule MvWeb.AuthControllerTest do import Phoenix.ConnTest import ExUnit.CaptureLog + alias Mv.Membership + # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do # Create new conn but preserve sandbox metadata for database access @@ -169,6 +171,23 @@ defmodule MvWeb.AuthControllerTest do assert html =~ "length must be greater than or equal to 8" end + test "when registration is disabled, sign-in page does not show Need an account? toggle", %{ + conn: authenticated_conn + } do + {:ok, settings} = Membership.get_settings() + original = Map.get(settings, :registration_enabled, true) + {:ok, _} = Membership.update_settings(settings, %{registration_enabled: false}) + + try do + conn = build_unauthenticated_conn(authenticated_conn) + {:ok, _view, html} = live(conn, ~p"/sign-in") + refute html =~ "Need an account?" + after + {:ok, s} = Membership.get_settings() + Membership.update_settings(s, %{registration_enabled: original}) + end + end + # Access control test "unauthenticated user accessing protected route gets redirected to sign-in", %{ conn: authenticated_conn