435 lines
12 KiB
Elixir
435 lines
12 KiB
Elixir
defmodule PlausibleWeb.Live.RegisterForm do
|
|
@moduledoc """
|
|
LiveView for registration form.
|
|
"""
|
|
|
|
use PlausibleWeb, :live_view
|
|
|
|
alias Plausible.Auth
|
|
alias Plausible.Repo
|
|
alias Plausible.Teams
|
|
|
|
def mount(params, _session, socket) do
|
|
socket =
|
|
socket
|
|
|> assign_new(:invitation, fn ->
|
|
if invitation_id = params["invitation_id"] do
|
|
find_by_id_unified(invitation_id)
|
|
end
|
|
end)
|
|
|> assign_new(:team_identifier, fn %{invitation: invitation} ->
|
|
if invitation do
|
|
invitation.team_identifier
|
|
end
|
|
end)
|
|
|
|
if socket.assigns.live_action == :register_from_invitation_form and
|
|
socket.assigns.invitation == nil do
|
|
{:ok, assign(socket, invitation_expired: true)}
|
|
else
|
|
changeset =
|
|
if invitation = socket.assigns.invitation do
|
|
Auth.User.settings_changeset(%Auth.User{email: invitation.email})
|
|
else
|
|
Auth.User.settings_changeset(%Auth.User{})
|
|
end
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
form: to_form(changeset),
|
|
captcha_error: nil,
|
|
password_strength: Auth.User.password_strength(changeset),
|
|
disable_submit: false,
|
|
trigger_submit: false
|
|
)}
|
|
end
|
|
end
|
|
|
|
def render(%{invitation_expired: true} = assigns) do
|
|
~H"""
|
|
<div class="mx-auto mt-6 text-center dark:text-gray-300">
|
|
<h1 class="text-3xl font-black">{Plausible.product_name()}</h1>
|
|
<div class="text-xl font-medium">Lightweight and privacy-friendly web analytics</div>
|
|
</div>
|
|
|
|
<div class="w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded-sm px-8 py-6 mb-4 mt-8">
|
|
<h2 class="text-xl font-black dark:text-gray-100">Invitation expired</h2>
|
|
|
|
<p class="mt-4">
|
|
Your invitation has expired or been revoked. Please request fresh one or you can
|
|
<.styled_link href={Routes.auth_path(@socket, :register_form)}>sign up</.styled_link>
|
|
for a 30-day unlimited free trial without an invitation.
|
|
</p>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="mx-auto text-center dark:text-gray-300">
|
|
<h1 class="text-3xl font-black">
|
|
<%= if ce?() or @live_action == :register_from_invitation_form do %>
|
|
Register your {Plausible.product_name()} account
|
|
<% else %>
|
|
Register your 30-day free trial
|
|
<% end %>
|
|
</h1>
|
|
<div class="text-xl font-medium mt-2">
|
|
Set up privacy-friendly analytics with just a few clicks
|
|
</div>
|
|
</div>
|
|
|
|
<PlausibleWeb.Components.FlowProgress.render
|
|
:if={@live_action == :register_form}
|
|
flow={PlausibleWeb.Flows.register()}
|
|
current_step="Register"
|
|
/>
|
|
<PlausibleWeb.Components.FlowProgress.render
|
|
:if={@live_action == :register_from_invitation_form}
|
|
flow={PlausibleWeb.Flows.invitation()}
|
|
current_step="Register"
|
|
/>
|
|
|
|
<.focus_box>
|
|
<:title>
|
|
Enter your details
|
|
</:title>
|
|
|
|
<.form
|
|
:let={f}
|
|
for={@form}
|
|
id="register-form"
|
|
action={Routes.auth_path(@socket, :login)}
|
|
phx-hook="Metrics"
|
|
phx-change="validate"
|
|
phx-submit="register"
|
|
phx-trigger-action={@trigger_submit}
|
|
>
|
|
<input name="user[register_action]" type="hidden" value={@live_action} />
|
|
<input
|
|
:if={@team_identifier}
|
|
name="user[team_identifier]"
|
|
type="hidden"
|
|
value={@team_identifier}
|
|
/>
|
|
|
|
<%= if @invitation do %>
|
|
<.email_input field={f[:email]} for_invitation={true} />
|
|
<.name_input field={f[:name]} />
|
|
<% else %>
|
|
<.name_input field={f[:name]} />
|
|
<.email_input field={f[:email]} for_invitation={false} />
|
|
<% end %>
|
|
|
|
<div class="my-4">
|
|
<div class="flex justify-between">
|
|
<label for={f[:password].name} class="block font-medium text-gray-700 dark:text-gray-300">
|
|
Password
|
|
</label>
|
|
<.password_length_hint minimum={12} field={f[:password]} />
|
|
</div>
|
|
<div class="mt-1">
|
|
<.password_input_with_strength
|
|
field={f[:password]}
|
|
strength={@password_strength}
|
|
phx-debounce={200}
|
|
class="dark:bg-gray-900 shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-full border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="my-4">
|
|
<label
|
|
for={f[:password_confirmation].name}
|
|
class="block font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
Confirm password
|
|
</label>
|
|
<div class="mt-1">
|
|
<.input
|
|
type="password"
|
|
autocomplete="new-password"
|
|
field={f[:password_confirmation]}
|
|
phx-debounce={200}
|
|
class="dark:bg-gray-900 shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-full border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<%= if PlausibleWeb.Captcha.enabled?() do %>
|
|
<div class="mt-4">
|
|
<div
|
|
phx-update="ignore"
|
|
id="hcaptcha-placeholder"
|
|
class="h-captcha"
|
|
data-sitekey={PlausibleWeb.Captcha.sitekey()}
|
|
>
|
|
</div>
|
|
<%= if @captcha_error do %>
|
|
<div class="text-red-500 text-xs italic mt-3" x-data x-init="hcaptcha.reset()">
|
|
{@captcha_error}
|
|
</div>
|
|
<% end %>
|
|
<script
|
|
phx-update="ignore"
|
|
id="hcaptcha-script"
|
|
src="https://hcaptcha.com/1/api.js"
|
|
async
|
|
defer
|
|
>
|
|
</script>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% submit_text =
|
|
if ce?() or @invitation do
|
|
"Create my account"
|
|
else
|
|
"Start my free trial"
|
|
end %>
|
|
<.button id="register" disabled={@disable_submit} type="submit" class="mt-4 w-full">
|
|
{submit_text}
|
|
</.button>
|
|
|
|
<p class="text-center text-gray-600 dark:text-gray-500 mt-4">
|
|
Already have an account?
|
|
<.styled_link href="/login">
|
|
Log in
|
|
</.styled_link>
|
|
</p>
|
|
</.form>
|
|
</.focus_box>
|
|
"""
|
|
end
|
|
|
|
defp name_input(assigns) do
|
|
~H"""
|
|
<div class="my-4">
|
|
<label for={@field.name} class="block font-medium text-gray-700 dark:text-gray-300">
|
|
Full name
|
|
</label>
|
|
<div class="mt-1">
|
|
<.input
|
|
field={@field}
|
|
placeholder="Jane Doe"
|
|
phx-debounce={200}
|
|
class="dark:bg-gray-900 shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-full border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300"
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp email_input(assigns) do
|
|
email_classes = ~w(
|
|
dark:bg-gray-900
|
|
shadow-sm
|
|
focus:ring-indigo-500
|
|
focus:border-indigo-500
|
|
block
|
|
w-full
|
|
border-gray-300
|
|
dark:border-gray-500
|
|
rounded-md
|
|
dark:text-gray-300
|
|
)
|
|
|
|
{email_readonly, email_extra_classes} =
|
|
if assigns[:for_invitation] do
|
|
{[readonly: "readonly"], ["bg-gray-100"]}
|
|
else
|
|
{[], []}
|
|
end
|
|
|
|
assigns =
|
|
assigns
|
|
|> assign(:email_readonly, email_readonly)
|
|
|> assign(:email_classes, email_classes ++ email_extra_classes)
|
|
|
|
~H"""
|
|
<div class="my-4">
|
|
<div class="flex justify-between">
|
|
<label for={@field.name} class="block font-medium text-gray-700 dark:text-gray-300">
|
|
Email
|
|
</label>
|
|
<p class="text-xs text-gray-500 mt-1">No spam, guaranteed.</p>
|
|
</div>
|
|
<div class="mt-1">
|
|
<.input
|
|
type="email"
|
|
field={@field}
|
|
placeholder="example@email.com"
|
|
phx-debounce={200}
|
|
class={@email_classes}
|
|
{@email_readonly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def handle_event("validate", %{"user" => params}, socket) do
|
|
changeset =
|
|
params
|
|
|> Auth.User.new()
|
|
|> Map.put(:action, :validate)
|
|
|
|
password_strength = Auth.User.password_strength(changeset)
|
|
|
|
{:noreply,
|
|
assign(socket,
|
|
form: to_form(changeset),
|
|
password_strength: password_strength,
|
|
captcha_error: nil
|
|
)}
|
|
end
|
|
|
|
def handle_event(
|
|
"register",
|
|
%{"user" => _} = params,
|
|
%{assigns: %{invitation: %{} = invitation}} = socket
|
|
) do
|
|
if not PlausibleWeb.Captcha.enabled?() or
|
|
PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
user =
|
|
params["user"]
|
|
|> Map.put("email", invitation.email)
|
|
|> Auth.User.new()
|
|
|
|
with_team? = invitation.type == :site_transfer
|
|
|
|
add_user(socket, user, with_team?: with_team?)
|
|
else
|
|
{:noreply, assign(socket, :captcha_error, "Please complete the captcha to register")}
|
|
end
|
|
end
|
|
|
|
def handle_event("register", %{"user" => _} = params, socket) do
|
|
if not PlausibleWeb.Captcha.enabled?() or
|
|
PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
|
user = Auth.User.new(params["user"])
|
|
|
|
add_user(socket, user)
|
|
else
|
|
{:noreply, assign(socket, :captcha_error, "Please complete the captcha to register")}
|
|
end
|
|
end
|
|
|
|
def handle_event("send-metrics-after", _params, socket) do
|
|
{:noreply, assign(socket, trigger_submit: true)}
|
|
end
|
|
|
|
defp add_user(socket, user, opts \\ []) do
|
|
result =
|
|
Repo.transaction(fn ->
|
|
do_add_user(user, opts)
|
|
end)
|
|
|
|
case result do
|
|
{:ok, _user} ->
|
|
socket = assign(socket, disable_submit: true)
|
|
|
|
on_ee do
|
|
event_name = "Signup#{if socket.assigns.invitation, do: " via invitation"}"
|
|
{:noreply, push_event(socket, "send-metrics", %{event_name: event_name})}
|
|
else
|
|
{:noreply, assign(socket, trigger_submit: true)}
|
|
end
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
assign(socket,
|
|
form: to_form(Map.put(changeset, :action, :validate))
|
|
)}
|
|
end
|
|
end
|
|
|
|
defp do_add_user(user, opts) do
|
|
case Repo.insert(user) do
|
|
{:ok, user} ->
|
|
if opts[:with_team?] do
|
|
{:ok, _} = Plausible.Teams.get_or_create(user)
|
|
end
|
|
|
|
user
|
|
|
|
{:error, reason} ->
|
|
Repo.rollback(reason)
|
|
end
|
|
end
|
|
|
|
defp find_by_id_unified(invitation_or_transfer_id) do
|
|
result =
|
|
with {:error, :invitation_not_found} <-
|
|
find_team_invitation_by_id_unified(invitation_or_transfer_id),
|
|
{:error, :invitation_not_found} <-
|
|
find_invitation_by_id_unified(invitation_or_transfer_id) do
|
|
find_transfer_by_id_unified(invitation_or_transfer_id)
|
|
end
|
|
|
|
case result do
|
|
{:error, :invitation_not_found} -> nil
|
|
{:ok, unified} -> unified
|
|
end
|
|
end
|
|
|
|
defp find_team_invitation_by_id_unified(id) do
|
|
invitation =
|
|
Teams.Invitation
|
|
|> Repo.get_by(invitation_id: id)
|
|
|> Repo.preload(:team)
|
|
|
|
case invitation do
|
|
nil ->
|
|
{:error, :invitation_not_found}
|
|
|
|
team_invitation ->
|
|
{:ok,
|
|
%{
|
|
type: :team_invitation,
|
|
email: team_invitation.email,
|
|
team_identifier: team_invitation.team.identifier
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp find_invitation_by_id_unified(id) do
|
|
invitation =
|
|
Teams.GuestInvitation
|
|
|> Repo.get_by(invitation_id: id)
|
|
|> Repo.preload(:team_invitation)
|
|
|
|
case invitation do
|
|
nil ->
|
|
{:error, :invitation_not_found}
|
|
|
|
guest_invitation ->
|
|
{:ok,
|
|
%{
|
|
type: :guest_invitation,
|
|
email: guest_invitation.team_invitation.email,
|
|
team_identifier: nil
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp find_transfer_by_id_unified(id) do
|
|
transfer =
|
|
Teams.SiteTransfer
|
|
|> Repo.get_by(transfer_id: id)
|
|
|
|
case transfer do
|
|
nil ->
|
|
{:error, :invitation_not_found}
|
|
|
|
transfer ->
|
|
{:ok,
|
|
%{
|
|
type: :site_transfer,
|
|
email: transfer.email,
|
|
team_identifier: nil
|
|
}}
|
|
end
|
|
end
|
|
end
|