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, _} -> 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() |> Enum.map(fn {k, v} -> {k, String.trim(to_string(v))} end) |> Map.new() 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 defp client_ip_from_socket(socket) do case get_connect_info(socket, :peer_data) do %{address: address} when is_tuple(address) -> address |> Tuple.to_list() |> Enum.join(".") _ -> "unknown" end end end