towerops/lib/towerops_web/live/user_registration_live.ex
Graham McIntire f2608e8f16
fix: status emoji accumulating in page title and add missing page titles
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
2026-03-11 09:54:18 -05:00

342 lines
12 KiB
Elixir

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"""
<Layouts.app flash={@flash}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.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
</.link>
to your account now.
</:subtitle>
</.header>
<%= if @invitation do %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-900 dark:text-blue-100">
You've been invited to join
<span class="font-semibold">{@invitation.organization.name}</span>
as
a {@invitation.role}.
</p>
</div>
<% else %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
Free tier includes:
</p>
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>✓ Monitor up to 10 devices</li>
<li>✓ Real-time alerts and notifications</li>
<li>✓ Performance charts and historical data</li>
<li>✓ No credit card required</li>
</ul>
</div>
<% end %>
</div>
<div class="mt-8">
<.form for={@form} phx-change="validate" phx-submit="save">
<%= if @invitation_token do %>
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
<% end %>
<div class="grid grid-cols-2 gap-4">
<.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"
/>
</div>
<.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 %>
<div class="mt-2 rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password found in data breaches
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
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.
</p>
</div>
</div>
</div>
</div>
<% end %>
<%= unless @invitation do %>
<.input
name="user[organization_name]"
value=""
type="text"
label="Organization Name"
placeholder="My Company"
required
/>
<% end %>
<div class="space-y-3 mt-6">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="privacy_policy_consent"
name="user[privacy_policy_consent]"
type="checkbox"
checked={@privacy_policy_consent}
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for="privacy_policy_consent"
class="text-zinc-700 dark:text-zinc-300 cursor-pointer"
>
I agree to the
<.link
href={~p"/privacy"}
target="_blank"
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onclick="event.stopPropagation()"
>
Privacy Policy
</.link>
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="terms_of_service_consent"
name="user[terms_of_service_consent]"
type="checkbox"
checked={@terms_of_service_consent}
required
class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:checked:bg-blue-500 dark:checked:border-blue-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for="terms_of_service_consent"
class="text-zinc-700 dark:text-zinc-300 cursor-pointer"
>
I agree to the
<.link
href={~p"/terms"}
target="_blank"
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onclick="event.stopPropagation()"
>
Terms of Service
</.link>
<span class="text-red-600 dark:text-red-400">*</span>
</label>
</div>
</div>
</div>
<.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"}
</.button>
</.form>
</div>
</div>
</Layouts.app>
"""
end
end