diff --git a/docs/architecture.md b/docs/architecture.md index 14a916d..b899d4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -211,6 +211,15 @@ one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery removes only the token created for that failed attempt. Expired buckets and tokens are pruned by the maintenance worker. +Authentication delivery also reuses the validity window of an existing +one-time link as an idempotency window: a repeated request for the same account +and flow does not create or send another link while the previous one remains +valid. The account row is locked only while reserving the token, so concurrent +requests cannot produce duplicate messages and SMTP work does not run inside a +database transaction. Google verification flows include a non-reversible hash +of the pending flow in the token context, so a new OAuth flow is not suppressed +by an older one. + The Compose and kind scripts finish by subscribing on one live BEAM node, broadcasting through a different connected node, and failing if the PubSub probe is not received. diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index d9a71fb..4abd7fb 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -1067,9 +1067,10 @@ defmodule WhoNeedHelp.Accounts do end @doc "Delivers a one-time local-account verification link before connecting Google." - def deliver_google_link_instructions(%User{} = user, magic_link_url_fun) - when is_function(magic_link_url_fun, 1) do - {encoded_token, user_token} = UserToken.build_email_token(user, "login") + def deliver_google_link_instructions(%User{} = user, delivery_key, magic_link_url_fun) + when is_binary(delivery_key) and is_function(magic_link_url_fun, 1) do + context = "login:google:#{short_hash(delivery_key)}" + {encoded_token, user_token} = UserToken.build_email_token(user, context) persist_email_token_and_deliver(user_token, fn -> UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token)) @@ -1093,8 +1094,54 @@ defmodule WhoNeedHelp.Accounts do ## Token helper defp persist_email_token_and_deliver(user_token, deliver_fun) do - persisted_token = Repo.insert!(user_token) + case reserve_email_token(user_token) do + {:ok, :already_sent} -> + {:ok, :already_sent} + {:ok, persisted_token} -> + deliver_reserved_email_token(persisted_token, deliver_fun) + + {:error, reason} -> + {:error, reason} + end + end + + defp reserve_email_token(%UserToken{context: "login"} = user_token), + do: reserve_auth_email_token(user_token) + + defp reserve_email_token(%UserToken{context: "login:google:" <> _} = user_token), + do: reserve_auth_email_token(user_token) + + defp reserve_email_token(user_token), do: {:ok, Repo.insert!(user_token)} + + defp reserve_auth_email_token(user_token) do + cutoff = + DateTime.utc_now(:second) + |> DateTime.add(-UserToken.magic_link_validity_in_minutes(), :minute) + + Repo.transact(fn -> + # Serialize reservations per account so concurrent requests cannot both send. + Repo.one!( + from user in User, + where: user.id == ^user_token.user_id, + select: user.id, + lock: "FOR UPDATE" + ) + + recent_token? = + Repo.exists?( + from token in UserToken, + where: token.user_id == ^user_token.user_id, + where: token.context == ^user_token.context, + where: token.sent_to == ^user_token.sent_to, + where: token.inserted_at > ^cutoff + ) + + if recent_token?, do: {:ok, :already_sent}, else: Repo.insert(user_token) + end) + end + + defp deliver_reserved_email_token(persisted_token, deliver_fun) do case deliver_fun.() do {:ok, _email} = delivered -> delivered @@ -1105,6 +1152,12 @@ defmodule WhoNeedHelp.Accounts do end end + defp short_hash(value) do + :crypto.hash(:sha256, value) + |> Base.url_encode64(padding: false) + |> binary_part(0, 22) + end + defp before_moderation_user(query, nil), do: query defp before_moderation_user(query, {inserted_at, id}) do diff --git a/lib/who_need_help/accounts/user_notifier.ex b/lib/who_need_help/accounts/user_notifier.ex index d8b6592..98f363f 100644 --- a/lib/who_need_help/accounts/user_notifier.ex +++ b/lib/who_need_help/accounts/user_notifier.ex @@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do alias WhoNeedHelp.Accounts.User # Delivers the email using the application mailer. - defp deliver(recipient, subject, body) do + defp deliver(recipient, subject, text_body, html_body) do from = Application.fetch_env!(:who_need_help, :mailer_from) email = @@ -15,7 +15,8 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do |> to(recipient) |> from({from[:name], from[:address]}) |> subject(subject) - |> text_body(body) + |> text_body(text_body) + |> html_body(html_body) with {:ok, _metadata} <- Mailer.deliver(email) do {:ok, email} @@ -27,14 +28,18 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do """ def deliver_update_email_instructions(user, url) do with_user_locale(user, fn -> - deliver( - user.email, - gettext("Update email instructions"), - gettext( - "Hi %{email},\n\nYou can change your email by visiting the URL below:\n\n%{url}\n\nIf you didn't request this change, please ignore this.", - email: user.email, - url: url - ) + deliver_action_email(user, + subject: gettext("Confirm your Who Need Help email change"), + heading: gettext("Confirm your new email address"), + introduction: + gettext("Use the secure link below to confirm this email address for your account."), + action_label: gettext("Confirm email address"), + url: url, + expiry_note: gettext("This confirmation link expires in 7 days."), + security_note: + gettext( + "If you did not request this change, ignore this email. Your address will not change." + ) ) end) end @@ -52,46 +57,143 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do @doc "Delivers instructions for verifying a local account before connecting Google sign-in." def deliver_google_link_instructions(user, url) do with_user_locale(user, fn -> - deliver( - user.email, - gettext("Confirm Google sign-in"), - gettext( - "Hi %{email},\n\nUse the secure link below to sign in and connect Google to your Who Need Help account:\n\n%{url}\n\nIf you did not request this, ignore this email. Google will not be connected.", - email: user.email, - url: url - ) + deliver_action_email(user, + subject: gettext("Confirm Google sign-in for Who Need Help"), + heading: gettext("Confirm Google sign-in"), + introduction: + gettext("Use the secure link below to sign in and connect Google to your account."), + action_label: gettext("Confirm Google sign-in"), + url: url, + expiry_note: gettext("This one-time link expires in 15 minutes."), + security_note: + gettext("If you did not request this, ignore this email. Google will not be connected.") ) end) end defp deliver_magic_link_instructions(user, url) do with_user_locale(user, fn -> - deliver( - user.email, - gettext("Log in instructions"), - gettext( - "Hi %{email},\n\nYou can log into your account by visiting the URL below:\n\n%{url}\n\nIf you didn't request this email, please ignore this.", - email: user.email, - url: url - ) + deliver_action_email(user, + subject: gettext("Your Who Need Help sign-in link"), + heading: gettext("Sign in to Who Need Help"), + introduction: gettext("Use the secure link below to sign in to your account."), + action_label: gettext("Sign in to Who Need Help"), + url: url, + expiry_note: gettext("This one-time link expires in 15 minutes."), + security_note: + gettext("If you did not request this sign-in, you can safely ignore this email.") ) end) end defp deliver_confirmation_instructions(user, url) do with_user_locale(user, fn -> - deliver( - user.email, - gettext("Confirmation instructions"), - gettext( - "Hi %{email},\n\nYou can confirm your account by visiting the URL below:\n\n%{url}\n\nIf you didn't create an account with us, please ignore this.", - email: user.email, - url: url - ) + deliver_action_email(user, + subject: gettext("Confirm your Who Need Help account"), + heading: gettext("Confirm your account"), + introduction: gettext("Use the secure link below to confirm your Who Need Help account."), + action_label: gettext("Confirm account"), + url: url, + expiry_note: gettext("This one-time link expires in 15 minutes."), + security_note: + gettext("If you did not create this account, you can safely ignore this email.") ) end) end + defp deliver_action_email(user, content) do + subject = Keyword.fetch!(content, :subject) + heading = Keyword.fetch!(content, :heading) + introduction = Keyword.fetch!(content, :introduction) + action_label = Keyword.fetch!(content, :action_label) + url = Keyword.fetch!(content, :url) + expiry_note = Keyword.fetch!(content, :expiry_note) + security_note = Keyword.fetch!(content, :security_note) + + text = """ + #{heading} + + #{introduction} + + #{action_label}: #{url} + + #{expiry_note} + + #{security_note} + + Who Need Help + """ + + html = action_email_html(heading, introduction, action_label, url, expiry_note, security_note) + + deliver(user.email, subject, text, html) + end + + defp action_email_html(heading, introduction, action_label, url, expiry_note, security_note) do + escaped_heading = escape_html(heading) + escaped_introduction = escape_html(introduction) + escaped_action_label = escape_html(action_label) + escaped_url = escape_html(url) + escaped_expiry_note = escape_html(expiry_note) + escaped_security_note = escape_html(security_note) + + """ + + + + + + + #{escaped_heading} + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Who Need Help
+

