add public join form #466

Merged
simon merged 3 commits from feature/308-web-form into main 2026-03-10 23:08:27 +01:00
26 changed files with 795 additions and 12 deletions

View file

@ -86,7 +86,7 @@ lib/
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest)
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)

View file

@ -99,6 +99,19 @@
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents }
/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */
.join-form-helper {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.join-form-helper .join-form-helper-input {
position: absolute;
left: -9999px;
}
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
spacing; use inherited values so custom stylesheets can override. */

View file

@ -93,6 +93,9 @@ config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
# user confirmation, password reset). Override in config/runtime.exs from ENV.
config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
# Configure esbuild (the version is required)
config :esbuild,
version: "0.17.11",

View file

@ -55,3 +55,6 @@ config :mv, :default_locale, "en"
# Enable SQL Sandbox for async LiveView tests
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
config :mv, :sql_sandbox, true
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2

View file

@ -42,7 +42,7 @@
### 2.3 Data Flow
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored.
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys.
- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email.
- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent).
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
@ -73,7 +73,7 @@
- **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200).
- **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plugs **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone.
- **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm.
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Verify library version and multi-node behaviour before or during implementation.
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation.
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention.
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.

View file

@ -36,7 +36,9 @@ This document lists all protected routes, which permission set may access them,
## Public Paths (no permission check)
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`**
The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`.
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).

View file

@ -37,6 +37,7 @@ defmodule Mv.Membership.JoinRequest do
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
read :get_by_confirmation_token_hash do
@ -77,6 +78,8 @@ defmodule Mv.Membership.JoinRequest do
end
validations do
# Format/formatting of email is not validated here; invalid addresses may fail at send time
# or can be enforced via an Ash change if needed.
validate present(:email), on: [:create]
end

View file

@ -0,0 +1,38 @@
defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
@moduledoc """
Filters form_data to only keys that are in the join form allowlist (server-side).
Ensures that even when submit_join_request/2 is called directly (e.g. from tests or API),
only allowlisted custom fields are persisted. Typed fields (email, first_name, last_name)
are not part of form_data; allowlist is join_form_field_ids minus those.
"""
use Ash.Resource.Change
alias Mv.Membership
@typed_fields ["email", "first_name", "last_name"]
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
allowlist_ids =
case Membership.get_join_form_allowlist() do
list when is_list(list) ->
list
|> Enum.map(fn item -> item.id end)
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
_ ->
MapSet.new()
end
filtered =
form_data
|> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end)
|> Map.new()
Ash.Changeset.force_change_attribute(changeset, :form_data, filtered)
end
end

View file

@ -9,6 +9,7 @@ defmodule Mv.Application do
alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint
alias MvWeb.JoinRateLimit
alias MvWeb.Telemetry
@impl true
@ -18,6 +19,7 @@ defmodule Mv.Application do
children = [
Telemetry,
Repo,
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},

View file

@ -82,7 +82,25 @@ defmodule MvWeb.Layouts do
</div>
</div>
<% else %>
<!-- Not logged in -->
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}

View file

