Packages

phoenix_kit

2.60.2
2.63.0 2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web users confirmation_instructions.ex
Raw

lib/phoenix_kit_web/users/confirmation_instructions.ex

defmodule PhoenixKitWeb.Users.ConfirmationInstructions do
  @moduledoc """
  LiveView for resending email confirmation instructions.

  Allows users to request a new confirmation email if they didn't receive
  the original or if the link expired. Only sends if the account exists
  and is not already confirmed.

  This page is also where the authentication gates park logged-in users
  whose email is not yet confirmed (when `require_email_confirmation` is
  on), so it moves them along as soon as confirmation happens:

  - On mount, an already-confirmed user is redirected onward immediately —
    covers a confirmation done elsewhere (or directly in the DB) followed
    by a refresh, since the user is reloaded from the DB on every mount.
  - While parked, the LiveView listens on this user's own confirmation topic
    for `{:user_confirmed, user}` (broadcast by both the email-link flow and
    the admin confirm action) and redirects onward live, no refresh needed —
    e.g. when the user clicks the emailed link in another tab. It is a
    per-user topic, not the site-wide admin users feed, so a parked
    non-admin never receives other users' structs.
  - A parked user can also fix a typo'd address instead of only resending to
    it: "Wrong email?" reveals a bare new-address form — no password, unlike
    Profile Settings' confirmed-user change-email flow, because there is no
    live/confirmed account yet for a hijacked session to protect (see
    `Auth.apply_unconfirmed_user_email/2`). The emailed link points
    at `PhoenixKitWeb.Users.ConfirmEmailChange`
    (`/users/confirm/change-email/:token`), NOT the normal
    `/profile/settings/confirm-email/:token` landing page — that one sits
    behind the authenticated-AND-confirmed live_session, which an
    unconfirmed user fixing their email could never pass. Confirming the
    *new* address there changes the account's email AND confirms it in one
    step (`Auth.update_user_email/2` runs `confirm_changeset` either way),
    so there is no separate "now confirm again" round trip.

  "Onward" is `?return_to=` (stashed by the gate that parked them), then
  the session's `user_return_to`, then the `after_login_path` setting.
  """
  use PhoenixKitWeb, :live_view

  require Logger

  alias PhoenixKit.Admin.Events
  alias PhoenixKit.Users.Auth
  alias PhoenixKit.Users.RateLimiter
  alias PhoenixKit.Utils.IpAddress
  alias PhoenixKit.Utils.Routes

  def mount(params, session, socket) do
    socket = assign(socket, :page_title, gettext("Resend confirmation"))

    user = socket.assigns[:phoenix_kit_current_user]
    destination = resolve_destination(params, session, socket)

    # Connect info is readable during mount and nowhere else, so the address
    # the per-IP bucket needs at submit time has to be captured here.
    socket = assign(socket, :ip_address, IpAddress.client_address_from_socket(socket))

    cond do
      user && user.confirmed_at ->
        {:ok, redirect(socket, to: destination)}

      is_nil(user) ->
        {:ok,
         socket
         |> assign(form: to_form(%{"email" => ""}, as: "user"))
         |> assign(awaiting_confirmation?: false)
         |> assign(destination: destination)}

      true ->
        # Subscribe FIRST, then re-read: a confirmation committed between the
        # on_mount user load and the subscribe would otherwise be missed, and
        # the page would sit there forever despite promising to continue
        # automatically. Re-reading after subscribing closes that window —
        # either the re-read sees it, or the broadcast reaches us.
        if connected?(socket), do: Events.subscribe_to_user_confirmation(user.uuid)

        if connected?(socket) and confirmed_since_mount?(user) do
          {:ok, redirect(socket, to: destination)}
        else
          {:ok,
           socket
           |> assign(form: to_form(%{"email" => user.email}, as: "user"))
           |> assign(awaiting_confirmation?: true)
           |> assign(destination: destination)
           |> assign(change_email?: false)
           |> assign(email_form: to_form(Auth.change_user_email(user), as: "email_change"))
           |> assign(email_change_requires_password?: email_change_requires_password?())
           |> assign(email_form_current_password: nil)
           |> assign(confirmation_sent_at: Auth.get_last_confirmation_sent_at(user))}
        end
    end
  end

  defp confirmed_since_mount?(user) do
    case Auth.get_user(user.uuid) do
      %{confirmed_at: confirmed_at} -> not is_nil(confirmed_at)
      _ -> false
    end
  end

  def handle_event("send_instructions", %{"user" => %{"email" => submitted_email}}, socket) do
    # A parked visitor is already authenticated — resend to THEIR account,
    # never whatever the (still-editable) form field currently holds. Without
    # this, a logged-in unconfirmed user could edit the pre-filled field to
    # probe whether an arbitrary address exists, and get an honest answer
    # back (see below) instead of the anonymous form's deliberately vague one.
    email =
      if socket.assigns.awaiting_confirmation? do
        socket.assigns.phoenix_kit_current_user.email
      else
        submitted_email
      end

    # Throttle BEFORE the lookup and answer identically either way. This form
    # is public: only an existing unconfirmed account does work (insert a token,
    # send mail), so an unthrottled endpoint is both a targeted mail-flood
    # vector and a timing oracle for which addresses are registered.
    result =
      with :ok <-
             RateLimiter.check_confirmation_resend_rate_limit(
               email,
               socket.assigns[:ip_address]
             ),
           %{} = user <- Auth.get_user_by_email(email) do
        Auth.deliver_user_confirmation_instructions(
          user,
          &Routes.url("/users/confirm/#{&1}")
        )
      end

    socket = flash_send_result(socket, result, email)

    # A parked (logged-in, unconfirmed) user stays here so the live
    # auto-advance can fire once they click the emailed link; anonymous
    # visitors are sent on to the resolved destination.
    if socket.assigns.awaiting_confirmation? do
      sent_at =
        Auth.get_last_confirmation_sent_at(socket.assigns.phoenix_kit_current_user)

      {:noreply, assign(socket, confirmation_sent_at: sent_at)}
    else
      {:noreply, redirect(socket, to: socket.assigns.destination)}
    end
  end

  def handle_event("toggle_change_email", _params, socket) do
    if socket.assigns.awaiting_confirmation? do
      {:noreply, update(socket, :change_email?, &(!&1))}
    else
      {:noreply, socket}
    end
  end

  def handle_event("validate_email_change", params, socket) do
    if socket.assigns.awaiting_confirmation? do
      {:noreply, do_validate_email_change(params, socket)}
    else
      {:noreply, socket}
    end
  end

  def handle_event("update_email", params, socket) do
    if socket.assigns.awaiting_confirmation? do
      {:noreply, do_update_email(params, socket)}
    else
      {:noreply, socket}
    end
  end

  defp do_validate_email_change(%{"email_change" => user_params} = params, socket) do
    email_form =
      socket.assigns.phoenix_kit_current_user
      |> Auth.change_user_email(user_params)
      |> Map.put(:action, :validate)
      |> to_form(as: "email_change")

    socket
    |> assign(email_form: email_form)
    |> assign(email_form_current_password: params["current_password"])
  end

  defp do_update_email(%{"email_change" => user_params} = params, socket) do
    user = socket.assigns.phoenix_kit_current_user

    case apply_email_change(socket, user, params["current_password"], user_params) do
      {:ok, applied_user} ->
        Auth.deliver_user_update_email_instructions(
          applied_user,
          user.email,
          &Routes.url("/users/confirm/change-email/#{&1}")
        )

        info =
          gettext(
            "We've sent a confirmation link to %{email}. Click it to finish updating your email — that also confirms your account.",
            email: applied_user.email
          )

        socket
        |> put_flash(:info, info)
        |> assign(change_email?: false)
        |> assign(email_form: to_form(Auth.change_user_email(user), as: "email_change"))
        |> assign(email_form_current_password: nil)

      {:error, changeset} ->
        socket
        |> assign(email_form: to_form(changeset, as: "email_change", action: :insert))
        |> assign(email_form_current_password: params["current_password"])
    end
  end

  # The password-less path exists for a signup that cannot use its account
  # yet: with `require_email_confirmation` on, an unconfirmed account is
  # parked, so there is no live account for a hijacked session to re-address.
  # With the setting OFF that premise is gone — the account works, only the
  # confirmation is pending — so the change asks for the password exactly as
  # Profile Settings does (`Auth.apply_user_email/3`). Either way the account
  # must still be unconfirmed: the pattern match keeps this page from ever
  # becoming a second email-change path for a confirmed user.
  defp apply_email_change(
         %{assigns: %{email_change_requires_password?: true}},
         %{confirmed_at: nil} = user,
         password,
         user_params
       ),
       do: Auth.apply_user_email(user, password, user_params)

  defp apply_email_change(_socket, user, _password, user_params),
    do: Auth.apply_unconfirmed_user_email(user, user_params)

  defp email_change_requires_password?,
    do: not PhoenixKit.Settings.get_boolean_setting("require_email_confirmation", true)

  def handle_info({:user_confirmed, %{uuid: uuid}}, socket) do
    current = socket.assigns[:phoenix_kit_current_user]

    if current && current.uuid == uuid do
      {:noreply,
       socket
       |> put_flash(:info, gettext("Email confirmed. Welcome!"))
       |> redirect(to: socket.assigns.destination)}
    else
      {:noreply, socket}
    end
  end

  # Defensive: the topic carries only this user's confirmation today.
  def handle_info(_msg, socket), do: {:noreply, socket}

  # The anonymous path always shows the same vague copy no matter the outcome
  # (unregistered address, rate limit, or a real mailer failure) — surfacing
  # any of those as different from "sent" would tell an anonymous visitor
  # whether an address is registered. A parked visitor is already logged in
  # as that account, so there is nothing left to protect by hiding a real
  # failure from them — and hiding it is exactly how "we sent it" survived a
  # silent rate-limit block or mailer error undetected.
  defp flash_send_result(socket, result, email) do
    if socket.assigns.awaiting_confirmation? do
      flash_parked_result(socket, result, email)
    else
      put_flash(
        socket,
        :info,
        gettext(
          "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
        )
      )
    end
  end

  defp flash_parked_result(socket, {:ok, _email}, email) do
    put_flash(
      socket,
      :info,
      gettext("We've sent a new confirmation link to %{email}.", email: email)
    )
  end

  defp flash_parked_result(socket, {:error, :rate_limit_exceeded}, _email) do
    put_flash(
      socket,
      :error,
      gettext(
        "You've asked for this a few times already — please wait a few minutes and try again."
      )
    )
  end

  defp flash_parked_result(socket, {:error, :already_confirmed}, _email) do
    put_flash(socket, :info, gettext("Your email is already confirmed."))
  end

  defp flash_parked_result(socket, other, email) do
    Logger.error("Confirmation resend to #{email} failed: #{inspect(other)}")

    put_flash(
      socket,
      :error,
      gettext("We couldn't send that email just now — please try again in a moment.")
    )
  end

  # `:context` threads the socket's router so `"/"` is only used where the
  # host actually declares a root route. Without it the resolver synthesises
  # `"/"` literally, which 404s on any host that has no root route — the
  # configuration this entire branch exists to handle.
  defp resolve_destination(params, session, socket) do
    Routes.post_auth_path([params["return_to"], session["user_return_to"]],
      context: socket,
      scope: socket.assigns[:phoenix_kit_current_scope]
    )
  end
end