From 086ecdcb1bc27dc5b8a5f1f0d729a57a7863826f Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Mar 2026 11:18:34 +0100 Subject: [PATCH] feat: prevent join requests with equal mail --- docs/onboarding-join-concept.md | 2 +- lib/membership/join_request.ex | 16 +++ .../join_request/changes/approve_request.ex | 2 + .../join_request/changes/helpers.ex | 20 +++ .../changes/regenerate_confirmation_token.ex | 30 +++++ .../join_request/changes/reject_request.ex | 2 + lib/membership/membership.ex | 126 +++++++++++++++++- .../emails/join_already_member_email.ex | 42 ++++++ .../emails/join_already_pending_email.ex | 43 ++++++ lib/mv_web/emails/join_confirmation_email.ex | 13 +- lib/mv_web/live/join_request_live/helpers.ex | 19 ++- lib/mv_web/live/join_request_live/show.ex | 9 +- .../emails/join_already_member.html.heex | 10 ++ .../emails/join_already_pending.html.heex | 10 ++ .../emails/join_confirmation.html.heex | 5 + priv/gettext/de/LC_MESSAGES/default.po | 31 +++++ priv/gettext/default.pot | 31 +++++ priv/gettext/en/LC_MESSAGES/default.po | 31 +++++ ...d_reviewed_by_display_to_join_requests.exs | 30 +++++ .../join_request_approval_domain_test.exs | 12 ++ .../join_request_approval_policy_test.exs | 2 + test/membership/join_request_test.exs | 59 ++++++++ 22 files changed, 534 insertions(+), 11 deletions(-) create mode 100644 lib/membership/join_request/changes/regenerate_confirmation_token.ex create mode 100644 lib/mv_web/emails/join_already_member_email.ex create mode 100644 lib/mv_web/emails/join_already_pending_email.ex create mode 100644 lib/mv_web/templates/emails/join_already_member.html.heex create mode 100644 lib/mv_web/templates/emails/join_already_pending.html.heex create mode 100644 priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 487256e..8e6c615 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -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). - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `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**. - **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**. diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 05a9e8d..94907e2 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.RejectRequest 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 policies do @@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do attribute :approved_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec 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 create_timestamp :inserted_at diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index 24716f6..b86ca5d 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) |> 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_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex index ee09b75..9bb0697 100644 --- a/lib/membership/join_request/changes/helpers.ex +++ b/lib/membership/join_request/changes/helpers.ex @@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do end 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 diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex new file mode 100644 index 0000000..a3206a2 --- /dev/null +++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex @@ -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 diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex index 2c33a77..1b9fe1a 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) |> 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_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 24bf27b..8812d99 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -29,7 +29,11 @@ defmodule Mv.Membership do require Ash.Query import Ash.Expr alias Ash.Error.Query.NotFound, as: NotFoundError + alias Mv.Helpers.SystemActor alias Mv.Membership.JoinRequest + alias Mv.Membership.Member + alias MvWeb.Emails.JoinAlreadyMemberEmail + alias MvWeb.Emails.JoinAlreadyPendingEmail alias MvWeb.Emails.JoinConfirmationEmail require Logger @@ -365,15 +369,130 @@ defmodule Mv.Membership do ## Returns - `{: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, error}` - Validation or authorization error """ def submit_join_request(attrs, opts \\ []) do 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 - # hashes it before persist. Only the hash is stored; the raw token is sent in the email link. + pending = + 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) case Ash.create(JoinRequest, attrs_with_token, @@ -384,6 +503,7 @@ defmodule Mv.Membership do {:ok, request} -> case JoinConfirmationEmail.send(request.email, token) do {:ok, _email} -> + apply_anti_enumeration_delay() {:ok, request} {:error, reason} -> diff --git a/lib/mv_web/emails/join_already_member_email.ex b/lib/mv_web/emails/join_already_member_email.ex new file mode 100644 index 0000000..fa309d8 --- /dev/null +++ b/lib/mv_web/emails/join_already_member_email.ex @@ -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 diff --git a/lib/mv_web/emails/join_already_pending_email.ex b/lib/mv_web/emails/join_already_pending_email.ex new file mode 100644 index 0000000..17dc487 --- /dev/null +++ b/lib/mv_web/emails/join_already_pending_email.ex @@ -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 diff --git a/lib/mv_web/emails/join_confirmation_email.ex b/lib/mv_web/emails/join_confirmation_email.ex index 9bd3c5a..08f4ad3 100644 --- a/lib/mv_web/emails/join_confirmation_email.ex +++ b/lib/mv_web/emails/join_confirmation_email.ex @@ -18,10 +18,16 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do Uses the same SMTP configuration as the test mail (Settings or boot ENV) via `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. """ - 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}") subject = gettext("Confirm your membership request") @@ -29,7 +35,8 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do confirm_url: confirm_url, subject: subject, 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 = diff --git a/lib/mv_web/live/join_request_live/helpers.ex b/lib/mv_web/live/join_request_live/helpers.ex index 5ec5105..58d5ccf 100644 --- a/lib/mv_web/live/join_request_live/helpers.ex +++ b/lib/mv_web/live/join_request_live/helpers.ex @@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do @doc """ 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 + 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) case user do @@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do nil end end - - def reviewer_display(_), do: nil end diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index d326f4f..14e2760 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -264,11 +264,16 @@ defmodule MvWeb.JoinRequestLive.Show do defp format_applicant_value(nil), do: nil defp format_applicant_value(""), do: nil 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), do: if(value, do: gettext("Yes"), else: gettext("No")) + defp format_applicant_value(value) when is_binary(value) or is_number(value), do: to_string(value) + defp format_applicant_value(value), do: to_string(value) defp format_applicant_value_from_map(value) do @@ -283,8 +288,10 @@ defmodule MvWeb.JoinRequestLive.Show do end defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw + defp format_applicant_value_simple(raw, _value) when is_boolean(raw), 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), do: to_string(value) diff --git a/lib/mv_web/templates/emails/join_already_member.html.heex b/lib/mv_web/templates/emails/join_already_member.html.heex new file mode 100644 index 0000000..0791b97 --- /dev/null +++ b/lib/mv_web/templates/emails/join_already_member.html.heex @@ -0,0 +1,10 @@ +
+

