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"""
<.header> {gettext("Become a member")}

{gettext("Please enter your details for the membership application here.")}

<%= if @submitted do %>

{gettext( "We have saved your details. To complete your request, please click the link we sent to your email." )}

<% else %> <.form for={@form} id="join-form" phx-submit="submit" class="space-y-4" > <%= if @rate_limit_error do %>
{@rate_limit_error}
<% end %> <%= for field <- @join_fields do %>
<% 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). --%>

{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." )}

{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." )}

<% end %>
""" 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, :email_delivery_failed} -> {:noreply, socket |> put_flash( :error, gettext( "We could not send the confirmation email. Please try again later or contact support." ) ) |> assign(:form, to_form(params, as: "join"))} {: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