Packages

phoenix_kit

2.27.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 magic_link_registration.ex
Raw

lib/phoenix_kit_web/users/magic_link_registration.ex

defmodule PhoenixKitWeb.Users.MagicLinkRegistration do
  @moduledoc """
  LiveView for magic link registration completion form.
  """

  use PhoenixKitWeb, :live_view

  alias PhoenixKit.Users.Auth
  alias PhoenixKit.Users.Auth.User
  alias PhoenixKit.Users.MagicLinkRegistration
  alias PhoenixKit.Users.Referrals
  alias PhoenixKit.Utils.Routes
  alias PhoenixKitWeb.Users.Auth, as: WebAuth

  @impl true
  def mount(%{"token" => token}, _session, socket) do
    case WebAuth.maybe_redirect_authenticated(socket) do
      {:redirect, socket} ->
        {:ok, socket}

      :cont ->
        # Gate the completion page too, not just the request page. Otherwise a
        # link already sitting in an inbox still creates an account after an
        # admin has switched magic-link registration off.
        #
        # BOTH switches, matching the request page. `allow_registration` is the
        # broader one — "no new accounts at all" — so honouring only the
        # magic-link-specific setting here left the wider switch as exactly the
        # kind of button-hiding-without-route-closing this page was fixed for.
        cond do
          not PhoenixKit.Settings.get_boolean_setting("allow_registration", true) ->
            # Registration is closed outright, so /users/register is no help.
            {:ok,
             socket
             |> put_flash(:error, gettext("Registration is currently disabled."))
             |> redirect(to: Routes.path("/users/log-in"))}

          not WebAuth.magic_link_registration_enabled?() ->
            {:ok,
             socket
             |> put_flash(:error, gettext("Magic link registration is currently disabled."))
             |> redirect(to: Routes.path("/users/register"))}

          true ->
            mount_completion_form(token, socket)
        end
    end
  end

  defp mount_completion_form(token, socket) do
    case MagicLinkRegistration.verify_registration_token(token) do
      {:ok, email} ->
        # Get referral codes configuration
        referral_codes_config = Referrals.get_config()

        # Generate username suggestion from email
        suggested_username = User.generate_username_from_email(email)

        # What this form may create. There is no picker here (the visitor
        # already proved the address; the site decides the type), so the mode
        # is applied straight to the seed struct and enforced again on submit.
        account_type_mode = Auth.registration_account_type()

        changeset =
          Auth.change_user_registration(%User{
            email: email,
            username: suggested_username,
            account_type: default_account_type(account_type_mode)
          })

        # Extract IP address for geolocation
        ip_address =
          case get_connect_info(socket, :peer_data) do
            %{address: {a, b, c, d}} -> "#{a}.#{b}.#{c}.#{d}"
            %{address: address} -> to_string(address)
            _ -> "unknown"
          end

        {:ok,
         socket
         |> assign(:page_title, "Complete Registration")
         |> assign(:token, token)
         |> assign(:email, email)
         |> assign(:ip_address, ip_address)
         |> assign(:account_type_mode, account_type_mode)
         |> assign(:referral_codes_enabled, referral_codes_config.enabled)
         |> assign(:referral_codes_required, referral_codes_config.required)
         |> assign(:referral_code, nil)
         |> assign(:referral_code_error, nil)
         |> assign(:trigger_submit, false)
         |> assign(:check_errors, false)
         |> assign(:remember_me_available, WebAuth.remember_me_enabled?())
         |> assign(:remember_me, WebAuth.remember_me_default?())
         |> assign_form(changeset)}

      {:error, _} ->
        {:ok,
         socket
         |> put_flash(:error, gettext("Registration link is invalid or has expired."))
         |> redirect(to: Routes.path("/users/register"))}
    end
  end

  @impl true
  def handle_event("validate", %{"user" => user_params} = params, socket) do
    referral_code = params["referral_code"]
    user_params = form_params(user_params, socket.assigns.account_type_mode)

    # Track the checkbox across re-renders so unticking it sticks.
    socket = assign(socket, :remember_me, user_params["remember_me"] == "true")

    case validate_referral_code(referral_code, socket, :change) do
      {:ok, _} ->
        socket =
          socket
          |> assign(referral_code: referral_code)
          |> assign(referral_code_error: nil)

        changeset =
          %User{email: socket.assigns.email}
          |> Auth.change_user_registration(user_params)
          |> Map.put(:action, :validate)

        {:noreply, assign_form(socket, changeset)}

      {:error, error_message} ->
        socket =
          socket
          |> assign(referral_code: referral_code)
          |> assign(referral_code_error: error_message)

        changeset =
          %User{email: socket.assigns.email}
          |> Auth.change_user_registration(user_params)
          |> Map.put(:action, :validate)

        {:noreply, assign_form(socket, changeset)}
    end
  end

  @impl true
  def handle_event("save", %{"user" => user_params} = params, socket) do
    referral_code = params["referral_code"]
    user_params = form_params(user_params, socket.assigns.account_type_mode)

    case validate_referral_code(referral_code, socket, :submit) do
      {:ok, _validated_code} ->
        # Add referral_code to user params
        user_params =
          if referral_code && String.trim(referral_code) != "" do
            Map.put(user_params, "referral_code", referral_code)
          else
            user_params
          end

        # Complete registration
        case MagicLinkRegistration.complete_registration(
               socket.assigns.token,
               user_params,
               socket.assigns.ip_address
             ) do
          {:ok, user} ->
            changeset = Auth.change_user_registration(user)
            {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

          {:error, %Ecto.Changeset{} = changeset} ->
            {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}

          # `complete_registration/3` also returns bare atoms — `:invalid_token`
          # once the token expires while the form sits open, and
          # `:email_already_exists` if the address is claimed in between.
          # Without these clauses either outcome was a CaseClauseError.
          {:error, reason} ->
            {:noreply,
             socket
             |> put_flash(:error, completion_error_message(reason))
             |> redirect(to: Routes.path("/users/register/magic-link"))}
        end

      {:error, error_message} ->
        socket =
          socket
          |> assign(referral_code: referral_code)
          |> assign(referral_code_error: error_message)
          |> assign(check_errors: true)

        {:noreply, socket}
    end
  end

  defp completion_error_message(:email_already_exists),
    do: gettext("That email is already registered. Please log in instead.")

  defp completion_error_message(_),
    do: gettext("Registration link is invalid or has expired. Please request a new one.")

  # Only what this form legitimately owns. `registration_changeset/3` also casts
  # `custom_fields` and the geo columns — fine for server-side callers, but a
  # phx-submit payload is whatever the client sends.
  @form_fields ~w(email username password first_name last_name account_type
                  organization_name user_timezone remember_me return_to)

  defp form_params(user_params, mode) when is_map(user_params) do
    user_params
    |> Map.take(@form_fields)
    |> Auth.enforce_registration_account_type(mode)
  end

  defp form_params(other, _mode), do: other

  defp default_account_type("organization"), do: "organization"
  defp default_account_type(_mode), do: "person"

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    form = to_form(changeset, as: "user")

    if changeset.valid? do
      assign(socket, form: form, check_errors: false)
    else
      assign(socket, form: form)
    end
  end

  # `context` is `:change` while the user types and `:submit` on the final
  # attempt. See `PhoenixKit.Users.Referrals.validate_for_signup/2` for why the
  # two differ and why every rejection reads the same.
  defp validate_referral_code(referral_code, socket, context) do
    Referrals.validate_for_signup(referral_code,
      enabled?: socket.assigns.referral_codes_enabled,
      required?: socket.assigns.referral_codes_required,
      context: context,
      ip_address: socket.assigns[:ip_address]
    )
  end
end