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) --%> +

{gettext("Fields on the join form")}

+
+ <.button + type="button" + variant="primary" + phx-click="toggle_add_field_dropdown" + disabled={Enum.empty?(@available_join_form_member_fields) and Enum.empty?(@available_join_form_custom_fields)} + aria-haspopup="listbox" + aria-expanded={to_string(@show_add_field_dropdown)} + > + <.icon name="hero-plus" class="size-4" /> + {gettext("Add field")} + + + <%!-- Available fields dropdown (sections: Personal data, Custom fields) --%> +
+
+
+ {gettext("Personal data")} +
+
+ {field.label} +
+
+
+
+ {gettext("Individual fields")} +
+
+ {field.label} +
+
+
+
+ + <%!-- Empty state --%> +

+ {gettext("No fields selected. Add at least the email field.")} +

+ + <%!-- Fields table (compact width) --%> +
+ <.table + id="join-form-fields-table" + rows={@join_form_fields} + row_id={fn field -> "join-field-#{field.id}" end} + > + <:col :let={field} label={gettext("Field")} class="min-w-[14rem]"> + {field.label} + + <:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center"> + + + <:action :let={field}> + <.tooltip content={gettext("Remove")} position="left"> + <.button + type="button" + variant="danger" + size="sm" + disabled={not field.can_remove} + class={if(not field.can_remove, do: "opacity-50 cursor-not-allowed", else: "")} + phx-click="remove_join_form_field" + phx-value-field_id={field.id} + aria-label={gettext("Remove field %{label}", label: field.label)} + > + <.icon name="hero-trash" class="size-4" /> + + + + +
+
+ <%!-- Vereinfacht Integration Section --%> <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %> @@ -426,6 +577,126 @@ defmodule MvWeb.GlobalSettingsLive do end end + # ---- Join form event handlers ---- + + @impl true + def handle_event("toggle_join_form_enabled", _params, socket) do + socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled) + {:noreply, persist_join_form_settings(socket)} + end + + @impl true + def handle_event("toggle_add_field_dropdown", _params, socket) do + {:noreply, + assign(socket, :show_add_field_dropdown, not socket.assigns.show_add_field_dropdown)} + end + + @impl true + def handle_event("hide_add_field_dropdown", _params, socket) do + {:noreply, assign(socket, :show_add_field_dropdown, false)} + end + + @impl true + def handle_event("add_join_form_field", %{"field_id" => field_id}, socket) do + member_avail = socket.assigns.available_join_form_member_fields + custom_avail = socket.assigns.available_join_form_custom_fields + current = socket.assigns.join_form_fields + + field_to_add = + Enum.find(member_avail, &(&1.id == field_id)) || + Enum.find(custom_avail, &(&1.id == field_id)) + + socket = + if field_to_add do + full_field = %{ + id: field_to_add.id, + label: field_to_add.label, + type: field_to_add.type, + required: false, + can_remove: field_to_add.id != "email", + can_toggle_required: field_to_add.id != "email" + } + + new_fields = current ++ [full_field] + new_member = Enum.reject(member_avail, &(&1.id == field_id)) + new_custom = Enum.reject(custom_avail, &(&1.id == field_id)) + + socket + |> assign(:join_form_fields, new_fields) + |> assign(:available_join_form_member_fields, new_member) + |> assign(:available_join_form_custom_fields, new_custom) + |> assign(:show_add_field_dropdown, false) + else + socket + end + + {:noreply, persist_join_form_settings(socket)} + end + + @impl true + def handle_event("remove_join_form_field", %{"field_id" => field_id}, socket) do + if field_id == "email" do + {:noreply, socket} + else + current = socket.assigns.join_form_fields + custom_fields = socket.assigns.join_form_custom_fields + new_fields = Enum.reject(current, &(&1.id == field_id)) + new_ids = Enum.map(new_fields, & &1.id) + %{member_fields: new_member, custom_fields: new_custom} = + build_available_join_form_fields(new_ids, custom_fields) + + socket = + socket + |> assign(:join_form_fields, new_fields) + |> assign(:available_join_form_member_fields, new_member) + |> assign(:available_join_form_custom_fields, new_custom) + |> persist_join_form_settings() + + {:noreply, socket} + end + end + + @impl true + def handle_event("toggle_join_form_field_required", %{"field_id" => "email"}, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("toggle_join_form_field_required", %{"field_id" => field_id}, socket) do + new_fields = + Enum.map(socket.assigns.join_form_fields, &toggle_required_if_matches(&1, field_id)) + + socket = assign(socket, :join_form_fields, new_fields) |> persist_join_form_settings() + {:noreply, socket} + end + + defp persist_join_form_settings(socket) do + settings = socket.assigns.settings + field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id) + + required_map = + socket.assigns.join_form_fields + |> Map.new(fn field -> {field.id, field.required} end) + + attrs = %{ + join_form_enabled: socket.assigns.join_form_enabled, + join_form_field_ids: field_ids, + join_form_field_required: required_map + } + + case Membership.update_settings(settings, attrs) do + {:ok, updated_settings} -> + custom_fields = socket.assigns.join_form_custom_fields + + socket + |> assign(:settings, updated_settings) + |> assign_join_form_state(updated_settings, custom_fields) + + {:error, _error} -> + put_flash(socket, :error, gettext("Could not save join form settings.")) + 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 @@ -709,4 +980,94 @@ defmodule MvWeb.GlobalSettingsLive do """ end + + # ---- Join form helper functions ---- + + defp assign_join_form_state(socket, settings, custom_fields) do + enabled = settings.join_form_enabled || false + raw_ids = settings.join_form_field_ids || [] + field_ids = if "email" in raw_ids, do: raw_ids, else: ["email" | raw_ids] + required_config = settings.join_form_field_required || %{} + + join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields) + %{member_fields: member_avail, custom_fields: custom_avail} = + build_available_join_form_fields(field_ids, custom_fields) + + socket + |> assign(:join_form_enabled, enabled) + |> assign(:join_form_fields, join_form_fields) + |> assign(:available_join_form_member_fields, member_avail) + |> assign(:available_join_form_custom_fields, custom_avail) + |> assign(:show_add_field_dropdown, false) + |> assign(:join_form_custom_fields, custom_fields) + end + + defp build_join_form_fields(field_ids, required_config, custom_fields) do + Enum.map(field_ids, fn id -> + label = join_form_field_label(id, custom_fields) + required = if id == "email", do: true, else: Map.get(required_config, id, false) + type = if id in member_field_id_strings(), do: :member_field, else: :custom_field + + %{ + id: id, + label: label, + required: required, + can_remove: id != "email", + can_toggle_required: id != "email", + type: type + } + end) + end + + defp build_available_join_form_fields(selected_ids, custom_fields) do + member_fields = + Mv.Constants.member_fields() + |> Enum.reject(fn field -> Atom.to_string(field) in selected_ids end) + |> Enum.map(fn field -> + %{id: Atom.to_string(field), label: MemberFields.label(field), type: :member_field} + end) + + custom_field_entries = + custom_fields + |> Enum.reject(fn cf -> cf.id in selected_ids end) + |> Enum.map(fn cf -> + %{id: cf.id, label: cf.name, type: :custom_field} + end) + |> Enum.sort_by(& &1.label) + + %{member_fields: member_fields, custom_fields: custom_field_entries} + end + + defp join_form_field_label(id, custom_fields) do + if id in member_field_id_strings() do + MemberFields.label(String.to_existing_atom(id)) + else + case Enum.find(custom_fields, &(&1.id == id)) do + nil -> id + cf -> cf.name + end + end + end + + defp toggle_required_if_matches(%{id: id} = field, id), + do: Map.put(field, :required, not field.required) + + defp toggle_required_if_matches(field, _field_id), do: field + + defp member_field_id_strings do + Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + end + + defp load_custom_fields(nil), do: [] + + defp load_custom_fields(actor) do + case Ash.read(Mv.Membership.CustomField, + actor: actor, + domain: Mv.Membership, + authorize?: true + ) do + {:ok, fields} -> fields + {:error, _} -> [] + end + end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 7015d9c..01c49f6 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -357,6 +357,7 @@ msgstr "Passwort-Authentifizierung" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2149,6 +2150,7 @@ msgstr "Mitglied ist nicht in dieser Gruppe." msgid "No email" msgstr "Keine E-Mail" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,68 @@ msgstr "Unvollständig" #, elixir-autogen, elixir-format, fuzzy msgid "These fields are necessary 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 +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "Feld hinzufügen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "Verfügbare Felder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "Konfiguriere das öffentliche Beitrittsformular, über das neue Mitglieder einen Beitrittsantrag stellen können." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "Feld" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "Felder im Beitrittsformular" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "Beitrittsformular" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "Beitrittsformular aktiv" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "Keine Felder ausgewählt. Füge mindestens das E-Mail-Feld hinzu." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Remove field %{label}" +msgstr "Feld %{label} entfernen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Board approval required (in development)" +msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Personal data" +msgstr "Persönliche Daten" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Individual fields" +msgstr "Individuelle Felder" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 14db165..b5ab449 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -358,6 +358,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2150,6 +2151,7 @@ msgstr "" msgid "No email" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,58 @@ msgstr "" #, elixir-autogen, elixir-format msgid "These fields are necessary 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 +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Remove field %{label}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Board approval required (in development)" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7dc5068..5556d10 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -358,6 +358,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format @@ -2150,6 +2151,7 @@ msgstr "" msgid "No email" msgstr "" +#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Remove" @@ -3324,3 +3326,68 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "These fields are necessary 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 +#, elixir-autogen, elixir-format +msgid "Add field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Available fields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure the public join form that allows new members to submit a join request." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Could not save join form settings." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Field" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Fields on the join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join Form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join form enabled" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "No fields selected. Add at least the email field." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Remove field %{label}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Board approval required (in development)" +msgstr "Board approval required (in development)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Personal data" +msgstr "Personal data" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Individual fields" +msgstr "Individual fields" diff --git a/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs b/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs new file mode 100644 index 0000000..225c05e --- /dev/null +++ b/priv/repo/migrations/20260310114701_add_join_form_settings_to_settings.exs @@ -0,0 +1,27 @@ +defmodule Mv.Repo.Migrations.AddJoinFormSettingsToSettings do + @moduledoc """ + Adds join form configuration columns to the settings table. + + - join_form_enabled: whether the public /join page is active + - join_form_field_ids: ordered list of field IDs shown on the join form (JSONB array) + - join_form_field_required: map of field ID => required boolean (JSONB) + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :join_form_enabled, :boolean, default: false, null: false + add :join_form_field_ids, {:array, :string} + add :join_form_field_required, :map + end + end + + def down do + alter table(:settings) do + remove :join_form_enabled + remove :join_form_field_ids + remove :join_form_field_required + end + end +end diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs index 9b15ca4..bcafe9f 100644 --- a/test/membership/setting_join_form_test.exs +++ b/test/membership/setting_join_form_test.exs @@ -26,8 +26,17 @@ defmodule Mv.Membership.SettingJoinFormTest do on_exit(fn -> {:ok, s} = Membership.get_settings() attrs = %{} - attrs = if saved_enabled != nil, do: Map.put(attrs, :join_form_enabled, saved_enabled), else: attrs - attrs = if saved_ids != nil, do: Map.put(attrs, :join_form_field_ids, saved_ids || []), else: attrs + + attrs = + if saved_enabled != nil, + do: Map.put(attrs, :join_form_enabled, saved_enabled), + else: attrs + + attrs = + if saved_ids != nil, + do: Map.put(attrs, :join_form_field_ids, saved_ids || []), + else: attrs + attrs = if saved_required != nil, do: Map.put(attrs, :join_form_field_required, saved_required || %{}), @@ -57,6 +66,7 @@ defmodule Mv.Membership.SettingJoinFormTest do describe "join form settings persistence and loading" do test "save and load join_form_enabled plus field selection and required flags returns same config" do {:ok, settings} = Membership.get_settings() + attrs = %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], @@ -78,17 +88,20 @@ defmodule Mv.Membership.SettingJoinFormTest do test "repeated save with changed field list overwrites config without leftovers" do {:ok, settings} = Membership.get_settings() - assert {:ok, _} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "first_name"], - join_form_field_required: %{"email" => true, "first_name" => false} - }) - assert {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "last_name"], - join_form_field_required: %{"email" => true, "last_name" => false} - }) + assert {:ok, _} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => true, "first_name" => false} + }) + + assert {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "last_name"], + join_form_field_required: %{"email" => true, "last_name" => false} + }) assert updated.join_form_field_ids == ["email", "last_name"] assert Map.has_key?(updated.join_form_field_required, "last_name") @@ -102,11 +115,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "only existing member fields or custom field ids are accepted; unknown field names rejected or sanitized" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "not_a_member_field"], - join_form_field_required: %{"email" => true, "not_a_member_field" => false} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "not_a_member_field"], + join_form_field_required: %{"email" => true, "not_a_member_field" => false} + }) # Until attributes exist we get NoSuchInput; once implemented we expect validation error assert {:error, _} = result @@ -115,16 +129,18 @@ defmodule Mv.Membership.SettingJoinFormTest do test "config without email is rejected or email is auto-added and required" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["first_name", "last_name"], - join_form_field_required: %{"first_name" => true, "last_name" => false} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["first_name", "last_name"], + join_form_field_required: %{"first_name" => true, "last_name" => false} + }) # Either rejected or, when loaded, email must be present and required case result do {:error, _} -> :ok + {:ok, updated} -> assert "email" in updated.join_form_field_ids assert updated.join_form_field_required["email"] == true @@ -134,11 +150,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "required false for email is ignored or forced to true when saved" do {:ok, settings} = Membership.get_settings() - {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email", "first_name"], - join_form_field_required: %{"email" => false, "first_name" => false} - }) + {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name"], + join_form_field_required: %{"email" => false, "first_name" => false} + }) assert updated.join_form_field_required["email"] == true end @@ -146,15 +163,17 @@ defmodule Mv.Membership.SettingJoinFormTest do test "required flag for field not in join_form_field_ids is rejected or dropped" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: ["email"], - join_form_field_required: %{"email" => true, "first_name" => true} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email"], + join_form_field_required: %{"email" => true, "first_name" => true} + }) case result do {:error, _} -> :ok + {:ok, updated} -> refute Map.has_key?(updated.join_form_field_required, "first_name") end @@ -166,6 +185,7 @@ defmodule Mv.Membership.SettingJoinFormTest do describe "join form allowlist" do test "allowlist returns configured fields with required/optional when join form enabled" do {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], @@ -175,8 +195,8 @@ defmodule Mv.Membership.SettingJoinFormTest do allowlist = Membership.get_join_form_allowlist() assert length(allowlist) == 2 - email_entry = Enum.find(allowlist, &( &1.id == "email" )) - first_name_entry = Enum.find(allowlist, &( &1.id == "first_name" )) + email_entry = Enum.find(allowlist, &(&1.id == "email")) + first_name_entry = Enum.find(allowlist, &(&1.id == "first_name")) assert email_entry.required == true assert first_name_entry.required == false assert email_entry.type == :member_field @@ -185,6 +205,7 @@ defmodule Mv.Membership.SettingJoinFormTest do test "allowlist returns empty or defined default when join form disabled" do {:ok, settings} = Membership.get_settings() + update_join_form_settings(settings, %{ join_form_enabled: false, join_form_field_ids: ["email", "first_name"], @@ -200,11 +221,13 @@ defmodule Mv.Membership.SettingJoinFormTest do test "allowlist distinguishes member fields and custom field identifiers" do {:ok, settings} = Membership.get_settings() actor = SystemActor.get_system_actor() + {:ok, cf} = Membership.create_custom_field( %{name: "join_cf_#{System.unique_integer([:positive])}", value_type: :string}, actor: actor ) + update_join_form_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", cf.id], @@ -213,8 +236,8 @@ defmodule Mv.Membership.SettingJoinFormTest do allowlist = Membership.get_join_form_allowlist() - email_entry = Enum.find(allowlist, &( &1.id == "email" )) - cf_entry = Enum.find(allowlist, &( &1.id == cf.id )) + email_entry = Enum.find(allowlist, &(&1.id == "email")) + cf_entry = Enum.find(allowlist, &(&1.id == cf.id)) assert email_entry.type == :member_field assert cf_entry.type == :custom_field end @@ -252,11 +275,12 @@ defmodule Mv.Membership.SettingJoinFormTest do test "invalid or unexpected payload structure yields clean error or ignores unknown keys" do {:ok, settings} = Membership.get_settings() - result = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: "not_a_list", - join_form_field_required: %{} - }) + result = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: "not_a_list", + join_form_field_required: %{} + }) assert match?({:error, _}, result) or (match?({:ok, _}, result) && elem(result, 1).join_form_field_ids != "not_a_list") @@ -267,11 +291,12 @@ defmodule Mv.Membership.SettingJoinFormTest do all_member = Constants.member_fields() |> Enum.map(&to_string/1) required_map = Map.new(all_member, fn f -> {f, f == "email"} end) - assert {:ok, updated} = update_join_form_settings(settings, %{ - join_form_enabled: true, - join_form_field_ids: all_member, - join_form_field_required: required_map - }) + assert {:ok, updated} = + update_join_form_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: all_member, + join_form_field_required: required_map + }) assert length(updated.join_form_field_ids) == length(all_member) {:ok, reloaded} = Membership.get_settings()