feat: add join form settings
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-03-10 14:29:49 +01:00
parent b7a83d9298
commit fa738aae88
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
12 changed files with 846 additions and 54 deletions

View file

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

View file

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

View file

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