diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 66a93a5..ed9f130 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -89,7 +89,8 @@ lib/
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
-│ ├── setting.ex # Global settings (singleton resource)
+│ ├── setting.ex # Global settings (singleton resource; incl. join form config)
+│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
│ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource
│ └── email.ex # Email custom type
diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md
index b2a814b..a6297ba 100644
--- a/docs/development-progress-log.md
+++ b/docs/development-progress-log.md
@@ -809,8 +809,13 @@ end
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass.
-**Subtask 3 – Admin: Join form settings (TDD tests only):**
-- Test file: `test/membership/setting_join_form_test.exs` – TDD tests for join form settings (persistence, validation, allowlist, defaults, robustness). Tests are red until Setting gains `join_form_enabled`, `join_form_field_ids`, `join_form_field_required` and `Mv.Membership.get_join_form_allowlist/0` is implemented. No functionality implemented yet.
+**Subtask 3 – Admin: Join form settings (done):**
+- **Setting resource** (`lib/membership/setting.ex`): 3 new attributes `join_form_enabled` (boolean, default false), `join_form_field_ids` ({:array, :string} – ordered list of member field names or custom field UUIDs), `join_form_field_required` (:map – field ID → boolean). Added to `:create` and `:update` accept lists. Validation rejects field IDs that are neither valid member field names nor UUID format. Migration: `20260310114701_add_join_form_settings_to_settings.exs`.
+- **NormalizeJoinFormSettings** (`lib/membership/setting/changes/normalize_join_form_settings.ex`): Change applied on create/update whenever join form attrs are changing. Ensures email is always in `join_form_field_ids`, forces `join_form_field_required["email"] = true`, drops required flags for fields not in `join_form_field_ids`.
+- **Domain** (`lib/membership/membership.ex`): `Mv.Membership.get_join_form_allowlist/0` – returns `[]` when join form is disabled, otherwise a list of `%{id, required, type}` maps (type = `:member_field` or `:custom_field` based on ID format).
+- **GlobalSettingsLive** (`lib/mv_web/live/global_settings_live.ex`): New "Join Form" / "Beitrittsformular" section between Club Settings and Vereinfacht. Checkbox to enable/disable, table of selected fields (with Required checkbox per field – email always checked/disabled, other fields can be toggled), "Add field" dropdown with all unselected member fields and custom fields, "Save Join Form Settings" button. State is managed locally (not via AshPhoenix.Form); saved on explicit save click.
+- **Translations**: 14 new German strings in `priv/gettext/de/LC_MESSAGES/default.po` (Beitrittsformular, Felder im Beitrittsformular, Feld hinzufügen, etc.).
+- Tests: All 13 tests in `test/membership/setting_join_form_test.exs` pass; full test suite 1900 tests, 0 failures.
### Test Data Management
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 5e01a6a..3f34903 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -455,6 +455,56 @@ defmodule Mv.Membership do
end
end
+ @doc """
+ Returns the allowlist of fields configured for the public join form.
+
+ Reads the current settings. When the join form is disabled (or no settings exist),
+ returns an empty list. When enabled, returns each configured field as a map with:
+ - `:id` - field identifier string (member field name or custom field UUID)
+ - `:required` - boolean; email is always true
+ - `:type` - `:member_field` or `:custom_field`
+
+ This is the server-side allowlist used by the join form submit action (Subtask 4)
+ to enforce which fields are accepted from user input.
+
+ ## Returns
+
+ - `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
+ - `[]` when join form is disabled or settings are missing
+
+ ## Examples
+
+ iex> Mv.Membership.get_join_form_allowlist()
+ [%{id: "email", required: true, type: :member_field},
+ %{id: "first_name", required: false, type: :member_field}]
+
+ """
+ def get_join_form_allowlist do
+ case get_settings() do
+ {:ok, settings} ->
+ if settings.join_form_enabled do
+ build_join_form_allowlist(settings)
+ else
+ []
+ end
+
+ {:error, _} ->
+ []
+ end
+ end
+
+ defp build_join_form_allowlist(settings) do
+ field_ids = settings.join_form_field_ids || []
+ required_config = settings.join_form_field_required || %{}
+ member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+
+ Enum.map(field_ids, fn id ->
+ type = if id in member_field_names, do: :member_field, else: :custom_field
+ required = Map.get(required_config, id, false)
+ %{id: id, required: required, type: type}
+ end)
+ end
+
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
end
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 894725f..adf05b9 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -15,6 +15,12 @@ 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)
+ - `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
+ included and always required; normalization enforces this automatically.
+ - `join_form_field_required` - Map of field ID => required boolean for the join form.
+ Email is always forced to true.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@@ -86,8 +92,13 @@ defmodule Mv.Membership.Setting do
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
- :oidc_only
+ :oidc_only,
+ :join_form_enabled,
+ :join_form_field_ids,
+ :join_form_field_required
]
+
+ change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end
update :update do
@@ -110,8 +121,13 @@ defmodule Mv.Membership.Setting do
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
- :oidc_only
+ :oidc_only,
+ :join_form_enabled,
+ :join_form_field_ids,
+ :join_form_field_required
]
+
+ change Mv.Membership.Setting.Changes.NormalizeJoinFormSettings
end
update :update_member_field_visibility do
@@ -232,6 +248,39 @@ defmodule Mv.Membership.Setting do
end,
on: [:create, :update]
+ # Validate join_form_field_ids: each entry must be a known member field name
+ # or a UUID-format string (custom field ID). Normalization (NormalizeJoinFormSettings
+ # change) runs before validations, so email is already present when this runs.
+ validate fn changeset, _context ->
+ field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
+
+ if is_list(field_ids) and field_ids != [] do
+ valid_member_fields =
+ Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+
+ uuid_pattern =
+ ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+ invalid_ids =
+ Enum.reject(field_ids, fn id ->
+ is_binary(id) and
+ (id in valid_member_fields or Regex.match?(uuid_pattern, id))
+ end)
+
+ if Enum.empty?(invalid_ids) do
+ :ok
+ else
+ {:error,
+ field: :join_form_field_ids,
+ message:
+ "Invalid field identifiers: #{inspect(invalid_ids)}. Use member field names or custom field UUIDs."}
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:create, :update]
+
# Validate default_membership_fee_type_id exists if set
validate fn changeset, context ->
fee_type_id =
@@ -382,6 +431,29 @@ defmodule Mv.Membership.Setting do
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
end
+ # Join form (Beitrittsformular) settings
+ attribute :join_form_enabled, :boolean do
+ allow_nil? false
+ default false
+ public? true
+
+ description "When true, the public /join page is active and new members can submit a request."
+ end
+
+ attribute :join_form_field_ids, {:array, :string} do
+ allow_nil? true
+ public? true
+
+ description "Ordered list of field IDs shown on the join form. Each entry is a member field name (e.g. 'email') or a custom field UUID. Email is always present after normalization."
+ end
+
+ attribute :join_form_field_required, :map do
+ allow_nil? true
+ public? true
+
+ description "Map of field ID => required boolean for the join form. Email is always true after normalization."
+ end
+
timestamps()
end
diff --git a/lib/membership/setting/changes/normalize_join_form_settings.ex b/lib/membership/setting/changes/normalize_join_form_settings.ex
new file mode 100644
index 0000000..d21434a
--- /dev/null
+++ b/lib/membership/setting/changes/normalize_join_form_settings.ex
@@ -0,0 +1,60 @@
+defmodule Mv.Membership.Setting.Changes.NormalizeJoinFormSettings do
+ @moduledoc """
+ Ash change that normalizes join form field settings before persist.
+
+ Applied on create and update actions whenever join form attributes are present.
+
+ Rules enforced:
+ - Email is always added to join_form_field_ids if not already present.
+ - Email is always marked as required (true) in join_form_field_required.
+ - Keys in join_form_field_required that are not in join_form_field_ids are dropped.
+
+ Only runs when join_form_field_ids is being changed; if only
+ join_form_field_required changes, normalization still uses the current
+ (possibly changed) field_ids to strip orphaned required flags.
+ """
+ use Ash.Resource.Change
+
+ def change(changeset, _opts, _context) do
+ changing_ids? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_ids)
+ changing_required? = Ash.Changeset.changing_attribute?(changeset, :join_form_field_required)
+
+ if changing_ids? or changing_required? do
+ normalize(changeset)
+ else
+ changeset
+ end
+ end
+
+ defp normalize(changeset) do
+ field_ids = Ash.Changeset.get_attribute(changeset, :join_form_field_ids)
+ required_config = Ash.Changeset.get_attribute(changeset, :join_form_field_required)
+
+ field_ids = normalize_field_ids(field_ids)
+ required_config = normalize_required(field_ids, required_config)
+
+ changeset
+ |> Ash.Changeset.force_change_attribute(:join_form_field_ids, field_ids)
+ |> Ash.Changeset.force_change_attribute(:join_form_field_required, required_config)
+ end
+
+ defp normalize_field_ids(nil), do: ["email"]
+
+ defp normalize_field_ids(ids) when is_list(ids) do
+ if "email" in ids do
+ ids
+ else
+ ["email" | ids]
+ end
+ end
+
+ defp normalize_field_ids(_), do: ["email"]
+
+ defp normalize_required(field_ids, required_config) do
+ base = if is_map(required_config), do: required_config, else: %{}
+
+ base
+ |> Map.filter(fn {key, _} -> key in field_ids end)
+ |> Map.put("email", true)
+ end
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index bb5529e..8a8ff0d 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -990,7 +990,7 @@ defmodule MvWeb.CoreComponents do
/>
- {gettext("Actions")}
+ {gettext("Actions")}
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 58eed2a..651afc0 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -4,16 +4,24 @@ defmodule MvWeb.GlobalSettingsLive do
## Features
- Edit the association/club name
+ - Configure the public join form (Beitrittsformular)
- Manage custom fields
- Real-time form validation
- Success/error feedback
## Settings
- `club_name` - The name of the association/club (required)
+ - `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` - Real-time form validation
- - `save` - Save settings changes
+ - `validate` / `save` - Club settings form
+ - `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
+ - `toggle_add_field_dropdown` / `hide_add_field_dropdown` - Dropdown visibility
+ - Join form changes (enable/disable, add/remove fields, required toggles) are persisted immediately
## Note
Settings is a singleton resource - there is only one settings record.
@@ -31,6 +39,7 @@ defmodule MvWeb.GlobalSettingsLive do
alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers
+ alias MvWeb.Translations.MemberFields
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@@ -42,6 +51,9 @@ defmodule MvWeb.GlobalSettingsLive do
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
+ actor = MvWeb.LiveHelpers.current_actor(socket)
+ custom_fields = load_custom_fields(actor)
+
socket =
socket
|> assign(:page_title, gettext("Settings"))
@@ -65,6 +77,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, present?(settings.oidc_client_secret))
+ |> assign_join_form_state(settings, custom_fields)
|> assign_form()
{:ok, socket}
@@ -103,6 +116,144 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- Join Form Section (Beitrittsformular) --%>
+ <.form_section title={gettext("Join Form")}>
+
+ {gettext("Configure the public join form that allows new members to submit a join request.")}
+
+
+ <%!-- Enable/disable --%>
+
+
+
+
+
+ <%!-- Board approval (future feature) --%>
+
+
+
+
+
+
+ <%!-- Field list header + Add button (left-aligned) --%>
+