Packages

phoenix_kit

2.41.2
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 registration.ex
Raw

lib/phoenix_kit_web/users/registration.ex

defmodule PhoenixKitWeb.Users.Registration do
  @moduledoc """
  LiveView for user registration.

  Provides a registration form for new users to create an account.
  Supports referral codes and respects the allow_registration setting.
  Tracks anonymous visitor sessions during registration.
  """
  use PhoenixKitWeb, :live_view

  alias PhoenixKit.Admin.Presence
  alias PhoenixKit.Settings
  alias PhoenixKit.Users.Auth
  alias PhoenixKit.Users.Auth.User
  alias PhoenixKit.Users.Invitations
  alias PhoenixKit.Users.Referrals
  alias PhoenixKit.Utils.Date, as: UtilsDate
  alias PhoenixKit.Utils.IpAddress
  alias PhoenixKit.Utils.Routes
  alias PhoenixKitWeb.Users.Auth, as: WebAuth
  alias PhoenixKitWeb.Users.AuthSEO

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

    case WebAuth.maybe_redirect_authenticated(socket) do
      {:redirect, socket} ->
        {:ok, socket}

      :cont ->
        do_mount(params, session, socket)
    end
  end

  defp do_mount(params, session, socket) do
    # Check if registration is allowed
    allow_registration = Settings.get_boolean_setting("allow_registration", true)

    if allow_registration do
      # Track anonymous visitor session
      if connected?(socket) do
        session_id = session["live_socket_id"] || generate_session_id()

        Presence.track_anonymous(session_id, %{
          connected_at: UtilsDate.utc_now(),
          ip_address: IpAddress.extract_from_socket(socket),
          user_agent: get_connect_info(socket, :user_agent),
          current_page: Routes.path("/users/register")
        })
      end

      # Get project title from settings (with Config fallback)
      project_title = PhoenixKit.Settings.get_project_title()

      # Get referral codes configuration
      referral_codes_config = Referrals.get_config()

      # Get Magic Link registration setting
      magic_link_registration_enabled =
        Settings.get_boolean_setting("magic_link_registration_enabled", true)

      # Get username field visibility setting
      show_username = Settings.get_setting("registration_show_username", "true") != "false"

      # Parse invitation token from URL params
      invitation_token = Map.get(params, "invitation")
      pending_invitation = load_pending_invitation(invitation_token)

      # What this form is allowed to create — see `account_type_mode/1`.
      account_type_mode = account_type_mode(pending_invitation)

      # Seeded with the type the site fixes, so the form renders the
      # Organization Name field from the first paint in organization-only mode
      # (the changeset carries no `:action` yet, so its "can't be blank" does
      # not display until the visitor submits).
      changeset =
        Auth.change_user_registration(%User{
          account_type: default_account_type(account_type_mode)
        })

      # Extract and store IP address during mount for later use
      ip_address = IpAddress.extract_from_socket(socket)

      seo = AuthSEO.seo_assigns("/users/register")

      # Support return_to query param for post-registration redirect
      # (mirrors the login page; wins over the after_registration_path setting)
      return_to = sanitize_return_to(params["return_to"])

      socket =
        socket
        |> assign(trigger_submit: false, check_errors: false)
        |> assign(username_edited: false)
        |> assign(project_title: project_title)
        |> assign(canonical_url: seo.canonical_url)
        |> assign(hreflang_links: seo.hreflang_links)
        |> 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(user_ip_address: ip_address)
        |> assign(magic_link_registration_enabled: magic_link_registration_enabled)
        |> assign(show_username: show_username)
        |> assign(account_type_mode: account_type_mode)
        |> assign(account_type_selectable: account_type_mode == "choice")
        |> assign(pending_invitation: pending_invitation)
        |> assign(pending_invitation_token: invitation_token)
        |> assign(return_to: return_to)
        |> assign(remember_me_available: WebAuth.remember_me_enabled?())
        |> assign(remember_me: WebAuth.remember_me_default?())
        |> assign_form(changeset)

      {:ok, socket, temporary_assigns: [form: nil]}
    else
      socket =
        socket
        |> put_flash(
          :error,
          gettext("User registration is currently disabled. Please contact an administrator.")
        )
        |> redirect(to: Routes.path("/users/log-in"))

      {:ok, socket}
    end
  end

  def handle_event("save", %{"user" => user_params} = params, socket) do
    if Settings.get_boolean_setting("allow_registration", true) do
      do_save(user_params, params, socket)
    else
      # Re-checked here, not just in mount/3. A LiveView socket outlives the
      # page load that created it, so a form mounted while registration was open
      # keeps a working submit endpoint for as long as the tab stays open —
      # closing registration has to close the submit, not only the entrance.
      {:noreply,
       socket
       |> put_flash(:error, gettext("Registration is currently disabled."))
       |> redirect(to: Routes.path("/users/log-in"))}
    end
  end

  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 whether the user owns the username field, then keep it in sync with
    # the email while it's still auto-managed.
    username_edited = username_manually_edited?(socket, params, user_params)
    user_params = maybe_sync_username(user_params, username_edited)

    # The form re-renders on every change, so the checkbox has to be driven by
    # what the user actually left it at — otherwise unticking it would snap
    # back to the site default on the next keystroke.
    socket =
      socket
      |> assign(username_edited: username_edited)
      |> assign(remember_me: user_params["remember_me"] == "true")

    # Validate referral code and update error state
    case validate_referral_code(referral_code, socket, :change) do
      {:ok, _} ->
        socket =
          socket
          |> assign(referral_code: referral_code)
          |> assign(referral_code_error: nil)

        changeset = Auth.change_user_registration(%User{}, user_params)
        {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}

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

        changeset = Auth.change_user_registration(%User{}, user_params)
        {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
    end
  end

  defp do_save(user_params, params, socket) do
    referral_code = params["referral_code"]
    user_params = form_params(user_params, socket.assigns.account_type_mode)

    # If the username was never manually edited, let the schema (re)generate it
    # from the final email rather than persisting a possibly-stale preview value.
    user_params = maybe_sync_username(user_params, socket.assigns.username_edited)

    # Validate referral code if system is enabled
    case validate_referral_code(referral_code, socket, :submit) do
      {:ok, validated_code} ->
        # Check if geolocation tracking is enabled
        track_geolocation = Settings.get_boolean_setting("track_registration_geolocation", false)
        ip_address = socket.assigns.user_ip_address

        # Use appropriate registration function based on geolocation setting
        registration_result =
          if track_geolocation do
            Auth.register_user_with_geolocation(user_params, ip_address)
          else
            Auth.register_user(user_params, ip_address)
          end

        case registration_result do
          {:ok, user} ->
            # Records the use AND marks the account as having satisfied
            # invite-only, so a code supplied here is not asked for again by
            # the access gate.
            Referrals.record_signup_use(user, validated_code)

            # Store invitation UUID in custom_fields for auto-accept after email
            # confirmation — only when the address registered IS the invited one;
            # a forwarded invite link must not enrol an arbitrary address.
            if invitation_for?(socket.assigns[:pending_invitation], user) do
              Auth.update_user_fields(user, %{
                "pending_invitation_uuid" => socket.assigns.pending_invitation.uuid
              })

              # That auto-accept only fires from `confirm_user/1`. With
              # confirmation not required there is no confirmation step, so
              # accept now or the invitation is never redeemed.
              maybe_accept_invitation_without_confirmation(user)
            end

            case Auth.deliver_user_confirmation_instructions(
                   user,
                   &Routes.url("/users/confirm/#{&1}")
                 ) do
              {:ok, _} ->
                # Email sent successfully
                :ok

              {:error, error} ->
                # Log error but don't fail registration
                require Logger
                Logger.error("Failed to send confirmation email: #{inspect(error)}")
                :ok
            end

            PhoenixKit.Activity.log(%{
              action: "user.registered",
              module: "users",
              mode: "manual",
              actor_uuid: user.uuid,
              resource_type: "user",
              resource_uuid: user.uuid,
              metadata: %{"method" => "self_registration", "actor_role" => "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)}
        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

  # Determines whether the user has taken manual ownership of the username field.
  #
  # `phx-change` reports the touched field in `_target`. Typing a non-empty value
  # into the username field claims it (auto-sync stops); clearing it releases
  # ownership so auto-sync from email resumes. Any other field leaves the current
  # ownership state untouched.
  defp username_manually_edited?(socket, params, user_params) do
    case params["_target"] do
      ["user", "username"] -> String.trim(user_params["username"] || "") != ""
      _ -> socket.assigns.username_edited
    end
  end

  # While the username is still auto-managed, blank it so the registration
  # changeset regenerates it from the (possibly just-corrected) email. Once the
  # user owns the field, their value is passed through unchanged.
  defp maybe_sync_username(user_params, true = _username_edited), do: user_params

  defp maybe_sync_username(user_params, false = _username_edited),
    do: Map.put(user_params, "username", "")

  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[:user_ip_address]
    )
  end

  defp generate_session_id do
    :crypto.strong_rand_bytes(16) |> Base.encode64()
  end

  # Only allow relative paths to prevent open redirect attacks
  defp sanitize_return_to(path) do
    if Routes.local_path?(path), do: path, else: nil
  end

  # Fields the public form is allowed to set. `registration_changeset/3` also
  # casts `custom_fields` and the `registration_*` geo columns — legitimate for
  # server-side callers (OAuth, the geolocation path) but attacker-controlled
  # here, since a phx-submit payload is whatever the client sends. Writing
  # `custom_fields` from the browser would let a visitor plant
  # `pending_invitation_uuid` or an `oauth_avatar_url` that the admin user list
  # renders as an <img src>.
  @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

  # The site policy, with one local override: a visitor arriving on an
  # organization invitation is joining an organization that already exists.
  # Letting them pick "Organization" would create a SECOND one — and an
  # organization account cannot hold an `organization_uuid`
  # (`validate_organization_fields/1` nils it), so the invitation could never
  # be redeemed afterwards.
  defp account_type_mode(nil), do: Auth.registration_account_type()
  defp account_type_mode(_pending_invitation), do: "person"

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

  defp maybe_accept_invitation_without_confirmation(user) do
    if Settings.get_boolean_setting("require_email_confirmation", true) do
      :ok
    else
      user = Auth.get_user(user.uuid) || user
      uuid = user.custom_fields && user.custom_fields["pending_invitation_uuid"]

      case uuid && Invitations.accept_invitation_by_uuid(uuid, user) do
        {:ok, _} ->
          Auth.update_user_fields(user, %{"pending_invitation_uuid" => nil})
          :ok

        _ ->
          :ok
      end
    end
  end

  defp invitation_for?(nil, _user), do: false
  defp invitation_for?(invitation, user), do: Invitations.invitee?(invitation, user)

  defp load_pending_invitation(nil), do: nil

  defp load_pending_invitation(token) when is_binary(token) do
    case Invitations.get_by_token(token) do
      {:ok, invitation} -> invitation
      {:error, _} -> nil
    end
  end
end