+ {gettext( + "We have received your request. The email address you entered is already registered as a member." + )} +

+

+ {gettext("If you have any questions, please contact us.")} +

+
diff --git a/lib/mv_web/templates/emails/join_already_pending.html.heex b/lib/mv_web/templates/emails/join_already_pending.html.heex new file mode 100644 index 0000000..1f3b608 --- /dev/null +++ b/lib/mv_web/templates/emails/join_already_pending.html.heex @@ -0,0 +1,10 @@ +
+

+ {gettext( + "We have received your request. You already have a membership application that is being reviewed." + )} +

+

+ {gettext("If you have any questions, please contact us.")} +

+
diff --git a/lib/mv_web/templates/emails/join_confirmation.html.heex b/lib/mv_web/templates/emails/join_confirmation.html.heex index b8344eb..0cd6ebc 100644 --- a/lib/mv_web/templates/emails/join_confirmation.html.heex +++ b/lib/mv_web/templates/emails/join_confirmation.html.heex @@ -1,4 +1,9 @@
+ <%= if @resend do %> +

+ {gettext("You already had a pending request. Here is a new confirmation link.")} +

+ <% end %>

{gettext( "We have received your membership request. To complete it, please click the link below." diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a0d73fb..4c824f0 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3800,3 +3800,34 @@ msgstr "Status und Prüfung" #, elixir-autogen, elixir-format 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." + +#: 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." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d20a604..8796553 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3800,3 +3800,34 @@ msgstr "" #, elixir-autogen, elixir-format msgid "We could not send the confirmation email. Please try again later or contact support." 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7a42e63..22c6363 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3800,3 +3800,34 @@ msgstr "Status and review" #, elixir-autogen, elixir-format msgid "We could not send the confirmation email. Please try again later or contact support." 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." diff --git a/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs b/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs new file mode 100644 index 0000000..850953e --- /dev/null +++ b/priv/repo/migrations/20260313120000_add_reviewed_by_display_to_join_requests.exs @@ -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 diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs index 1f9b3c2..15f5636 100644 --- a/test/membership/join_request_approval_domain_test.exs +++ b/test/membership/join_request_approval_domain_test.exs @@ -67,6 +67,18 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do 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 test "reject does not create a member" do request = Fixtures.submitted_join_request_fixture() diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs index 6c09526..fee355c 100644 --- a/test/membership/join_request_approval_policy_test.exs +++ b/test/membership/join_request_approval_policy_test.exs @@ -49,6 +49,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do assert approved.status == :approved assert approved.approved_at != nil assert approved.reviewed_by_user_id == user.id + assert approved.reviewed_by_display == to_string(user.email) end 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.rejected_at != nil assert rejected.reviewed_by_user_id == user.id + assert rejected.reviewed_by_display == to_string(user.email) end test "admin can reject a submitted join request", %{request: request} do diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 1992993..5f0ae83 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -12,7 +12,12 @@ defmodule Mv.Membership.JoinRequestTest do """ use Mv.DataCase, async: true + require Ash.Query + import Ash.Expr + + alias Mv.Fixtures alias Mv.Membership + alias Mv.Membership.JoinRequest # Valid minimal attributes for submit (email required; confirmation_token optional for tests) @valid_submit_attrs %{ @@ -136,6 +141,60 @@ defmodule Mv.Membership.JoinRequestTest do 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 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.