feat: prevent join requests with equal mail
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
40a4461d23
commit
086ecdcb1b
22 changed files with 534 additions and 11 deletions
|
|
@ -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 plug’s `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 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**.
|
- **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**.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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} ->
|
||||||
|
|
|
||||||
42
lib/mv_web/emails/join_already_member_email.ex
Normal file
42
lib/mv_web/emails/join_already_member_email.ex
Normal 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
|
||||||
43
lib/mv_web/emails/join_already_pending_email.ex
Normal file
43
lib/mv_web/emails/join_already_pending_email.ex
Normal 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
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
10
lib/mv_web/templates/emails/join_already_member.html.heex
Normal file
10
lib/mv_web/templates/emails/join_already_member.html.heex
Normal 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>
|
||||||
10
lib/mv_web/templates/emails/join_already_pending.html.heex
Normal file
10
lib/mv_web/templates/emails/join_already_pending.html.heex
Normal 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>
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue