feat: prevent join requests with equal mail
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-13 11:18:34 +01:00
parent 40a4461d23
commit 086ecdcb1b
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
22 changed files with 534 additions and 11 deletions

View file

@ -196,7 +196,7 @@ Implementation spec for Subtask 5.
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). - **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). - **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. - **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**.
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.

View file

@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.RejectRequest change Mv.Membership.JoinRequest.Changes.RejectRequest
end end
# Internal: resend confirmation (new token) when user submits form again with same email.
# Called from domain with authorize?: false; not exposed to public.
update :regenerate_confirmation_token do
description "Set new confirmation token and expiry (resend flow)"
require_atomic? false
argument :confirmation_token, :string, allow_nil?: false
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
end
end end
policies do policies do
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
attribute :approved_at, :utc_datetime_usec attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid attribute :reviewed_by_user_id, :uuid
attribute :reviewed_by_display, :string do
description "Denormalized reviewer display (e.g. email) for UI without loading User"
end
attribute :source, :string attribute :source, :string
create_timestamp :inserted_at create_timestamp :inserted_at

View file

@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
if current_status == :submitted do if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor) reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset changeset
|> Ash.Changeset.force_change_attribute(:status, :approved) |> Ash.Changeset.force_change_attribute(:status, :approved)
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else else
Ash.Changeset.add_error(changeset, Ash.Changeset.add_error(changeset,
field: :status, field: :status,

View file

@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
end end
def actor_id(_), do: nil def actor_id(_), do: nil
@doc """
Extracts the actor's email for display (e.g. reviewed_by_display).
Supports both atom and string keys for compatibility with different actor representations.
"""
@spec actor_email(term()) :: String.t() | nil
def actor_email(nil), do: nil
def actor_email(actor) when is_map(actor) do
raw = Map.get(actor, :email) || Map.get(actor, "email")
if is_nil(raw), do: nil, else: actor_email_string(raw)
end
def actor_email(_), do: nil
defp actor_email_string(raw) do
s = raw |> to_string() |> String.trim()
if s == "", do: nil, else: s
end
end end

View file

@ -0,0 +1,30 @@
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
@moduledoc """
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
Used when the user submits the join form again with the same email while a request
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
hash = JoinRequest.hash_confirmation_token(token)
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, DateTime.utc_now())
else
changeset
end
end
end

View file

@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
if current_status == :submitted do if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor) reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset changeset
|> Ash.Changeset.force_change_attribute(:status, :rejected) |> Ash.Changeset.force_change_attribute(:status, :rejected)
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else else
Ash.Changeset.add_error(changeset, Ash.Changeset.add_error(changeset,
field: :status, field: :status,

View file

@ -29,7 +29,11 @@ defmodule Mv.Membership do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
alias Mv.Membership.Member
alias MvWeb.Emails.JoinAlreadyMemberEmail
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail alias MvWeb.Emails.JoinConfirmationEmail
require Logger require Logger
@ -365,15 +369,130 @@ defmodule Mv.Membership do
## Returns ## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent - `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged) - `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
- `{:error, error}` - Validation or authorization error - `{:error, error}` - Validation or authorization error
""" """
def submit_join_request(attrs, opts \\ []) do def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor) actor = Keyword.get(opts, :actor)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() email = normalize_submit_email(attrs)
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken pending =
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link. if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
cond do
email != nil and email != "" and member_exists_with_email?(email) ->
send_already_member_and_return(email)
pending != nil ->
handle_already_pending(email, pending)
true ->
do_create_join_request(attrs, actor)
end
end
defp normalize_submit_email(attrs) do
raw = attrs["email"] || attrs[:email]
if is_binary(raw), do: String.trim(raw), else: nil
end
defp member_exists_with_email?(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
opts = [actor: system_actor, domain: __MODULE__]
case Ash.get(Member, %{email: email}, opts) do
{:ok, _member} -> true
_ -> false
end
end
defp member_exists_with_email?(_), do: false
defp pending_join_request_with_email(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
query =
JoinRequest
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(1)
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
{:ok, request} -> request
_ -> nil
end
end
defp pending_join_request_with_email(_), do: nil
defp apply_anti_enumeration_delay do
Process.sleep(100 + :rand.uniform(200))
end
defp send_already_member_and_return(email) do
case JoinAlreadyMemberEmail.send(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_member}
end
defp handle_already_pending(email, existing) do
if existing.status == :pending_confirmation do
resend_confirmation_to_pending(email, existing)
else
send_already_pending_and_return(email)
end
end
defp resend_confirmation_to_pending(email, request) do
new_token = generate_confirmation_token()
case request
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
confirmation_token: new_token
})
|> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} ->
case JoinConfirmationEmail.send(email, new_token, resend: true) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_pending}
{:error, _} ->
# Fallback: do not create duplicate; send generic pending email
send_already_pending_and_return(email)
end
end
defp send_already_pending_and_return(email) do
case JoinAlreadyPendingEmail.send(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_pending}
end
defp do_create_join_request(attrs, actor) do
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
attrs_with_token = Map.put(attrs, :confirmation_token, token) attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token, case Ash.create(JoinRequest, attrs_with_token,
@ -384,6 +503,7 @@ defmodule Mv.Membership do
{:ok, request} -> {:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do case JoinConfirmationEmail.send(request.email, token) do
{:ok, _email} -> {:ok, _email} ->
apply_anti_enumeration_delay()
{:ok, request} {:ok, request}
{:error, reason} -> {:error, reason} ->

View file

@ -0,0 +1,42 @@
defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that is already a member.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout.
"""
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer
@doc """
Sends the "already a member" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already a member")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_member.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that already
has a submitted (confirmed) application under review.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout.
"""
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer
@doc """
Sends the "application already under review" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already under review")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_pending.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -18,10 +18,16 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
Uses the same SMTP configuration as the test mail (Settings or boot ENV) via Uses the same SMTP configuration as the test mail (Settings or boot ENV) via
`Mailer.deliver/2` with `Mailer.smtp_config/0` for consistency. `Mailer.deliver/2` with `Mailer.smtp_config/0` for consistency.
Called from the domain after a JoinRequest is created (submit flow). Called from the domain after a JoinRequest is created (submit flow) or when
resending to an existing pending request.
## Options
- `:resend` - If true, adds a short note that the link is being sent again for an existing request.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure. Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
""" """
def send(email_address, token) when is_binary(email_address) and is_binary(token) do def send(email_address, token, opts \\ [])
when is_binary(email_address) and is_binary(token) do
confirm_url = url(~p"/confirm_join/#{token}") confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request") subject = gettext("Confirm your membership request")
@ -29,7 +35,8 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
confirm_url: confirm_url, confirm_url: confirm_url,
subject: subject, subject: subject,
app_name: Mailer.mail_from() |> elem(0), app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext) locale: Gettext.get_locale(MvWeb.Gettext),
resend: Keyword.get(opts, :resend, false)
} }
email = email =

View file

@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do
@doc """ @doc """
Returns the reviewer display string (e.g. email) for a join request, or nil if none. Returns the reviewer display string (e.g. email) for a join request, or nil if none.
Accepts a join request struct or map with optional :reviewed_by_user (loaded User struct). Prefers the denormalized :reviewed_by_display (set on approve/reject) so the UI
works for all roles without loading the User resource. Falls back to
:reviewed_by_user when loaded (e.g. admin or legacy data before backfill).
""" """
def reviewer_display(req) when is_map(req) do def reviewer_display(req) when is_map(req) do
case Map.get(req, :reviewed_by_display) do
s when is_binary(s) ->
trimmed = String.trim(s)
if trimmed == "", do: reviewer_display_from_user(req), else: trimmed
_ ->
reviewer_display_from_user(req)
end
end
def reviewer_display(_), do: nil
defp reviewer_display_from_user(req) do
user = Map.get(req, :reviewed_by_user) user = Map.get(req, :reviewed_by_user)
case user do case user do
@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do
nil nil
end end
end end
def reviewer_display(_), do: nil
end end

View file

@ -264,11 +264,16 @@ defmodule MvWeb.JoinRequestLive.Show do
defp format_applicant_value(nil), do: nil defp format_applicant_value(nil), do: nil
defp format_applicant_value(""), do: nil defp format_applicant_value(""), do: nil
defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date) defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date)
defp format_applicant_value(value) when is_map(value), do: format_applicant_value_from_map(value)
defp format_applicant_value(value) when is_map(value),
do: format_applicant_value_from_map(value)
defp format_applicant_value(value) when is_boolean(value), defp format_applicant_value(value) when is_boolean(value),
do: if(value, do: gettext("Yes"), else: gettext("No")) do: if(value, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value(value) when is_binary(value) or is_number(value), defp format_applicant_value(value) when is_binary(value) or is_number(value),
do: to_string(value) do: to_string(value)
defp format_applicant_value(value), do: to_string(value) defp format_applicant_value(value), do: to_string(value)
defp format_applicant_value_from_map(value) do defp format_applicant_value_from_map(value) do
@ -283,8 +288,10 @@ defmodule MvWeb.JoinRequestLive.Show do
end end
defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw
defp format_applicant_value_simple(raw, _value) when is_boolean(raw), defp format_applicant_value_simple(raw, _value) when is_boolean(raw),
do: if(raw, do: gettext("Yes"), else: gettext("No")) do: if(raw, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw) defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw)
defp format_applicant_value_simple(_raw, value), do: to_string(value) defp format_applicant_value_simple(_raw, value), do: to_string(value)

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. The email address you entered is already registered as a member."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. You already have a membership application that is being reviewed."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -1,4 +1,9 @@
<div style="color: #111827;"> <div style="color: #111827;">
<%= if @resend do %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext("You already had a pending request. Here is a new confirmation link.")}
</p>
<% end %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;"> <p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext( {gettext(
"We have received your membership request. To complete it, please click the link below." "We have received your membership request. To complete it, please click the link below."

View file

@ -3800,3 +3800,34 @@ msgstr "Status und Prüfung"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support." msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support." msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support."
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr "Bei Fragen kannst du dich gerne an uns wenden."
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr "Mitgliedsantrag bereits Mitglied"
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr "Mitgliedsantrag wird bereits geprüft"
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr "Wir haben deine Anfrage erhalten. Die angegebene E-Mail-Adresse ist bereits als Mitglied registriert."
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag, der geprüft wird."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink."

View file

@ -3800,3 +3800,34 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support." msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr "" msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr ""
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr ""
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr ""
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr ""
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr ""

View file

@ -3800,3 +3800,34 @@ msgstr "Status and review"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support." msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr "" msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr "If you have any questions, please contact us."
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr "Membership application already a member"
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr "Membership application already under review"
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr "We have received your request. The email address you entered is already registered as a member."
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr "We have received your request. You already have a membership application that is being reviewed."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "You already had a pending request. Here is a new confirmation link."

View file

@ -0,0 +1,30 @@
defmodule Mv.Repo.Migrations.AddReviewedByDisplayToJoinRequests do
@moduledoc """
Adds reviewed_by_display to join_requests for showing reviewer in UI without loading User.
Backfills existing rows from users.email where reviewed_by_user_id is set.
"""
use Ecto.Migration
def up do
alter table(:join_requests) do
add :reviewed_by_display, :text
end
# Backfill from users.email for rows that have reviewed_by_user_id
execute """
UPDATE join_requests j
SET reviewed_by_display = u.email
FROM users u
WHERE j.reviewed_by_user_id = u.id
AND j.reviewed_by_user_id IS NOT NULL
"""
end
def down do
alter table(:join_requests) do
remove :reviewed_by_display
end
end
end

View file

@ -67,6 +67,18 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
end end
end end
describe "reviewed_by_display" do
test "get_join_request returns reviewed_by_display so UI can show reviewer without loading User" do
request = Fixtures.submitted_join_request_fixture()
reviewer = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: reviewer)
assert {:ok, loaded} = Membership.get_join_request(request.id, actor: reviewer)
assert loaded.reviewed_by_display == to_string(reviewer.email)
end
end
describe "reject_join_request/2" do describe "reject_join_request/2" do
test "reject does not create a member" do test "reject does not create a member" do
request = Fixtures.submitted_join_request_fixture() request = Fixtures.submitted_join_request_fixture()

View file

@ -49,6 +49,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert approved.status == :approved assert approved.status == :approved
assert approved.approved_at != nil assert approved.approved_at != nil
assert approved.reviewed_by_user_id == user.id assert approved.reviewed_by_user_id == user.id
assert approved.reviewed_by_display == to_string(user.email)
end end
test "admin can approve a submitted join request", %{request: request} do test "admin can approve a submitted join request", %{request: request} do
@ -89,6 +90,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert rejected.status == :rejected assert rejected.status == :rejected
assert rejected.rejected_at != nil assert rejected.rejected_at != nil
assert rejected.reviewed_by_user_id == user.id assert rejected.reviewed_by_user_id == user.id
assert rejected.reviewed_by_display == to_string(user.email)
end end
test "admin can reject a submitted join request", %{request: request} do test "admin can reject a submitted join request", %{request: request} do

View file

@ -12,7 +12,12 @@ defmodule Mv.Membership.JoinRequestTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr
alias Mv.Fixtures
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.JoinRequest
# Valid minimal attributes for submit (email required; confirmation_token optional for tests) # Valid minimal attributes for submit (email required; confirmation_token optional for tests)
@valid_submit_attrs %{ @valid_submit_attrs %{
@ -136,6 +141,60 @@ defmodule Mv.Membership.JoinRequestTest do
end end
end end
describe "submit_join_request/2 anti-enumeration (already member / already pending)" do
test "returns {:ok, :notified_already_member} and creates no JoinRequest when email is already a member" do
member =
Fixtures.member_fixture(%{
email: "already_member#{System.unique_integer([:positive])}@example.com"
})
attrs = %{
email: member.email,
confirmation_token: "token-#{System.unique_integer([:positive])}"
}
assert {:ok, :notified_already_member} = Membership.submit_join_request(attrs, actor: nil)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, requests} =
JoinRequest
|> Ash.Query.filter(expr(email == ^member.email))
|> Ash.read(actor: system_actor, domain: Mv.Membership)
assert requests == []
end
test "returns {:ok, :notified_already_pending} and does not create duplicate when same email submits again (resend)" do
email = "resend#{System.unique_integer([:positive])}@example.com"
token1 = "first-token-#{System.unique_integer([:positive])}"
attrs1 = %{email: email, confirmation_token: token1}
assert {:ok, request1} = Membership.submit_join_request(attrs1, actor: nil)
assert request1.status == :pending_confirmation
attrs2 = %{
email: email,
confirmation_token: "second-token-#{System.unique_integer([:positive])}"
}
assert {:ok, :notified_already_pending} = Membership.submit_join_request(attrs2, actor: nil)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, requests} =
JoinRequest
|> Ash.Query.filter(expr(email == ^email))
|> Ash.read(actor: system_actor, domain: Mv.Membership)
assert length(requests) == 1
assert hd(requests).id == request1.id
# Resend path updates the request (new token stored); confirmation_sent_at will have been set/updated
assert hd(requests).confirmation_sent_at != nil
end
end
describe "allowlist (server-side field filter)" do describe "allowlist (server-side field filter)" do
test "submit with non-allowlisted form_data keys does not persist those keys" 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. # Allowlist restricts which fields are accepted; extra keys must not be stored.