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

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

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.
- `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

View file

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

View file

@ -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"] || %{})

View file

@ -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 %>
</.form>
</.form_section>
<%!-- OIDC Section --%>
<.form_section title={gettext("OIDC (Single Sign-On)")}>
<%!-- Authentication: Direct registration + OIDC --%>
<.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 %>
<p class="text-sm text-base-content/70 mb-4">
{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,

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 MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled
end
pipeline :api do