defmodule ToweropsWeb.UserRegistrationLive do @moduledoc """ LiveView for user registration with HIBP password breach checking. """ use ToweropsWeb, :live_view alias Towerops.Accounts alias Towerops.Accounts.HIBP alias Towerops.Accounts.User alias Towerops.Organizations alias Towerops.Workers.WelcomeEmailWorker def mount(params, session, socket) do # Check if registering via invitation invitation_token = params["invitation_token"] || params["token"] invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token) # Get detected timezone from session (set by CaptureTimezone plug) detected_timezone = Map.get(session, "detected_timezone", "UTC") changeset = Accounts.change_user_registration(%User{}) {:ok, socket |> assign(:page_title, t("Register")) |> assign(:form, to_form(changeset)) |> assign(:invitation, invitation) |> assign(:invitation_token, invitation_token) |> assign(:password_breach_count, nil) |> assign(:detected_timezone, detected_timezone) |> assign(:trigger_action, false) |> assign(:privacy_policy_consent, false) |> assign(:terms_of_service_consent, false)} end def handle_event("check_password_breach", %{"value" => password}, socket) when is_binary(password) and byte_size(password) > 0 do case HIBP.check_password(password) do {:ok, count} when is_integer(count) -> {:noreply, assign(socket, :password_breach_count, count)} {:ok, :unknown} -> {:noreply, assign(socket, :password_breach_count, nil)} end end def handle_event("check_password_breach", _params, socket) do {:noreply, socket} end def handle_event("validate", %{"user" => user_params}, socket) do changeset = %User{} |> Accounts.change_user_registration(user_params) |> Map.put(:action, :validate) {:noreply, socket |> assign(:form, to_form(changeset)) |> assign(:privacy_policy_consent, user_params["privacy_policy_consent"] == "on") |> assign(:terms_of_service_consent, user_params["terms_of_service_consent"] == "on")} end def handle_event("save", %{"user" => user_params}, socket) do invitation_token = socket.assigns.invitation_token # Normalize checkbox values: HTML sends "on", but changeset expects true user_params = normalize_consent_params(user_params) if invitation_token do # Registration via invitation - don't create personal org create_via_invitation(socket, user_params, invitation_token) else # Normal registration - create personal org create_with_organization(socket, user_params) end end defp normalize_consent_params(params) do params |> Map.update("privacy_policy_consent", nil, fn "on" -> true val -> val end) |> Map.update("terms_of_service_consent", nil, fn "on" -> true val -> val end) end defp create_with_organization(socket, user_params) do # Merge detected timezone into params user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone) case Accounts.register_user_with_organization(user_params_with_timezone) do {:ok, user} -> Accounts.deliver_user_confirmation_instructions( user, &url(~p"/users/confirm/#{&1}") ) WelcomeEmailWorker.enqueue(user.id) {:noreply, socket |> put_flash(:info, t("Account created! Please check your email to verify your account.")) |> redirect(to: ~p"/users/log-in?email=#{user.email}") |> assign(:trigger_action, true)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end defp create_via_invitation(socket, user_params, invitation_token) do # Merge detected timezone into params user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone) with {:ok, invitation} <- get_valid_invitation(invitation_token), {:ok, user} <- Accounts.register_user(user_params_with_timezone), {:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do Accounts.deliver_user_confirmation_instructions( user, &url(~p"/users/confirm/#{&1}") ) WelcomeEmailWorker.enqueue(user.id) {:noreply, socket |> put_flash(:info, t("Account created! Please check your email to verify your account.")) |> redirect(to: ~p"/users/log-in?email=#{user.email}") |> assign(:trigger_action, true)} else {:error, :invalid_invitation} -> {:noreply, socket |> put_flash(:error, t("Invalid or expired invitation link.")) |> assign(:invitation, nil) |> assign(:invitation_token, nil)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end defp get_valid_invitation(token) do case Organizations.get_invitation_by_token(token) do nil -> {:error, :invalid_invitation} invitation -> {:ok, invitation} end end def render(assigns) do ~H"""
<.header> <%= if @invitation do %> Join {@invitation.organization.name} <% else %> Register for an account <% end %> <:subtitle> Already registered? <.link navigate={~p"/users/log-in"} class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Log in to your account now. <%= if @invitation do %>

You've been invited to join {@invitation.organization.name} as a {@invitation.role}.

<% else %>

Free tier includes:

  • ✓ Monitor up to 10 devices
  • ✓ Real-time alerts and notifications
  • ✓ Performance charts and historical data
  • ✓ No credit card required
<% end %>
<.form for={@form} phx-change="validate" phx-submit="save"> <%= if @invitation_token do %> <% end %>
<.input field={@form[:first_name]} type="text" label="First name" autocomplete="given-name" phx-mounted={JS.focus()} /> <.input field={@form[:last_name]} type="text" label="Last name" autocomplete="family-name" />
<.input field={@form[:email]} type="email" label="Email" autocomplete="email" required /> <.input field={@form[:password]} type="password" label="Password" autocomplete="new-password" phx-blur="check_password_breach" required /> <%= if @password_breach_count && @password_breach_count > 0 do %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />

Password found in data breaches

This password has appeared {@password_breach_count} {if @password_breach_count == 1, do: "time", else: "times"} in known data breaches. We strongly recommend choosing a different, more unique password to protect your account.

<% end %> <%= unless @invitation do %> <.input name="user[organization_name]" value="" type="text" label="Organization Name" placeholder="My Company" required /> <% end %>
<.button phx-disable-with="Creating account..." class="w-full mt-6" variant="primary"> {if @invitation, do: "Accept invitation and create account", else: "Create an account"}
""" end end