feat: allow disabling registration
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-03-13 16:40:39 +01:00
parent eb18209669
commit 09e4b64663
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
14 changed files with 344 additions and 5 deletions

View file

@ -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] |
+------------------------------------------------------------------+

View file

@ -405,6 +405,10 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])], where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8" 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 # Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member # Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember

View file

@ -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

View file

@ -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. (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) - `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) - `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_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 - `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 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_ssl,
:smtp_from_name, :smtp_from_name,
:smtp_from_email, :smtp_from_email,
:registration_enabled,
:join_form_enabled, :join_form_enabled,
:join_form_field_ids, :join_form_field_ids,
:join_form_field_required :join_form_field_required
@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do
:smtp_ssl, :smtp_ssl,
:smtp_from_name, :smtp_from_name,
:smtp_from_email, :smtp_from_email,
:registration_enabled,
:join_form_enabled, :join_form_enabled,
:join_form_field_ids, :join_form_field_ids,
:join_form_field_required :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." description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
end 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 # Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do attribute :join_form_enabled, :boolean do
allow_nil? false allow_nil? false

View file

@ -35,6 +35,19 @@ defmodule MvWeb.AuthOverrides do
end end
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 defmodule MvWeb.AuthOverridesDE do
@moduledoc """ @moduledoc """
German locale-specific overrides for AshAuthentication Phoenix components. German locale-specific overrides for AshAuthentication Phoenix components.

View file

@ -19,7 +19,7 @@ defmodule MvWeb.SignInLive do
alias AshAuthentication.Phoenix.Components alias AshAuthentication.Phoenix.Components
alias Mv.Config alias Mv.Config
alias MvWeb.{AuthOverridesDE, Layouts} alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
@ -36,7 +36,18 @@ defmodule MvWeb.SignInLive do
# without _gettext support (e.g. HorizontalRule) still render in German. # without _gettext support (e.g. HorizontalRule) still render in German.
base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default]) base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default])
locale_overrides = if locale == "de", do: [AuthOverridesDE], else: [] 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 =
socket socket
@ -44,7 +55,7 @@ defmodule MvWeb.SignInLive do
|> assign_new(:otp_app, fn -> nil end) |> assign_new(:otp_app, fn -> nil end)
|> assign(:path, session["path"] || "/") |> assign(:path, session["path"] || "/")
|> assign(:reset_path, session["reset_path"]) |> assign(:reset_path, session["reset_path"])
|> assign(:register_path, session["register_path"]) |> assign(:register_path, register_path)
|> assign(:current_tenant, session["tenant"]) |> assign(:current_tenant, session["tenant"])
|> assign(:resources, session["resources"]) |> assign(:resources, session["resources"])
|> assign(:context, session["context"] || %{}) |> assign(:context, session["context"] || %{})

View file

@ -11,12 +11,14 @@ defmodule MvWeb.GlobalSettingsLive do
## Settings ## Settings
- `club_name` - The name of the association/club (required) - `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_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_ids` - Ordered list of field IDs shown on the join form
- `join_form_field_required` - Map of field ID => required boolean - `join_form_field_required` - Map of field ID => required boolean
## Events ## Events
- `validate` / `save` - Club settings form - `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
- `toggle_join_form_enabled` - Enable/disable the join form - `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields - `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field - `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_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> 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_env_configured, Mv.Config.smtp_env_configured?())
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?()) |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?()) |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
@ -607,8 +610,29 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %> <% end %>
</.form> </.form>
</.form_section> </.form_section>
<%!-- OIDC Section --%> <%!-- Authentication: Direct registration + OIDC --%>
<.form_section title={gettext("OIDC (Single Sign-On)")}> <.form_section title={gettext("Authentication")}>
<h3 class="font-medium mb-3">{gettext("Direct registration")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"If disabled, users cannot sign up via /register; sign-in and the join form remain available."
)}
</p>
<div class="flex items-center gap-3 mb-6">
<input
type="checkbox"
id="registration-enabled-checkbox"
class="checkbox checkbox-sm"
checked={@registration_enabled}
phx-click="toggle_registration_enabled"
aria-label={gettext("Allow direct registration (/register)")}
/>
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium">
{gettext("Allow direct registration (/register)")}
</label>
</div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
<%= if @oidc_env_configured do %> <%= if @oidc_env_configured do %>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")} {gettext("Some values are set via environment variables. Those fields are read-only.")}
@ -853,6 +877,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, fresh_settings)
|> assign(:registration_enabled, fresh_settings.registration_enabled != false)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
@ -889,6 +914,24 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, persist_join_form_settings(socket)} {:noreply, persist_join_form_settings(socket)}
end 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 @impl true
def handle_event("toggle_add_field_dropdown", _params, socket) do def handle_event("toggle_add_field_dropdown", _params, socket) do
{:noreply, {:noreply,

View file

@ -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

View file

@ -16,6 +16,7 @@ defmodule MvWeb.Router do
plug :set_locale plug :set_locale
plug MvWeb.Plugs.CheckPagePermission plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled
end end
pipeline :api do pipeline :api do

View file

@ -3867,3 +3867,33 @@ msgstr "Vielen Dank"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed." 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." 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."

View file

@ -3867,3 +3867,33 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed." msgid "You will receive an email once your application has been reviewed."
msgstr "" 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 ""

View file

@ -3867,3 +3867,33 @@ msgstr "Thank you"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed." msgid "You will receive an email once your application has been reviewed."
msgstr "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."

View file

@ -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

View file

@ -4,6 +4,8 @@ defmodule MvWeb.AuthControllerTest do
import Phoenix.ConnTest import Phoenix.ConnTest
import ExUnit.CaptureLog import ExUnit.CaptureLog
alias Mv.Membership
# Helper to create an unauthenticated conn (preserves sandbox metadata) # Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access # 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" assert html =~ "length must be greater than or equal to 8"
end 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 # Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{ test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn conn: authenticated_conn