@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
websocket: [connect_info: [:peer_data, :x_headers, session: @session_options]],
longpoll: [connect_info: [:peer_data, :x_headers, session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#

View file

@ -0,0 +1,28 @@
defmodule MvWeb.JoinRateLimit do
@moduledoc """
Rate limiting for the public join form (submit action).
Uses Hammer with ETS backend. Key is derived from client IP so each IP
is limited independently. Config from :mv :join_rate_limit (scale_ms, limit).
"""
use Hammer, backend: :ets
@doc """
Checks if the given key (e.g. client IP) is within rate limit for join form submit.
Returns:
- `:allow` - submission allowed
- `{:deny, _retry_after_ms}` - rate limit exceeded
"""
def check(key) when is_binary(key) do
# Read at runtime so config can be changed without restart (e.g. in tests).
config = Application.get_env(:mv, :join_rate_limit, [])
scale_ms = Keyword.get(config, :scale_ms, 60_000)
limit = Keyword.get(config, :limit, 10)
case hit(key, scale_ms, limit) do
{:allow, _count} -> :allow
{:deny, retry_after} -> {:deny, retry_after}
end
end
end

View file

@ -0,0 +1,261 @@
defmodule MvWeb.JoinLive do
@moduledoc """
Public join page (unauthenticated). Renders form from allowlist, handles submit
with honeypot and rate limiting; shows success copy after submit.
"""
use MvWeb, :live_view
alias Mv.Membership
alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields
# Honeypot field name (legitimate-sounding to avoid bot detection)
@honeypot_field "website"
@impl true
def mount(_params, _session, socket) do
allowlist = Membership.get_join_form_allowlist()
join_fields = build_join_fields_with_labels(allowlist)
client_ip = client_ip_from_socket(socket)
socket =
socket
|> assign(:join_fields, join_fields)
|> assign(:submitted, false)
|> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field)
|> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="max-w-xl mx-auto mt-8 space-y-6">
<.header>
{gettext("Become a member")}
</.header>
<p class="text-base-content/80">
{gettext("Please enter your details for the membership application here.")}
</p>
<%= if @submitted do %>
<div data-testid="join-success-message" class="alert alert-success">
<p class="font-medium">
{gettext(
"We have saved your details. To complete your request, please click the link we sent to your email."
)}
</p>
</div>
<% else %>
<.form
for={@form}
id="join-form"
phx-submit="submit"
class="space-y-4"
>
<%= if @rate_limit_error do %>
<div class="alert alert-error">
{@rate_limit_error}
</div>
<% end %>
<%= for field <- @join_fields do %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
</label>
<input
type={input_type(field.id)}
name={field.id}
id={"join-field-#{field.id}"}
value={@form.params[field.id]}
required={field.required}
class="input input-bordered w-full"
/>
</div>
<% end %>
<%!--
Honeypot (best practice): legit field name "website", type="text", no inline CSS,
hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off,
aria-hidden so screen readers skip. If filled silent failure (same success UI).
--%>
<div class="join-form-helper" aria-hidden="true">
<label for="join-website" class="sr-only">{gettext("Website")}</label>
<input
type="text"
name={@honeypot_field}
id="join-website"
tabindex="-1"
autocomplete="off"
class="join-form-helper-input"
/>
</div>
<p class="text-sm text-base-content/70">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}
</p>
<div>
<button type="submit" class="btn btn-primary">
{gettext("Submit request")}
</button>
</div>
</.form>
<% end %>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("submit", params, socket) do
# Honeypot: if filled, treat as bot show same success UI, do not create (silent failure)
honeypot_value = String.trim(params[@honeypot_field] || "")
if honeypot_value != "" do
{:noreply, assign(socket, :submitted, true)}
else
key = "join:#{socket.assigns.client_ip}"
case JoinRateLimit.check(key) do
:allow -> do_submit(socket, params)
{:deny, _retry_after} -> rate_limited_reply(socket, params)
end
end
end
defp do_submit(socket, params) do
case build_submit_attrs(params, socket.assigns.join_fields) do
{:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} -> {:noreply, assign(socket, :submitted, true)}
{:error, _} -> validation_error_reply(socket, params)
end
{:error, message} ->
{:noreply,
socket
|> put_flash(:error, message)
|> assign(:form, to_form(params, as: "join"))}
end
end
defp validation_error_reply(socket, params) do
{:noreply,
socket
|> put_flash(:error, gettext("Please check your entries. Email is required."))
|> assign(:form, to_form(params, as: "join"))}
end
defp rate_limited_reply(socket, params) do
{:noreply,
socket
|> assign(:rate_limit_error, gettext("Too many requests. Please try again later."))
|> assign(:form, to_form(params, as: "join"))}
end
defp build_join_fields_with_labels(allowlist) do
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
Enum.map(allowlist, fn %{id: id, required: required} ->
label =
if id in member_field_strings do
MemberFields.label(String.to_existing_atom(id))
else
gettext("Field")
end
%{id: id, label: label, required: required}
end)
end
defp initial_form_params(join_fields) do
join_fields
|> Enum.map(fn f -> {f.id, ""} end)
|> Map.new()
|> Map.put(@honeypot_field, "")
end
defp input_type("email"), do: "email"
defp input_type(_), do: "text"
defp build_submit_attrs(params, join_fields) do
allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id))
typed = ["email", "first_name", "last_name"]
email = String.trim(params["email"] || "")
if email == "" do
{:error, gettext("Email is required.")}
else
attrs = %{
email: email,
first_name: optional_param(params, "first_name"),
last_name: optional_param(params, "last_name"),
form_data: %{},
schema_version: 1
}
form_data =
params
|> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end)
|> Map.new(fn {k, v} -> {k, String.trim(to_string(v))} end)
attrs = %{attrs | form_data: form_data}
{:ok, attrs}
end
end
defp optional_param(params, key) do
v = params[key]
if is_binary(v), do: String.trim(v), else: nil
end
# Prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy; fall back to peer_data.
# Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation.
defp client_ip_from_socket(socket) do
with nil <- client_ip_from_headers(socket),
%{address: address} when is_tuple(address) <- get_connect_info(socket, :peer_data) do
address |> :inet.ntoa() |> to_string()
else
ip when is_binary(ip) -> ip
_ -> "unknown"
end
end
defp client_ip_from_headers(socket) do
headers = get_connect_info(socket, :x_headers) || []
real_ip = header_value(headers, "x-real-ip")
forwarded = header_value(headers, "x-forwarded-for")
cond do
real_ip != nil -> real_ip
forwarded != nil -> String.split(forwarded, ~r/,\s*/) |> List.first() |> String.trim()
true -> nil
end
end
defp header_value(headers, name) do
name_lower = String.downcase(name)
headers
|> Enum.find_value(fn
{h, v} when is_binary(h) -> if String.downcase(h) == name_lower, do: String.trim(v)
_ -> nil
end)
end
end