#{escaped_heading}

+
#{escaped_introduction}
+ #{escaped_action_label} +
#{escaped_expiry_note}
#{escaped_security_note}
#{escape_html(gettext("If the button does not work, copy and paste this address into your browser:"))}
#{escaped_url}
+
+ + + """ + end + + defp escape_html(value) do + value + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end + defp with_user_locale(%User{locale: locale}, fun) do Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun) end diff --git a/lib/who_need_help/accounts/user_token.ex b/lib/who_need_help/accounts/user_token.ex index 11845d2..a52a1cd 100644 --- a/lib/who_need_help/accounts/user_token.ex +++ b/lib/who_need_help/accounts/user_token.ex @@ -12,6 +12,8 @@ defmodule WhoNeedHelp.Accounts.UserToken do @change_email_validity_in_days 7 @session_validity_in_days 14 + def magic_link_validity_in_minutes, do: @magic_link_validity_in_minutes + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "users_tokens" do @@ -113,7 +115,9 @@ defmodule WhoNeedHelp.Accounts.UserToken do hashed_token = :crypto.hash(@hash_algorithm, decoded_token) query = - from token in by_token_and_context_query(hashed_token, "login"), + from token in UserToken, + where: token.token == ^hashed_token, + where: token.context == "login" or like(token.context, "login:google:%"), join: user in assoc(token, :user), where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), where: token.sent_to == user.email, @@ -160,7 +164,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do from token in UserToken, where: - (token.context == "login" and token.inserted_at <= ^magic_link_cutoff) or + (like(token.context, "login%") and token.inserted_at <= ^magic_link_cutoff) or (like(token.context, "change:%") and token.inserted_at <= ^change_email_cutoff) or (token.context == "session" and token.inserted_at <= ^session_cutoff) end diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex index a37ccac..8cdf1ae 100644 --- a/lib/who_need_help_web/controllers/google_auth_controller.ex +++ b/lib/who_need_help_web/controllers/google_auth_controller.ex @@ -525,6 +525,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do Accounts.deliver_google_link_instructions( user, + pending_token, &"#{login_url}?google_link=#{URI.encode_www_form(pending_token)}#token=#{URI.encode_www_form(&1)}" ) end diff --git a/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex b/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex index 396286b..f7f074a 100644 --- a/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex +++ b/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex @@ -12,7 +12,7 @@ else: gettext("Sign-in request processed")} <:subtitle> <%= if @email_flow == "register" do %> - {gettext("Use the newest confirmation link if it arrives.")} + {gettext("Use the most recent confirmation link that arrived.")} <% else %> {gettext("Signing in does not create a new account.")} <% end %> @@ -48,7 +48,7 @@

<%= if @email_flow == "register" do %> - {gettext("Open the newest Who Need Help email")} + {gettext("Open the most recent Who Need Help email")} <% else %> {gettext("Already registered? Check your email")} <% end %> @@ -69,7 +69,7 @@
{gettext( - "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." + "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." )}
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 50a89a8..96cc31c 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -4087,7 +4087,7 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #, elixir-autogen, elixir-format -msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." +msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 @@ -4166,7 +4166,7 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #, elixir-autogen, elixir-format -msgid "Open the newest Who Need Help email" +msgid "Open the most recent Who Need Help email" msgstr "" #: lib/who_need_help_web/live/public_profile_live.ex:105 @@ -4380,7 +4380,7 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #, elixir-autogen, elixir-format -msgid "Use the newest confirmation link if it arrives." +msgid "Use the most recent confirmation link that arrived." msgstr "" #: lib/who_need_help_web/live/request_live/show.ex:1658 @@ -6828,3 +6828,83 @@ msgstr "" #, elixir-autogen, elixir-format msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help email change" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your new email address" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm this email address for your account." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm email address" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This confirmation link expires in 7 days." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this change, ignore this email. Your address will not change." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm Google sign-in for Who Need Help" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in and connect Google to your account." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This one-time link expires in 15 minutes." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this, ignore this email. Google will not be connected." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Your Who Need Help sign-in link" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Sign in to Who Need Help" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in to your account." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this sign-in, you can safely ignore this email." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help account" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your account" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm your Who Need Help account." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm account" +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not create this account, you can safely ignore this email." +msgstr "" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If the button does not work, copy and paste this address into your browser:" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 772d3cc..d6ac092 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -4087,8 +4087,8 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #, elixir-autogen, elixir-format -msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." -msgstr "" +msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." +msgstr "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #, elixir-autogen, elixir-format @@ -4166,8 +4166,8 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #, elixir-autogen, elixir-format -msgid "Open the newest Who Need Help email" -msgstr "" +msgid "Open the most recent Who Need Help email" +msgstr "Open the most recent Who Need Help email" #: lib/who_need_help_web/live/public_profile_live.ex:105 #, elixir-autogen, elixir-format, fuzzy @@ -4380,8 +4380,8 @@ msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #, elixir-autogen, elixir-format -msgid "Use the newest confirmation link if it arrives." -msgstr "" +msgid "Use the most recent confirmation link that arrived." +msgstr "Use the most recent confirmation link that arrived." #: lib/who_need_help_web/live/request_live/show.ex:1658 #, elixir-autogen, elixir-format @@ -6828,3 +6828,83 @@ msgstr "Pending email verification" #, elixir-autogen, elixir-format msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgstr "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help email change" +msgstr "Confirm your Who Need Help email change" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your new email address" +msgstr "Confirm your new email address" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm this email address for your account." +msgstr "Use the secure link below to confirm this email address for your account." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm email address" +msgstr "Confirm email address" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This confirmation link expires in 7 days." +msgstr "This confirmation link expires in 7 days." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this change, ignore this email. Your address will not change." +msgstr "If you did not request this change, ignore this email. Your address will not change." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm Google sign-in for Who Need Help" +msgstr "Confirm Google sign-in for Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in and connect Google to your account." +msgstr "Use the secure link below to sign in and connect Google to your account." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This one-time link expires in 15 minutes." +msgstr "This one-time link expires in 15 minutes." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this, ignore this email. Google will not be connected." +msgstr "If you did not request this, ignore this email. Google will not be connected." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Your Who Need Help sign-in link" +msgstr "Your Who Need Help sign-in link" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Sign in to Who Need Help" +msgstr "Sign in to Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in to your account." +msgstr "Use the secure link below to sign in to your account." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this sign-in, you can safely ignore this email." +msgstr "If you did not request this sign-in, you can safely ignore this email." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help account" +msgstr "Confirm your Who Need Help account" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your account" +msgstr "Confirm your account" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm your Who Need Help account." +msgstr "Use the secure link below to confirm your Who Need Help account." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm account" +msgstr "Confirm account" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not create this account, you can safely ignore this email." +msgstr "If you did not create this account, you can safely ignore this email." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If the button does not work, copy and paste this address into your browser:" +msgstr "If the button does not work, copy and paste this address into your browser:" diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 431aadd..2d6dd97 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -4227,8 +4227,8 @@ msgstr "Помощники увидят только указанный выше #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #, elixir-autogen, elixir-format -msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." -msgstr "Если письмо не появилось через минуту, проверьте папку «Спам». Новая защищённая ссылка могла заменить предыдущие." +msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." +msgstr "Если письмо не появилось через минуту, проверьте папку «Спам». Повторный запрос не отправляет ещё одно письмо, пока недавняя защищённая ссылка остаётся действительной." #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #, elixir-autogen, elixir-format @@ -4306,8 +4306,8 @@ msgstr "Открыть ссылку для благодарности" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #, elixir-autogen, elixir-format -msgid "Open the newest Who Need Help email" -msgstr "Откройте последнее письмо Who Need Help" +msgid "Open the most recent Who Need Help email" +msgstr "Откройте последнее полученное письмо Who Need Help" #: lib/who_need_help_web/live/public_profile_live.ex:105 #, elixir-autogen, elixir-format @@ -4520,8 +4520,8 @@ msgstr "Перейдите по ссылке подтверждения в пи #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #, elixir-autogen, elixir-format -msgid "Use the newest confirmation link if it arrives." -msgstr "Если пришла новая ссылка подтверждения, используйте её." +msgid "Use the most recent confirmation link that arrived." +msgstr "Используйте последнюю полученную ссылку подтверждения." #: lib/who_need_help_web/live/request_live/show.ex:1658 #, elixir-autogen, elixir-format @@ -6971,3 +6971,83 @@ msgstr "Ожидает подтверждения email" #, elixir-autogen, elixir-format msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgstr "Мы сохранили ожидающую заявку %{reference}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help email change" +msgstr "Подтвердите изменение email в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your new email address" +msgstr "Подтвердите новый адрес электронной почты" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm this email address for your account." +msgstr "Перейдите по защищённой ссылке ниже, чтобы подтвердить этот адрес электронной почты для своей учётной записи." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm email address" +msgstr "Подтвердить email" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This confirmation link expires in 7 days." +msgstr "Ссылка для подтверждения действует 7 дней." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this change, ignore this email. Your address will not change." +msgstr "Если вы не запрашивали это изменение, проигнорируйте письмо. Ваш адрес не изменится." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm Google sign-in for Who Need Help" +msgstr "Подтвердите вход через Google в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in and connect Google to your account." +msgstr "Перейдите по защищённой ссылке ниже, чтобы войти и подключить Google к своей учётной записи." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This one-time link expires in 15 minutes." +msgstr "Эта одноразовая ссылка действует 15 минут." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this, ignore this email. Google will not be connected." +msgstr "Если вы этого не запрашивали, проигнорируйте письмо. Google не будет подключён." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Your Who Need Help sign-in link" +msgstr "Ваша ссылка для входа в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Sign in to Who Need Help" +msgstr "Войти в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in to your account." +msgstr "Перейдите по защищённой ссылке ниже, чтобы войти в свою учётную запись." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this sign-in, you can safely ignore this email." +msgstr "Если вы не запрашивали вход, просто проигнорируйте это письмо." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help account" +msgstr "Подтвердите учётную запись Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your account" +msgstr "Подтвердите учётную запись" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm your Who Need Help account." +msgstr "Перейдите по защищённой ссылке ниже, чтобы подтвердить учётную запись Who Need Help." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm account" +msgstr "Подтвердить учётную запись" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not create this account, you can safely ignore this email." +msgstr "Если вы не создавали эту учётную запись, просто проигнорируйте письмо." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If the button does not work, copy and paste this address into your browser:" +msgstr "Если кнопка не работает, скопируйте этот адрес и вставьте его в браузер:" diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po index 2341a4c..0100343 100644 --- a/priv/gettext/uk/LC_MESSAGES/default.po +++ b/priv/gettext/uk/LC_MESSAGES/default.po @@ -4221,8 +4221,8 @@ msgstr "Помічники бачитимуть лише вказаний вищ #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #, elixir-autogen, elixir-format -msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." -msgstr "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Нове захищене посилання могло замінити попередні." +msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid." +msgstr "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Повторний запит не надсилає ще одного листа, доки нещодавнє захищене посилання залишається чинним." #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #, elixir-autogen, elixir-format @@ -4300,8 +4300,8 @@ msgstr "Відкрити посилання для подяки" #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #, elixir-autogen, elixir-format -msgid "Open the newest Who Need Help email" -msgstr "Відкрийте останній лист Who Need Help" +msgid "Open the most recent Who Need Help email" +msgstr "Відкрийте останній отриманий лист Who Need Help" #: lib/who_need_help_web/live/public_profile_live.ex:105 #, elixir-autogen, elixir-format @@ -4514,8 +4514,8 @@ msgstr "Перейдіть за посиланням підтвердження #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #, elixir-autogen, elixir-format -msgid "Use the newest confirmation link if it arrives." -msgstr "Якщо надійшло нове посилання підтвердження, використайте його." +msgid "Use the most recent confirmation link that arrived." +msgstr "Скористайтеся останнім отриманим посиланням підтвердження." #: lib/who_need_help_web/live/request_live/show.ex:1658 #, elixir-autogen, elixir-format @@ -6965,3 +6965,83 @@ msgstr "Очікує підтвердження email" #, elixir-autogen, elixir-format msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgstr "Ми зберегли запит %{reference}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help email change" +msgstr "Підтвердьте зміну email у Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your new email address" +msgstr "Підтвердьте нову адресу електронної пошти" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm this email address for your account." +msgstr "Перейдіть за захищеним посиланням нижче, щоб підтвердити цю адресу електронної пошти для свого облікового запису." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm email address" +msgstr "Підтвердити email" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This confirmation link expires in 7 days." +msgstr "Посилання для підтвердження діє 7 днів." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this change, ignore this email. Your address will not change." +msgstr "Якщо ви не запитували цю зміну, проігноруйте лист. Ваша адреса не зміниться." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm Google sign-in for Who Need Help" +msgstr "Підтвердьте вхід через Google у Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in and connect Google to your account." +msgstr "Перейдіть за захищеним посиланням нижче, щоб увійти й підключити Google до свого облікового запису." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "This one-time link expires in 15 minutes." +msgstr "Це одноразове посилання діє 15 хвилин." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this, ignore this email. Google will not be connected." +msgstr "Якщо ви цього не запитували, проігноруйте лист. Google не буде підключено." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Your Who Need Help sign-in link" +msgstr "Ваше посилання для входу в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Sign in to Who Need Help" +msgstr "Увійти в Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to sign in to your account." +msgstr "Перейдіть за захищеним посиланням нижче, щоб увійти до свого облікового запису." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not request this sign-in, you can safely ignore this email." +msgstr "Якщо ви не запитували вхід, просто проігноруйте цей лист." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your Who Need Help account" +msgstr "Підтвердьте обліковий запис Who Need Help" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm your account" +msgstr "Підтвердьте обліковий запис" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Use the secure link below to confirm your Who Need Help account." +msgstr "Перейдіть за захищеним посиланням нижче, щоб підтвердити обліковий запис Who Need Help." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "Confirm account" +msgstr "Підтвердити обліковий запис" + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If you did not create this account, you can safely ignore this email." +msgstr "Якщо ви не створювали цей обліковий запис, просто проігноруйте лист." + +#: lib/who_need_help/accounts/user_notifier.ex +msgid "If the button does not work, copy and paste this address into your browser:" +msgstr "Якщо кнопка не працює, скопіюйте цю адресу та вставте її у браузер:" diff --git a/test/who_need_help/accounts/user_notifier_test.exs b/test/who_need_help/accounts/user_notifier_test.exs new file mode 100644 index 0000000..84248fb --- /dev/null +++ b/test/who_need_help/accounts/user_notifier_test.exs @@ -0,0 +1,72 @@ +defmodule WhoNeedHelp.Accounts.UserNotifierTest do + use ExUnit.Case, async: true + + alias WhoNeedHelp.Accounts.{User, UserNotifier} + + test "magic-link email is recognizable, multipart, and explicit about expiry" do + user = %User{ + email: "person@example.com", + display_name: "Helpful neighbor", + locale: "en", + confirmed_at: DateTime.utc_now(:second) + } + + url = "https://whoneedhelp.com/users/log-in#token=one-time-token" + + assert {:ok, email} = UserNotifier.deliver_login_instructions(user, url) + + assert email.subject == "Your Who Need Help sign-in link" + assert email.text_body =~ "This one-time link expires in 15 minutes." + assert email.text_body =~ url + refute email.text_body =~ user.email + + assert email.html_body =~ "Sign in to Who Need Help" + assert email.html_body =~ ~s(href="#{url}") + assert email.html_body =~ "copy and paste this address into your browser" + refute email.html_body =~ " where([token], token.user_id == ^user.id) + |> select([token], token.context) + |> Repo.all() + + assert length(contexts) == 2 + assert Enum.all?(contexts, &String.starts_with?(&1, "login:google:")) + assert contexts |> MapSet.new() |> MapSet.size() == 2 + end end describe "inspect/2 for the User module" do diff --git a/test/who_need_help_web/controllers/google_auth_controller_test.exs b/test/who_need_help_web/controllers/google_auth_controller_test.exs index 0175927..01e0e88 100644 --- a/test/who_need_help_web/controllers/google_auth_controller_test.exs +++ b/test/who_need_help_web/controllers/google_auth_controller_test.exs @@ -506,8 +506,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "finish connecting Google" assert_email_sent(fn email -> - email.subject == "Confirm Google sign-in" and - email.text_body =~ "connect Google to your Who Need Help account" and + email.subject == "Confirm Google sign-in for Who Need Help" and + email.text_body =~ "connect Google to your account" and email.text_body =~ "google_link=" and email.text_body =~ "#token=" end)