mitgliederverwaltung/lib/mv_web/live/join_live.ex
Simon 349cee0ce6
Some checks failed
continuous-integration/drone/push Build is failing
refactor: review remarks
2026-03-13 17:55:17 +01:00

306 lines
9.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
# Anti-enumeration: delay before showing success (ms). Applied in LiveView so the process is not blocked.
@anti_enumeration_delay_ms_min 100
@anti_enumeration_delay_ms_rand 200
@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)
club_name =
case Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
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(:club_name, club_name)
|> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.public_page flash={@flash} club_name={@club_name}>
<div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-xl w-full 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/85">
{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/80">
{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>
</div>
</div>
</div>
</Layouts.public_page>
"""
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, _} ->
delay_ms =
@anti_enumeration_delay_ms_min + :rand.uniform(@anti_enumeration_delay_ms_rand)
Process.send_after(self(), :show_join_success, delay_ms)
{:noreply, socket}
{: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
@impl true
def handle_info(:show_join_success, socket) do
{:noreply, assign(socket, :submitted, true)}
end
# Swoosh (e.g. in test) may send {:email, email} to the LiveView process; ignore.
def handle_info(_msg, socket) do
{:noreply, socket}
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