View file

@ -107,6 +107,9 @@ defmodule MvWeb.MemberLive.Index do
{:error, _} -> %{member_field_visibility: %{}}
end
# Ensure nested module is loaded (can be missing after code reload in dev if load order changes)
Code.ensure_loaded!(FieldSelection)
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)

View file

@ -80,7 +80,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
Used by LiveView hook to skip redirect on sign-in etc.
"""
def public_path?(path) when is_binary(path) do
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out", "/join"] or
String.starts_with?(path, "/auth") or
String.starts_with?(path, "/confirm") or
String.starts_with?(path, "/password-reset")

View file

@ -0,0 +1,34 @@
defmodule MvWeb.Plugs.JoinFormEnabled do
@moduledoc """
For GET /join: returns 404 when the join form is disabled in settings.
No-op for other paths.
"""
import Plug.Conn
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
if join_path?(conn), do: maybe_404(conn), else: conn
end
defp join_path?(conn) do
conn.request_path == "/join" and conn.method == "GET"
end
defp maybe_404(conn) do
case Membership.get_settings() do
{:ok, %{join_form_enabled: true}} -> conn
_ -> send_404(conn)
end
end
# Same body as default ErrorHTML 404 (no custom error templates in this app).
defp send_404(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(404, "Not Found")
|> halt()
end
end

View file

@ -15,6 +15,7 @@ defmodule MvWeb.Router do
plug :load_from_session
plug :set_locale
plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
end
pipeline :api do
@ -126,6 +127,12 @@ defmodule MvWeb.Router do
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Public join page (no auth required)
live_session :public_join,
on_mount: [{MvWeb.LiveUserAuth, :live_user_optional}] do
live "/join", JoinLive, :index
end
# Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission
get "/confirm_join/:token", JoinConfirmController, :confirm

View file

@ -82,7 +82,8 @@ defmodule Mv.MixProject do
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"},
{:imprintor, "~> 0.5.0"}
{:imprintor, "~> 0.5.0"},
{:hammer, "~> 7.0"}
]
end

View file

@ -37,6 +37,7 @@
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

View file

@ -513,6 +513,7 @@ msgstr "Zurück zur Mitgliederliste"
msgid "Back to users list"
msgstr "Zurück zur Benutzer*innen-Liste"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Select language"
@ -1792,6 +1793,7 @@ msgid "Email is invalid."
msgstr "E-Mail ist ungültig."
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr "E-Mail ist erforderlich."
@ -3348,6 +3350,7 @@ msgid "Could not save join form settings."
msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr "Feld"
@ -3401,3 +3404,48 @@ msgstr "Umordnen"
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr "Mitglied werden"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr "Bitte prüfe deine Angaben. E-Mail ist erforderlich."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr "Antrag absenden"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr "Zu viele Anfragen. Bitte versuche es später erneut."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website"
msgstr "Webseite"

View file

@ -514,6 +514,7 @@ msgstr ""
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Select language"
@ -1793,6 +1794,7 @@ msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
@ -3348,6 +3350,7 @@ msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
@ -3401,3 +3404,48 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website"
msgstr ""

View file

@ -514,6 +514,7 @@ msgstr ""
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
@ -1793,6 +1794,7 @@ msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
@ -3348,6 +3350,7 @@ msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
@ -3401,3 +3404,48 @@ msgstr "Reorder"
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "The order of rows determines the field order in the join form."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website"
msgstr ""

View file

@ -38,6 +38,21 @@ defmodule Mv.Membership.JoinRequestTest do
end
test "persists first_name, last_name and form_data when provided" do
# Allowlist must include custom fields so FilterFormDataByAllowlist persists them
{:ok, settings} = Membership.get_settings()
Mv.Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name", "last_name", "city", "notes"],
join_form_field_required: %{
"email" => true,
"first_name" => false,
"last_name" => false,
"city" => false,
"notes" => false
}
})
attrs =
@valid_submit_attrs
|> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}")
@ -121,6 +136,33 @@ defmodule Mv.Membership.JoinRequestTest do
end
end
describe "allowlist (server-side field filter)" do
test "submit with non-allowlisted form_data keys does not persist those keys" do
# Allowlist restricts which fields are accepted; extra keys must not be stored.
{:ok, settings} = Membership.get_settings()
Mv.Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name", "city"],
join_form_field_required: %{"email" => true, "first_name" => false, "city" => false}
})
attrs = %{
email: "allowlist#{System.unique_integer([:positive])}@example.com",
first_name: "Allowed",
confirmation_token: "tok-#{System.unique_integer([:positive])}",
form_data: %{"city" => "Berlin", "internal_or_secret" => "must not persist"},
schema_version: 1
}
assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil)
assert request.email == attrs.email
assert request.first_name == attrs.first_name
refute Map.has_key?(request.form_data || %{}, "internal_or_secret")
assert (request.form_data || %{})["city"] == "Berlin"
end
end
defp error_message(errors, field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)

View file

@ -60,13 +60,18 @@ defmodule MvWeb.JoinConfirmControllerTest do
end
@tag role: :unauthenticated
test "expired token returns 200 with expired message", %{conn: conn} do
test "expired token returns 200 with expired message and instructs to submit form again", %{
conn: conn
} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub)
conn = get(conn, "/confirm_join/expired-token")
body = response(conn, 200)
assert response(conn, 200) =~ "expired"
assert response(conn, 200) =~ "submit"
assert body =~ "expired"
assert body =~ "submit"
# Concept §2.5: clear message + "submit form again"
assert body =~ "form" or body =~ "again"
end
@tag role: :unauthenticated

View file

@ -0,0 +1,169 @@
defmodule MvWeb.JoinLiveTest do
@moduledoc """
Tests for the public join page (Subtask 4: Public join page and anti-abuse).
Covers: public path /join (unauthenticated 200), 404 when join disabled,
submit creates JoinRequest and shows success copy, honeypot prevents create,
rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member.
Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot").
Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text".
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Ecto.Query
alias Mv.Membership
alias Mv.Repo
describe "GET /join" do
@tag role: :unauthenticated
test "unauthenticated GET /join returns 200 when join form is enabled", %{conn: conn} do
enable_join_form(true)
conn = get(conn, "/join")
assert conn.status == 200
end
@tag role: :unauthenticated
test "unauthenticated GET /join returns 404 when join form is disabled", %{conn: conn} do
enable_join_form(false)
conn = get(conn, "/join")
assert conn.status == 404
end
end
describe "submit join form" do
setup :enable_join_form_for_test
@tag role: :unauthenticated
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
conn: conn
} do
count_before = count_join_requests()
{:ok, view, _html} = live(conn, "/join")
view
|> form("#join-form", %{
"email" => "newuser#{System.unique_integer([:positive])}@example.com",
"first_name" => "Jane",
"last_name" => "Doe",
"website" => ""
})
|> render_submit()
assert count_join_requests() == count_before + 1
assert view |> element("[data-testid='join-success-message']") |> has_element?()
assert render(view) =~ "saved your details"
assert render(view) =~ "click the link"
end
@tag role: :unauthenticated
test "submit with honeypot filled does not create JoinRequest but shows same success copy", %{
conn: conn
} do
count_before = count_join_requests()
{:ok, view, _html} = live(conn, "/join")
view
|> form("#join-form", %{
"email" => "bot#{System.unique_integer([:positive])}@example.com",
"first_name" => "Bot",
"last_name" => "User",
"website" => "filled-by-bot"
})
|> render_submit()
assert count_join_requests() == count_before
assert view |> element("[data-testid='join-success-message']") |> has_element?()
end
@tag role: :unauthenticated
@tag :slow
test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{
conn: conn
} do
# Reset rate limit state so this test is independent of others (same key in test)
try do
:ets.delete_all_objects(MvWeb.JoinRateLimit)
rescue
ArgumentError -> :ok
end
enable_join_form(true)
# Set allowlist so form has email, first_name, last_name
{:ok, settings} = Membership.get_settings()
Membership.update_settings(settings, %{
join_form_field_ids: ["email", "first_name", "last_name"],
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
})
# Rely on test config: join rate limit low (e.g. 2 per window)
base_email = "ratelimit#{System.unique_integer([:positive])}@example.com"
count_before = count_join_requests()
sandbox = conn.private[:ecto_sandbox]
# Exhaust limit with 2 valid submits (each needs a fresh session because form disappears after submit)
for i <- 0..1 do
c =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_private(:ecto_sandbox, sandbox)
{:ok, view, _} = live(c, "/join")
view
|> form("#join-form", %{
"email" => "#{i}-#{base_email}",
"first_name" => "User",
"last_name" => "Test",
"website" => ""
})
|> render_submit()
end
# Next submit (new session) should be rate limited
c =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_private(:ecto_sandbox, sandbox)
{:ok, view, _} = live(c, "/join")
result =
view
|> form("#join-form", %{
"email" => "third-#{base_email}",
"first_name" => "Third",
"last_name" => "User",
"website" => ""
})
|> render_submit()
assert count_join_requests() == count_before + 2
assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429"
end
end
defp enable_join_form(enabled) do
{:ok, settings} = Membership.get_settings()
{:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled})
end
defp enable_join_form_for_test(_context) do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name", "last_name"],
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
})
:ok
end
defp count_join_requests do
Repo.one(from j in "join_requests", select: count(j.id)) || 0
end
end

View file

@ -204,6 +204,12 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
refute conn.halted
end
test "unauthenticated user can access /join (public join page, no redirect)" do
conn = conn_without_user("/join") |> CheckPagePermission.call([])
refute conn.halted
end
end
describe "error handling" do