326 lines
12 KiB
Elixir
326 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
|
|
|
|
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(: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} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Account created successfully.")
|
|
|> 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
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
|
|
|> redirect(to: ~p"/users/log-in?email=#{user.email}")
|
|
|> assign(:trigger_action, true)}
|
|
else
|
|
{:error, :invalid_invitation} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "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
|