towerops/lib/towerops_web/live/user_registration_live.ex

356 lines
13 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
@impl true
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(:privacy_policy_consent, false)
|> assign(:terms_of_service_consent, false)}
end
@impl true
def handle_params(params, _url, socket) do
invitation_token = Map.get(params, "invitation_token") || Map.get(params, "token")
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
{:noreply,
socket
|> assign(:invitation, invitation)
|> assign(:invitation_token, invitation_token)}
end
@impl true
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
@doc false
def 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}")}
{: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}")}
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
@impl true
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} id="registration-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